Forms & React's Virtual Dom

I've been working with HTML for over 20 years so I know a thing or two about how it works. When I decided to shift from a full-stack ASP.NET developer to primarily focusing on where I'm the most passionate about, Front End, I picked up React and ran. I love React and don't see myself switching to another framework for a long time, especially with all the great stuff the Remix team is making with their framework (I guess I'm full stack again? lol), but the other day I ran into something that tripped me up about React's Virtual Dom.
The Scenario
Let's first lay out the scenario. I was tasked with creating a form, one of the fields in our form was to select an assignee (think of an assignee as an existing contact in your app). When you click a "select assignee" you get a modal dialog with tools to help you find who you are looking for, you select it, then hit the submit button for the modal. This updates the form with the selected assignee.
We have 2 forms, the main form on the page, and one in our dialog form. In the HTML world, this is pretty straightforward and works fine. The idea is the dialog would be at the bottom of the body, you can submit the dialog form and then javascript would take over, validate that form and populate that data in the main form.
<form>
<input type='hidden' name='assigneeId' />
<button type='button'>Select Assignee</button>
<!-- other fields and such -->
</form>
<dialog id='select-assignee'>
<form>
<!-- UI to search and find an assignee -->
<input type='hidden' name='assigneeId' />
<button type='submit'>Select</button>
</form>
</dialog>
"Why isn't the dialog inside the main form? why have 2 forms?" - no one
When you press the Select Assignee button in the main form, we open the dialog. That dialog can be canceled at any time by closing it out by pressing ESC, hitting the X close button or clicking Cancel. We don't want to change the underlying form until they submit the dialog form.
React Portals
One of my favorite features of React is how easy it is to create dialogs that render their dom at the bottom of your body. This is pretty standard practice to get all the z-indexing to work nicely and have your dialog be on top of everything on your page. The added benefit here is that your portaled element still has access to all the parent contexts. Portaling something in React, keeps it as a child in React's virtual dom.
Let's look at a striped down version of our main form and dialog form in React.
function MainForm() {
return <form>
<input type='hidden' name='assigneeId' />
<button type='button'>Select Assignee</button>
<DialogForm />
{/* other fields and such */}
</form>
}
function DialogForm() {
return React.createPortal(<dialog id='select-assignee'>
<form>
{/* UI to search and find an assignee */}
<input type='hidden' name='assigneeId' />
<button type='submit'>Select</button>
</form>
</dialog>, document.body)
}
If you know the virtual dom well, you've probably already put two and two together. Even though the rendered HTML looks like our first example, React's component tree looks different. Our two forms are still nested.
MainForm
- form
- input
- button
- DialogForm
- form
- input
- button
So when you submit the dialog form, you are also submitting the main form. As someone who has spent 20+ years with HTML, this threw me. I knew about the virtual dom, and I feel like I know a lot more than most React devs. For whatever reason this didn't make sense until I fully isolated it then the lightbulb came on.
Solutions
As with all things, there are many ways to potentially fix this. 3 come to mind quickly.
Move
<DialogForm>outside of the main form.event.stopPropagation()on the submit handler for the dialog form.Don't use a
<form>in the dialog form.
1. Move <DialogForm> Outside of the Main Form
This is a solution and one that will work. In my scenario at work, having more context would tell you this was not an easy option. At work, we create many different form elements. Ranging from <TextField /> to <DatePicker /> to <AssigneeSelector />. You see where I'm going with this. Our end goal is to allow our developers to build forms quickly that have complicated fields. Building a form is as simple as...
<Form>
<TextField label='Title' name='title' />
<Select label='type' name='type' />
<AssigneeSelector label='Assignee' name='assigneeId' />
</Form>
2. event.stopPropagation() on the Submit Handler for the Dialog Form
Depending on the complexity of the dialog form. This solution works rather well and allows our nested form to work without trying to submit our main form.
interface DialogFormProps {
open?: boolean
onSelected: (assigneeId: string) => void
}
function DialogForm({ open, onSelected}: DialogFormProps) {
const onSubmit = (event: React.FormEvent) => {
event.preventDefault()
event.stopPropagation()
// do validation on the form
onSelected(selectedAssigneeId)
}
return React.createPortal(<dialog id='select-assignee'>
<form onSubmit={onSubmit}>
{/* UI to search and find an assignee */}
<input type='hidden' name='assigneeId' />
<button type='submit'>Select</button>
</form>
</dialog>, document.body)
}
3. Don't use a <form> in the Dialog Form.
There's a lot to be considered with an option like this. It all depends on the complexity of the form in your dialog. Are there lots of fields that need validation to run against? Are you using a 3rd party validation tool that knows how to handle forms? etc.
We have a few of these components that don't use a <form> because the dialog might be a simple list that you scroll through and select a single item from. Not hard to handle a little state and there's virtually no validation needed. Did you select something or not?
Final Remarks
React's virtual dom is not a perfect 1 to 1 match of what gets rendered in the browser's dom. Portaling elements give you a lot of power to get dialogs, toasts, dropdowns, etc flexibility of where they render in the browser's dom. But at the end of the day, it's React's virtual dom that is handling events just because your HTML forms aren't nested, doesn't mean your React forms aren't.

