All guides
React — GuideReact

Forms in React 19: Actions, useActionState and useOptimistic

React 19 Actions replace most hand-rolled form state: pending flags, errors and optimistic updates with useActionState, useFormStatus and useOptimistic.

Syed Wajeeh Haider
Syed Wajeeh Haider
Full Stack Software Engineer
Sep 28, 20263 min read
#React 19#Forms#Hooks#UX

Every form used to come with the same boilerplate: an isSubmitting flag, an error state, a preventDefault, a try/catch and a reset. React 19 turns that pattern into a first-class feature called Actions, and a small set of hooks handles pending state, results and optimistic UI for you.

19
React version where Actions became stable
3
values from useActionState: state, action, isPending
2
values from useOptimistic: state and updater

Forms can take a function

In React 19, a form's action prop can be a function, including an async one. React calls it with the form's FormData when the form submits, tracks it as a transition, and resets uncontrolled fields when it succeeds. No onSubmit, no preventDefault.

useActionState: results and pending in one hook

useActionState(action, initialState) wraps your action and returns [state, formAction, isPending]. Your action receives the previous state and the form data, and whatever it returns becomes the new state. That makes it a natural home for validation errors and success messages.

tsx
type State = { error?: string; ok?: boolean };

async function subscribe(prev: State, formData: FormData): Promise<State> {
  const email = String(formData.get('email') ?? '');
  if (!email.includes('@')) return { error: 'Enter a valid email' };

  const res = await fetch('/api/subscribe', {
    method: 'POST',
    body: JSON.stringify({ email }),
  });
  return res.ok ? { ok: true } : { error: 'Something went wrong' };
}

export function Newsletter() {
  const [state, formAction, isPending] = useActionState(subscribe, {});

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <button disabled={isPending}>{isPending ? 'Joining…' : 'Join'}</button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.ok && <p>You are in.</p>}
    </form>
  );
}

useFormStatus for reusable buttons

useFormStatus, imported from react-dom, returns { pending, data, method, action } for the form it is rendered inside. The catch: it must be called from a component that is a child of the form, not from the component that renders the form. That is exactly what makes it useful for a shared submit button.

tsx
import { useFormStatus } from 'react-dom';

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? 'Saving…' : children}
    </button>
  );
}

Optimistic UI with useOptimistic

useOptimistic(state, updateFn) returns [optimisticState, addOptimistic]. While an action is running, you call addOptimistic to show the expected result immediately. When the action finishes, React discards the optimistic value and renders the real state. If the request failed and you never updated the real state, the optimistic item simply disappears: rollback comes for free.

tsx
type Comment = { id: string; text: string; sending?: boolean };

export function Comments({ initial }: { initial: Comment[] }) {
  const [comments, setComments] = useState(initial);
  const [error, setError] = useState<string | null>(null);
  const [optimistic, addOptimistic] = useOptimistic(
    comments,
    (current: Comment[], text: string) => [
      ...current,
      { id: 'temp', text, sending: true },
    ],
  );

  async function post(formData: FormData) {
    const text = String(formData.get('text') ?? '');
    setError(null);
    addOptimistic(text);
    try {
      const saved = await createComment(text);
      setComments((c) => [...c, saved]);
    } catch {
      setError('Could not post your comment. Try again.');
    }
  }

  return (
    <>
      <ul>
        {optimistic.map((c) => (
          <li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>{c.text}</li>
        ))}
      </ul>
      <form action={post}>
        <input name="text" required />
        <SubmitButton>Post</SubmitButton>
      </form>
      {error && <p role="alert">{error}</p>}
    </>
  );
}
On failure the temporary comment vanishes and an error explains why

Server Actions and progressive enhancement

In Next.js, the action can be a Server Action: an async function marked with 'use server' that runs on the server. Pass it to useActionState or directly to a form's action. When that form is rendered by a Server Component, it can submit before the JavaScript bundle has loaded, so the form still works on a slow connection, and the hooks take over once the page hydrates.

Key takeaways

  • —Pass functions, including async ones, to a form's action prop.
  • —useActionState gives you the returned state, a wrapped action and isPending.
  • —useFormStatus from react-dom must be called inside a child of the form.
  • —useOptimistic shows the expected result instantly and rolls back automatically.
  • —Use startTransition for actions that are not forms.
  • —Server Actions let forms work before hydration in Next.js.

The result is less state to manage and better UX by default: forms that show progress, report errors and feel instant, without a single isSubmitting flag.

Keep reading
(10)Let's talk

Ready to scaleyour application?

Stop worrying about technical debt. I can help you build a reliable, high-performance product from the ground up, tailored exactly to your business needs.

Actively open for new opportunities
Get in touch
Local time
--:-- PKT
© 2026 Syed Wajeeh Haider. All rights reserved.