13

I have two buttons, one of type "button" and one of type "submit", both wrapped in a form and which toggle each other. Weirdly, if I click on the button of type "button" the form is submitted and if I click of the button of type "submit" the form is not submitted.

const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);

function App() {
  const [clicked, setClicked] = React.useState(false);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log("form submitted!");
      }}
    >
      {!clicked ? (
        <button type="button" onClick={() => setClicked(true)}>
          Button 1
        </button>
      ) : (
        <button type="submit" onClick={() => setClicked(false)}>
          Button 2
        </button>
      )}
    </form>
  );
}

root.render(
  <App />
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id='root'></div>

I would expect that the opposite be true in regards to submitting the form.

Ahmed Sbai
  • 10,695
  • 9
  • 19
  • 38
TehYoshi
  • 133
  • 6

5 Answers5

8

Try this code, set manually the initial value of the showSubmitButton state to either true or false and you'll see that so far so good, the onSubmit event is looking for an input of type submit to fire and all works fine.
you can also notice that the component rerenders before the onSubmit event handler runs.

import { useState } from "react";

const App = () => {
  const [counter, setCounter] = useState(0);
  const [showSubmitButton, setShowSubmitButton] = useState(true);

  return (
    <>
      {console.log("component rerender and counter is: ", counter)}
      <form
        onSubmit={(e) => {
          console.log(e);
          e.preventDefault();
          console.log("form submitted!");
        }}
      >
        {showSubmitButton ? (
          <button
            type="submit"
            onClick={(e) => {
              console.log("submit button clicked");
              setCounter((prev) => prev + 1);
              // setShowSubmitButton((prev) => !prev);
            }}
          >
            Submit
          </button>
        ) : (
          <button
            type="button"
            onClick={() => {
              console.log("simple button clicked");
              setCounter((prev) => prev + 1);
              // setShowSubmitButton((prev) => !prev);
            }}
          >
            Button
          </button>
        )}
      </form>
    </>
  );
};
export default App

the drama begins when you uncomment setShowSubmitButton((prev) => !prev) in the submit button.
now when you click it and toggle showSubmitButton, the component rerenders it is like the onSubmit event is triggered but cannot fire because the component rerenders and the input of type submit which is mandatory to do so cannot be found so nothing happens, till now, neither one of the two buttons is triggering onSubmit.

now uncomment setShowSubmitButton((prev) => !prev) in the simple button.
you'll see when you click that button the onSubmit event is firing and if you check e.target from inside onSubmit you will find it equal to

<form>
  <button type="submit">Submit</button>
</form>

so when you click the submit button, it seems like the onSubmit event is stuck because the input of type submit cannot be found therefore when you click the simple button, the input of type submit is back to DOM, so the event handler can finally find it and run.
I know this is crazy, but it is the only explanation, there is no way that the simple button is triggering onSubmit.


if you move state updates inside the event handler after e.preventDefault():

 <>
      {console.log("component rerender and counter is: ", counter)}
      <form
        onSubmit={(e) => {
          console.log(e);
          e.preventDefault();
          console.log("form submitted!");
          setCounter((prev) => prev + 1);
          setShowSubmitButton((prev) => !prev);
        }}
      >
        {showSubmitButton ? (
          <button
            type="submit"
            onClick={(e) => {
              console.log("submit button clicked");
            }}
          >
            Submit
          </button>
        ) : (
          <button
            type="button"
            onClick={() => {
              console.log("simple button clicked");
            }}
          >
            Button
          </button>
        )}
      </form>
    </>
  );

you will see it working as expected! because the component will rerender only when the code inside the onSubmit event handler function finishes

Ahmed Sbai
  • 10,695
  • 9
  • 19
  • 38
  • Correct me if I'm wrong, but clicking the button with type='button' will not submit the form, so it will not switch to the submit button right? – Chris Hamilton Jul 03 '23 at 15:06
  • @ChrisHamilton it will not submit the form but it will run its own onClick event handler that updates the state so the submit button appears therefore the onSubmit event can finally run, it is like it is triggerend (when submit is clicked) but cannot fire unless the input of type submit can be found in DOM – Ahmed Sbai Jul 03 '23 at 19:43
  • But the onClick event handler you showed does not update the state, and if you call `setShowSubmitButton` there, you will just get the same issue, ie. the form submits. So this doesn't solve the problem? Am I missing something? – Chris Hamilton Jul 09 '23 at 14:14
  • I think there's a misunderstanding - you said "it seems like the onSubmit event is stuck because the input of type submit cannot be found therefore when you click the simple button, the input of type submit is back to DOM, so the event handler can finally find it and run." - that's not true since the first click of the `type=button` submits the form, supposedly before the `type=submit` button ever existed. The explanation is that the button type gets checked after they are swapped. – Chris Hamilton Jul 09 '23 at 14:49
6

Through some testing I can guess this is because events that are triggered before the buttons swap are executing after the buttons swap. I can reproduce this by just making a submit button disappear and reappear after clicking.

const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);

function RegularForm() {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log('form submitted!');
      }}
    >
      <p>Regular</p>
      <button type="submit">Type "submit"</button>
    </form>
  );
}

function BuggedForm() {
  const [show, setShow] = React.useState(true);
  const onClick = () => {
    setShow(false);
    setTimeout(() => setShow(true));
  };

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log('form submitted!');
      }}
    >
      <p>Bugged</p>
      {show && (
        <button type="submit" onClick={onClick}>
          Type "submit"
        </button>
      )}
    </form>
  );
}

root.render(
  <div>
    <RegularForm />
    <BuggedForm />
  </div>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id='root'></div>

As you can see, if the button disappears right after clicking, the form does not submit. The event probably tried to execute when the button was not present in the DOM, and nothing happened.

As for why the 'button' type submits the form, it's probably because the button type gets updated to 'submit' before the event executes. If you change both types to 'button' the form does not submit.

const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);

function App() {
  const [clicked, setClicked] = React.useState(false);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log('form submitted!');
      }}
    >
      {!clicked ? (
        <button type="button" onClick={() => setClicked(true)}>
          Button 1
        </button>
      ) : (
        <button type="button" onClick={() => setClicked(false)}>
          Button 2
        </button>
      )}
    </form>
  );
}

root.render(
  <App />
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id='root'></div>

Hard to say whether this is a bug, or just jank that comes with the virtual DOM.


As for a solution, you can use a zero delay timeout to push the state change to the back of the queue. ie. after the events run their course.

const rootElement = document.getElementById('root');
const root = ReactDOM.createRoot(rootElement);

function App() {
  const [clicked, setClicked] = React.useState(false);
  const toggleClicked = () => setClicked((prev) => !prev);
  // React Jank: Let form events finish before toggling
  const onClick = () => setTimeout(toggleClicked);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log('form submitted!');
      }}
    >
      {!clicked ? (
        <button type="button" onClick={onClick}>
          Type 'button'
        </button>
      ) : (
        <button type="submit" onClick={onClick}>
          Type 'submit'
        </button>
      )}
    </form>
  );
}

root.render(
  <App />
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id='root'></div>

More info on zero delays: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop#zero_delays

Chris Hamilton
  • 9,252
  • 1
  • 9
  • 26
5

I can't fully explain what's happening here, but it seems like--and I don't say this lightly--it might be a bug in React.

A few observations:

  1. If you log event.nativeEvent.submitter within the onSubmit handler you'll see Button 2 submitted the form despite having clicked Button 1.

  2. If you change Button 2 to <input type="submit" value="Button 2" /> it behaves as you would expect.

  3. If you preventDefault in Button 1's click handler, neither button submits the form.

  4. If you wrap the setClicked calls in a setTimeout it behaves as you'd expect. (See sample code below.)

Not sure what's going on here but it seems like there's a timing problem between the re-render from the state update and the dispatching and propagation of the click event. (My spidey-sense is telling me that someone smarter than me is going to come along with a much simpler explanation and I'm going to feel dumb for having suggested it's a bug in React.)

This feels like a bit of a hack, but if you're in a big hurry wrapping the state update in a setTimeout fixes it:

{!clicked ? (
  <button
    type="button"
    onClick={() => {
      setTimeout(() => setClicked(true), 1);
    }}
  >
    Button 1
  </button>
) : (
  <button
    type="submit"
    onClick={(e) => {
      setTimeout(() => setClicked(false), 1);
    }}
  >
    Button 2
  </button>
)}
ray
  • 26,557
  • 5
  • 28
  • 27
5

There are multiple things to point out here. Let me try and explain.

  1. First point is already explained by Ray, about the re-render issue. The component is rendered before the submit event is handled for the "submit". Generated Code for the above statement - here
  2. Second and probably main point is that, in react form, click event of the first button is captured by the second button because react thinks that they are the same button. This can be avoided by adding the key property with each button to distinguish them and to stop capturing the click event for other button. So, if you don't change states in onclick method for the second button, you will see that, if you click any button inside a form, it will get submitted. Generated code for this statement - here
  3. To get rid of this, just add key property for each button, so that react doesn't get confused in handling event for the respective button. Generated code for this statement - here

The last link is probably going to clear all of your issues. Let me know if there is any issue or something I failed to explain.

Sorry for the bad explanation, I guess.

koushik deb
  • 158
  • 10
  • FWIW Itried adding a key to each button during my exploration and it did not fix it. (It’s possible that I didn’t refresh or something, but I did try this.) – ray Jul 03 '23 at 19:31
  • Hello ray, Can you show me the code, please? And, did you look at the third point where I fixed the issue? Was there any confusion? Thanks. – koushik deb Jul 04 '23 at 02:33
  • Adding a key does solve the issue of the form submitting when `type=button`. But it doesn't solve the issue of the form not submitting when `type=submit`. You used a `setTimeout` to solve that. – Chris Hamilton Jul 09 '23 at 14:31
  • Hey Chris, The setTimeout is there just to show that it does submit when you press the button, but as the state changes and it quickly renders even before the event is handled, it seems like the form is not submitting. To properly visualize the scenario, I used setTimeout. Thanks. – koushik deb Jul 10 '23 at 03:19
3

If you remove the conditional operator, it is working as expected - the type="submit" actually submits the form. This suggests that the type of button updated before event bubbling. This might be a bug.

Mayank Kumar Chaudhari
  • 16,027
  • 10
  • 55
  • 122