React Form being submitted at the wrong time
Asked Answered
E

5

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.

Elver answered 3/7, 2023 at 2:47 Comment(1)
Reproduced here ~ codesandbox.io/s/hopeful-cohen-9ryt3x?file=/src/App.jsRaising
D
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

Darceldarcey answered 3/7, 2023 at 4:23 Comment(4)
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?Driven
@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 DOMDarceldarcey
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?Driven
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.Driven
D
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

Driven answered 3/7, 2023 at 4:0 Comment(0)
F
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>
)}
Fagen answered 3/7, 2023 at 3:38 Comment(2)
Hey, just FYI no need for the 1 ms delay. The default value of 0 is enough to push it to the back of the queue.Driven
Ha. I know this but it still weirds me out for some unknown irrational reason.Fagen
S
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.

Spate answered 3/7, 2023 at 10:12 Comment(5)
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.)Fagen
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.Spate
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.Driven
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.Spate
adding key worked fo meChromatolysis
J
2

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.

Jacksmelt answered 3/7, 2023 at 4:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.