skip to Main Content

I have 2 buttons, one type "button" and one 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.

4

Answers


  1. 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>
    )}
    
    Login or Signup to reply.
  2. 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(true);
    
      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

    Login or Signup to reply.
  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.

    Login or Signup to reply.
  4. 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
    

    you can try this code, you can 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.

    all begins when you uncomment setShowSubmitButton((prev) => !prev) in the submit button when you toggle that and the component rerenders it seems like the onSubmit event is triggered but the event handler function cannot be found so nothing happens, however, when you uncomment the same in the simple button, and when you click it, the component rerenders and the event handler can finally fire because the the inout of type sumbit is back in DOM.
    I know this is crazy but 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 after the event handler function finishes

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search