Articles

A deep dive on forms with modern React

Kent C. Dodds
Kent C. Dodds

In this post, we’re going to dive deep on how you use modern React APIs for building forms. We will not be using third party libraries or frameworks. Understanding these lower level primitives will unlock them for you when using libraries and frameworks.

I recently heard someone say that web applications are just a skin on top of a database. And that’s actually pretty accurate. Since the beginning of the web, modern web applications have had two sides to them:

  1. Users view data
  2. Users mutate data

Loading data into a web application is a pretty straightforward proposition. It’s only when we start talking about needing to modify that data and keep things in sync that things get more complicated.

From day 1, HTML supported mechanisms for both of these use cases.

Viewing Data

This allows the user to navigate to another page to view more data:

<a href="/remix-utah/events/301213597">Remix Meetup June 🏖</a>

This also allows the user to navigate to another page to view more data, but enables them to control the data that comes back by including the user’s input in the generated URL:

<form action="/remix-utah/events/search">
<label for="event-search">Query</label>
<input id="event-search" type="search" name="query" />
<button type="submit">Search</button>
</form>

Let’s say the user types “August” in the input and hits enter. Thanks to the action the user would be sent to /remix-utah/events/search?query=August. So by default, <form> is just like <a> with the special ability of allowing the user to provide input.

Mutating data

For both <form> and <a>, the navigation is performed via a GET request, which makes sense because you’re trying to GET data. But what if the user is trying to modify the data?

For example, let’s say you want to be able to sign up for an event with /remix-utah/events/301213597/join. If we made that endpoint a GET then we could do that like so:

<a href="/remix-utah/events/301213597/join">Join Remix Meetup June 🏖</a>

This would certainly work (you could even use a <form> with a submit button if you wanted. However, this would also open your users up to a potential problem: accidental or malicious modification of data. Since GET requests are designed to be idempotent (they should be callable repeatedly without observable effects), using them for state-changing actions can lead to unintended consequences.

Additionally, if you made a login form using a GET request, then the user submits their password and the URL looks like this: /login?username=kody&password=kodylovesyou! The user’s submission is in plain text in plain sight!

This is part of the reason we have POST requests and while you can’t make <a> use POST instead of GET, you can do this with <form>.

POST requests are designed for submitting data to be processed to a specified resource. There’s no feature of HTML that triggers a POST request without user interaction, so they can safely be used to change the state of the server, which is perfect for our scenario of joining an event. Here’s how you can use a form to POST data:

<form action="/remix-utah/events/301213597/join" method="POST">
<button type="submit">Join Remix Meetup June 🏖</button>
</form>

By using method="POST", we indicate that this form is meant to modify data on the server. The server will then process this request and update its state accordingly, ensuring that the user's intent to join the event is recorded.

Handling Data Mutations with JavaScript

While traditional form submissions cause a full page reload, modern web applications often use JavaScript to handle these actions asynchronously, providing a smoother user experience.

Here's an example of how you might handle a form submission with JavaScript:

<form id="join-event-form">
<button type="submit">Join Remix Meetup June 🏖</button>
</form>
<script type="module">
const form = document.getElementById('join-event-form')
form.addEventListener('submit', async (event) => {
event.preventDefault()
const response = await fetch('/remix-utah/events/301213597/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ /* any necessary data can go here */ }),
})
if (response.ok) {
alert('Successfully joined the event!')
} else {
alert('Failed to join the event.')
}
})
</script>

This script intercepts the form submission, prevents the default browser behavior, and instead sends an asynchronous POST request to the server. This way, the page does not need to reload, and the user receives immediate feedback on their action.

You may notice the lack of method="POST" in this example and that’s because it’s not strictly necessary since we prevent the browser’s default behavior.

While preventing the full page refresh is great, we lose a lot of things the browser does for us automatically when we prevent that default behavior which result in bugs we find in applications all the time.

For example, race conditions and data revalidation. That said, if you have a framework that helps you with those things (like React Router/Remix), then the effort is worth the improved user experience.

React Form Actions

Mutations are an important and common part of dynamic web applications. So is linking to different parts of the application. However, mutating data is complicated because as soon as the user has performed the mutation, there are parts of the UI they could be looking at that could be out of date.

Now, to be clear, with a regular browser mutation there isn’t a problem because the full page refresh means the user always sees the latest that the server has to offer. However, if we’re focused on providing the best user experience possible, the full page refresh is unacceptable. But that means we need to update the data on the page after the mutation.

Another thing that complicates both links and forms in client-side applications is managing pending states. By default, the browser will show a spinner (often in place of the favicon), but when we prevent that default behavior we need to think about giving feedback to the user during the transition.

React has a great solution for transitions, but that’s only half of the story for forms. Because of this added complexity, React 19 has a built-in mechanism for handling forms called “actions.”

Just like the action attribute in an HTML form, you use the action prop in a React component. However, in addition to a URL, you can instead provide a function which will be called upon submission of the form.

function JoinEventForm() {
function joinEvent(formData) {
const response = await fetch('/remix-utah/events/301213597/join', {
method: 'POST',
body: formData,
})
if (response.ok) {
alert('Successfully joined the event!')
} else {
alert('Failed to join the event.')
}
}
return (
<form action={joinEvent}>
<button type="submit">Join Remix Meetup June 🏖</button>
</form>
)
}

If you’re familiar with React forms using the onSubmit prop, I want you to note a few key differences here:

  1. There’s no need to add event.preventDefault because that’s handled for us by React
  2. The action is automatically treated as a transition, so anything that happens during the action which trigger components to suspend will be a part of that same transition. More on this in a bit.
  3. We can hook into the pending state of this action using useFormStatus .
  4. React manages errors and race conditions to ensure our form’s state is always correct (no infinite spinners).

Pending States

There are a couple ways to manage the pending states for this interaction.

useFormStatus

If you’re familiar with how context works in React apps, think of the <form> component as a provider and the useFormStatus hook as a function which accesses that provider’s data. Fundamentally, the <form> in React manages state of the status of the form and we can access that in any child component of the <form> . So to access the pending state of our form (while the submission is in flight) we need to create a child component.

function JoinButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<button type="submit">
{pending ? 'Joining...' : children}
</button>
)
}
function JoinEventForm() {
async function joinEvent(formData) {
const response = await fetch('/remix-utah/events/301213597/join', {
method: 'POST',
body: formData,
})
if (response.ok) {
alert('Successfully joined the event!')
} else {
alert('Failed to join the event.')
}
}
return (
<form action={joinEvent}>
<JoinButton>Join Remix Meetup June 🏖</JoinButton>
</form>
)
}

The cool thing about the useFormStatus hook is that it also gives you access to other things about the form (including the submitted data). So if you’re building a login form, you could have the pending state say: logging you in as {data.get('username')} .

One thing I don’t love about it though is the requirement to make an entirely new component to consume the form’s state, so let’s look at an alternative approach.

useActionState

It’s easiest to explain useActionState with an example:

const JOIN_URL = '/remix-utah/events/301213597/join'
async function joinEvent(
previousState: { joined: boolean },
formData: FormData,
) {
const response = await fetch(JOIN_URL, {
method: 'POST',
body: formData,
})
if (response.ok) {
return { joined: true }
} else {
return { joined: false }
}
}
function JoinEventForm() {
const [state, formAction, isPending] = useActionState(
joinEvent,
{ joined: false },
JOIN_URL,
)
return (
<div>
{state.joined ? (
<p>See you there!</p>
) : (
<form action={formAction}>
<button type="submit">
{isPending ? 'Joining...' : 'Join Remix Meetup June 🏖'}
</button>
</form>
)}
</div>
)
}

Alright, so what’s going on here is useActionState accepts a function (joinEvent), some initial state ({ joined: false }), and optionally a permalink URL (JOIN_URL). It then returns an array with the current state (state), a function you can call to trigger the action (formAction), and whether the form submission is pending (isPending).

The joinEvent function accepts the previous state. In a way you can think of it as a reducer a la useReducer. When our form is submitted, it’s action is called with the formData. Our action is the formAction. The formAction function will call joinEvent with the current state followed by any arguments it’s called with. I understand that may be a little confusing, so let me illustrate by being obnoxiously verbose:

function JoinEventForm() {
const [state, formAction, isPending] = useActionState(
(prevState, ...args) => joinEvent(prevState, ...args),
{ joined: false },
JOIN_URL,
)
return (
<div>
{state.joined ? (
<p>See you there!</p>
) : (
<form
action={(formData) => {
const args = [formData]
formAction(...args)
}}
>
<button type="submit">
{isPending ? 'Joining...' : 'Join Remix Meetup June 🏖'}
</button>
</form>
)}
</div>
)
}

The action prop is called with the formData, we then make an array called args with only one element of formData which we then spread into a call of formAction. formAction then calls our inline function with the prevState and the rest of the args (in our case that’s just the formData, but as far as useActionState is concerned, it doesn’t have to be. It just forwards along whatever arguments there are.

From there, our joinEvent function performs whatever async stuff it needs to do, and then it returns a value which ends the transition and triggers our form to rerender with the state being equal to our returned value.

Of course during the transition, isPending will be true so we can show our pending state. However, we don’t have access to the formData outside our action so we wouldn’t be able to use the form submission as part of our pending UI like we could with useFormStatus. But there’s even more we can do!

Before we talk about that, I’ll just briefly mention that the third argument to useActionState is called a permalink and it’s there for server rendering and progressive enhancement. What React does with that is set the action to the URL you provide during server rendering. That way if the form is submitted before React has a chance to load on the page, the regular browser behavior will take over and submit the form with the full-page refresh functionality we’re used to (you’ll just want to make sure your server can handle the form submission properly). Hooray for progressive enhancement!

useOptimistic

Showing the user feedback of pending tasks is important and useful. It’s awesome if the pending state actually looks like the finished state (maybe with a subtle indicator that it’s not actually complete). A good example of this is when you send a Slack/Discord message. The message appears in the messages list, but it’s slightly transparent to give you the sense that it’s an incomplete mutation.

You can do this with useOptimistic:

// ...
function JoinEventForm() {
const [state, formAction] = useActionState(
joinEvent,
{ joined: false },
JOIN_URL,
)
const [optimisticJoined, setOptimisticJoined] = useOptimistic(state.joined)
return (
<div>
{optimisticJoined ? (
// show faded a bit if we've not yet finished joining...
<p style={{ opacity: state.joined ? 1 : 0.8 }}>See you there!</p>
) : (
<form
action={(formData) => {
setOptimisticJoined(true) // Optimistically set the state to joined
return formAction(formData)
}}
>
<button type="submit">
Join Remix Meetup June 🏖
</button>
</form>
)}
</div>
)
}

The useOptimistic hook allows you to immediately update the UI to reflect the user's action before the network request completes. If the actual request fails, the state is reverted to the previous state. This provides a smoother user experience by reducing the perceived latency of the action.

We can even remove the pending UI from the button, but we add a little transparency to the success message to give the impression things are not completely settled. You may want to consider the experience for non-seeing users as well. React provides us with all we need to build this kind of experience!

You can even perform multiple steps and keep the user up-to-date with what’s happening with useOptimistic. It’s awesome:

// ...
function JoinEventForm() {
const [state, formAction] = useActionState(
joinEvent,
{ joined: false },
JOIN_URL,
)
const [optimisticMessage, setOptimisticMessage] = useOptimistic("")
return (
<div>
{state.joined ? (
<p>See you there!</p>
) : (
<form
action={async (formData) => {
setOptimisticMessage("Joining meetup...")
await formAction(formData)
setOptimisticMessage("Sending notifications...")
await sendNotification()
}}
>
<p>{optimisticMessage}</p>
<button type="submit">
Join Remix Meetup June 🏖
</button>
</form>
)}
</div>
)
}

In this example, we use the useOptimistic hook to provide feedback to the user at multiple stages of the form submission process. Initially, we set the optimistic message to "Joining meetup...", then await the formAction. Once that completes, we update the message to "Sending notification..." and await the sendNotification function.

Conclusion

I think it’s awesome that we’ve not needed to reach for any libraries other than React to have all of this functionality. Really, what’s cool is what you don’t see but get like declarative management of errors and pending transitions and proper management of race conditions. And a great Optimistic UI story is also awesome. We also didn’t get to talk much about how this integrates with the server via the "use server" directive, but that’s a huge win as well which we’ll have to explore in the future.

I hope this article helped you get a more clear picture of what’s possible with React 19’s form primitives.

Get my free 7-part email course on React!

Delivered straight to your inbox.