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
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:
If you log
event.nativeEvent.submitter
within the onSubmit handler you’ll see Button 2 submitted the form despite having clicked Button 1.If you change Button 2 to
<input type="submit" value="Button 2" />
it behaves as you would expect.If you
preventDefault
in Button 1’s click handler, neither button submits the form.If you wrap the
setClicked
calls in asetTimeout
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:
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.
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.
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.
More info on zero delays: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop#zero_delays
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.
you can try this code, you can set manually the initial value of the
showSubmitButton
state to eithertrue
orfalse
and you’ll see that so far so good, theonSubmit
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 theonSubmit
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()
:you will see it working as expected! because the component will rerender after the event handler function finishes