Skip to content

React’s useActionState: The Hook That Replaced My Form Reducers

React’s useActionState: The Hook That Replaced My Form Reducers

Short version for the impatient: useActionState replaces about 80% of the cases where I used to reach for useReducer in a form. The other 20% are still better with useReducer, and I’ll cover that too. If you’re already convinced, jump straight to the example.

Here’s the longer story.

I have a love-hate relationship with form state in React. I’ve tried Formik, react-hook-form, zustand-as-form-store, plain useState, plain useReducer, and a few in-house abominations. Each one has a moment where it’s great and a moment where you wonder why you didn’t just use the platform. Server-rendered forms with progressive enhancement felt like the right shape, but until React 19 the JS-side ergonomics were rough.

Then I started using useActionState properly, and a lot of the form code I had stopped feeling like form code. It started looking like business logic that happened to be triggered by a form. Which is what I’d wanted the whole time.

The form-state pattern I’d been using for years

Old way, with useReducer:

function reducer(state, action) {
  switch (action.type) {
    case 'submit/start':
      return { ...state, submitting: true, error: null };
    case 'submit/error':
      return { ...state, submitting: false, error: action.error };
    case 'submit/done':
      return { ...state, submitting: false, data: action.data };
    default:
      return state;
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(reducer, {
    submitting: false,
    error: null,
    data: null,
  });

  async function onSubmit(e) {
    e.preventDefault();
    dispatch({ type: 'submit/start' });
    try {
      const res = await api.signup(new FormData(e.target));
      dispatch({ type: 'submit/done', data: res });
    } catch (err) {
      dispatch({ type: 'submit/error', error: err.message });
    }
  }

  return (
    <form onSubmit={onSubmit}>
      {/* ... fields ... */}
      <button disabled={state.submitting}>
        {state.submitting ? 'Signing up...' : 'Sign up'}
      </button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

This works. I shipped this pattern for years. But every form I wrote had a near-identical reducer with the same three actions, copy-pasted with a slightly different naming convention each time.

What useActionState actually does

useActionState is a hook that takes an async action function shaped like (prevState, formData) => newState, plus an initial state. It returns the current state (whatever your action returned last), a dispatch function you put in the form’s action prop, and a pending boolean.

The hook handles the submit lifecycle for you. No more submitting flag. No more submit/start action. No more wrapping every API call in try/catch just to dispatch the error.

import { useActionState } from 'react';

async function signupAction(prevState, formData) {
  try {
    const res = await api.signup(formData);
    return { ok: true, data: res };
  } catch (err) {
    return { ok: false, error: err.message };
  }
}

function SignupForm() {
  const [state, formAction, pending] = useActionState(signupAction, null);

  return (
    <form action={formAction}>
      {/* ... fields ... */}
      <button disabled={pending}>
        {pending ? 'Signing up...' : 'Sign up'}
      </button>
      {state?.ok === false && <p>{state.error}</p>}
    </form>
  );
}

Same form, half the code. The reducer is gone. The submitting flag goes with it. So does the in-component try/catch, though I still wrap the action body in one to convert errors into return values, which is what useActionState wants.

The full API is in the React docs. The short version: think of your action as a function that takes “where I was” plus “what the user submitted” and returns “where I am now”. That’s it.

The smallest possible example

Form with a single name field. Server validation. No imports beyond React.

async function greetAction(prev, formData) {
  const name = formData.get('name')?.toString().trim();
  if (!name) return { error: 'name is required' };
  await new Promise((r) => setTimeout(r, 400)); // pretend it's a fetch
  return { greeting: `hi ${name}` };
}

export function Greeter() {
  const [state, action, pending] = useActionState(greetAction, {});
  return (
    <form action={action}>
      <input name="name" defaultValue="" />
      <button disabled={pending}>say hi</button>
      {state.error && <p style={{ color: 'crimson' }}>{state.error}</p>}
      {state.greeting && <p>{state.greeting}</p>}
    </form>
  );
}

Three things in this snippet that surprised me on day one.

The form’s action prop now takes a function. In React 18 this would be unusual; in React 19 it’s expected. You can still use onSubmit if you want, but action is the idiomatic path with this hook.

The action function receives FormData directly. You don’t need to read inputs by ref. You don’t need controlled inputs. defaultValue is enough.

If you wire it up via a server action, the action runs even when JS hasn’t loaded yet. That’s the part I find fun.

Pairing it with server actions

If you’re on Next.js or any setup with server actions, useActionState becomes the client-side complement. The server action is the function. useActionState is the hook that holds its return value and exposes pending.

// app/actions.ts
'use server';

export async function createPost(prev, formData) {
  const title = formData.get('title')?.toString();
  if (!title) return { error: 'title is required' };

  await db.post.create({ data: { title } });
  revalidatePath('/posts');
  return { ok: true };
}
// app/posts/new-post-form.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '../actions';

export function NewPostForm() {
  const [state, action, pending] = useActionState(createPost, {});
  return (
    <form action={action}>
      <input name="title" />
      <button disabled={pending}>Create</button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

The form posts to the server even before client JS hydrates. After hydration, the same action runs over RPC, returns its value, and useActionState hands it to your component. Progressive enhancement that doesn’t feel like progressive enhancement.

I covered the broader story of why I’d already moved most data mutations to server actions in my server-actions notes, so I won’t replay it here.

Wait, isn’t this called useFormState?

If you tried this hook before React 19 stable, you probably know it as useFormState from react-dom. They renamed it to useActionState and moved it to react itself. Same hook, slightly broader name because it isn’t strictly tied to forms.

If you’re upgrading an app and you see

import { useFormState } from 'react-dom';

that’s the old import. The replacement is

import { useActionState } from 'react';

The API shape is the same. Codemods exist but I usually find-and-replace by hand because there are usually fewer than ten call sites in any one app.

Where I still reach for useReducer

useActionState isn’t a replacement for useReducer in general. It’s a replacement for one specific pattern: form submit with pending and error state. I still reach for useReducer when:

  • The state has more than two or three meaningfully-different shapes, like a wizard with five steps or a chat with optimistic messages plus errors plus a retry queue.
  • The transitions are driven by something other than a form submit: mouse events, websocket messages, timers.
  • I need to dispatch from places that aren’t a form submit, like cancel buttons or undo buttons.

For those, useReducer’s explicit action types still earn their keep. The “what changed and why” trail is worth more than the boilerplate it costs.

For everything else, which is about three quarters of the forms I’ve shipped this year, useActionState wins.

The error-handling pattern I settled on

A subtle thing the docs don’t emphasise: throw vs return. If your action throws, useActionState surfaces the error to the nearest error boundary. If your action returns an error-shaped object, the hook just stores it like any other state.

I default to returning. Throwing means a full UI fallback, which is the wrong reaction for “the email field was empty”. Use a return for validation errors. Use a throw for “the database is on fire and the user should see an error page”.

The concrete shape I use across the app:

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; field?: string };

The optional field lets me highlight a specific input. The discriminated union makes downstream code small: render the form, then if (state?.ok === false) show the error. Nothing clever.

For form-level pending UI I pair this with useFormStatus when I want a spinner that lives inside a child component (like a submit button) without that child needing to know about its parent’s form state. useFormStatus is a small hook but it composes well with useActionState; they’re designed to be used together.

If you want my current take on the rest of the React 19 hooks I keep forgetting about, I wrote up useOptimistic notes too. Different hook, similar lesson: the platform is doing more for you than it used to.

Wire it up this week

Pick one form in your app, whether it’s login, signup, contact, whatever, and convert it. Don’t try to migrate everything. Pick the simplest form and just port it.

Targets to aim for:

  • Delete the submitting flag.
  • Delete the try/catch in the component (keep one inside the action).
  • Delete the dispatcher reducer if you had one.
  • Keep the validation logic. That’s still your problem.

If the form has a complex multi-step flow or shared state with other components, leave it alone for now. useActionState shines on the boring cases, and the boring cases are most cases. Ship that first, then come back for the weird ones.

If you want to see a real production-shaped example, I keep a few in my project work. And if you’ve found a useActionState pattern I haven’t, drop me a line.