Skip to content

useActionState: forms without the boilerplate

Three useStates for data, error and loading? React 19 does it with one hook — and the form works even before hydration.

Author
Jan Jarčík
Published
Reading time
2 min read
Updated
ShareXLinkedInBluesky

For years a React form looked the same: useState for values, another for the error, another for isLoading, an onSubmit with preventDefault() and a try/finally. React 19 ships useActionState, which folds that whole pattern into one hook.

How it works

useActionState(action, initialState) returns a tuple:

  • state — the last value returned by the action (initialState at first),
  • formAction — a function you pass to <form action={…}>,
  • isPending — true while the action is running.

The action receives the previous state and the FormData. Whatever it returns becomes the new state.

TSX
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions";
 
type State = { ok: boolean; message: string } | null;
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    async (_prev, formData) => {
      const email = String(formData.get("email") ?? "");
      if (!email.includes("@")) return { ok: false, message: "Invalid email" };
      return subscribe(email);
    },
    null,
  );
 
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <button disabled={isPending}>
        {isPending ? "Sending…" : "Subscribe"}
      </button>
      {state && <p role="status">{state.message}</p>}
    </form>
  );
}

Why it's better

  1. No hand-rolled isLoading. React owns the pending state, so it never gets stuck on true because you forgot a finally.
  2. Actions are queued. If the user clicks twice, the second call receives the result of the first as prev.
  3. Progressive enhancement. When the action is a Server Function ("use server"), the form submits even before JavaScript has loaded.

One last tip

After a successful action the form resets itself (for uncontrolled inputs). If you want to keep the values after an error, return them in the state and use defaultValue={state?.values?.email}.