One React mistake that's slowing you down
Simplify and speed up your app development using React composition
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:
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.
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.
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.
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.
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:
event.preventDefault
because that’s handled for us by Reactaction
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.useFormStatus
.There are a couple ways to manage the pending states for this interaction.
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.
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!
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.
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.
Delivered straight to your inbox.
Simplify and speed up your app development using React composition
Why can't React just magically know what to do without a key?
Is your app as fast as you think it is for your users?
Excellent TypeScript definitions for your React forms