Clean up React Form with useActionState


If you can't just use an HTML form, here's how you can get closer and clean up your React form with the React.useActionState hook.

Handling form state in React can get hairy. There are additional libraries on top of React to try to deal with this.

In React 19, we have a new hook inside of React itself to help us with form state. Hey, accreting more to the React API continues to erode the simplicity of fn (data) => ui, but in this case, it's an improvement. Instead of a bunch of React.useState and handleChange event handlers, we get something more compact to the purpose of form handling.

Here's an example of how I'm using this hook in form handling, top to bottom. The example code is from a form for creating a new permission. The permission is identified by a slug string. There's just that one field in the form.

'use client'

import React from 'react'
import { formatPrimaryErrorMessage, SchemaValidationError, validateUnderscoreSlug, findFieldError, validateFormFn, Spinner, rpc } from './not-shown'

export function MyForm(props: { slug?: string }) {
  const [formState, formAction, isPending] = React.useActionState(handleSubmit, undefined)
  const error = findFieldError('slug', formState)
  return (
    <form action={formAction}>
      <label>
        Permission
        <input type="text" name="slug" defaultValue={props.slug} />
      </label>
      {error && <div>{formatErrorMessage(error)}</div>}
      {isPending && <Spinner />}
      <button disabled={isPending}>
        Add
      </button>
    </form>
  )
}

const validateForm = validateFormFn<PermissionSubmission>({
  slug: [validateUnderscoreSlug],
})

async function handleSubmit(_prevState: unknown, formData: FormData): Promise<Result<Permission>> {
  const submission = mapSubmission(formData)
  if (validateForm(submission)) {
    const result = await rpc(submission)
    return result
  } else {
    return { ok: false, error: new SchemaValidationError(validateForm.errors) }
  }
}

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }

interface PermissionSubmission {
  slug: string
}

function mapSubmission(form: FormData): PermissionSubmission {
  return { slug: form.get('slug') as string }
}

So, how does it work?

React.useActionState tracks several things:

  1. formState - what the form action handler returns
  2. formAction - the form action handler, which is called on form submittion
  3. isPending - whether the form has been submitted and has yet to resolve

When you set up useActionState, you pass the raw action handler. It gets wrapped so that you can pass it later to the jsx form action prop. As a second param, you pass the initial state of your form.

In my case, I'm storing a Result type as the form state. I've included the Result type definition here for clarity. It is a type that stores either a successful return of data or an error condition. I'm starting with an undefined result, meaning that the result has not returned, one way or the other.

If there's a slug to begin with, that is passed as the defaultValue prop to the input field. Later, when the user types something, that newly-typed value will be what is submitted. Note that in handleSubmit, formData is parsed and validated, then used as input for an async "rpc" request.

So what's better about this?

You can use the form action, which feels more native to web platform. And, if you SSR this form, it'll gracefully degrade to an HTML form that can submit to the server.

There are fewer handlers applied to individual input fields. The inputs are not controlled. This would be even more obvious if this example was a form with a dozen fields. There would be one handler, and FormData would contain all the values at submit time.

formState does gets updated by the hook. Using a result type, I can pull the return state of the permission from it (eg, if I had an onSuccess callback prop in this form component) or the form errors (as shown).

Anyway, it feels like a nice hook to do a common thing. It's not as simple as an HTML form. But if you need to do a multi-field form submit and keep it all on the client, it's a pretty nice option.

How else have you been using this hook? I would like to use something like this beyond onSubmit for something like onChange. Has anyone figured that out?