# 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.

- Author: Syed Wajeeh Haider
- Published: Sep 28, 2026
- Category: React
- Tags: React 19, Forms, Hooks, UX
- Reading time: 3 min
- URL: https://syedwajeehhaider.com/react-19-actions-forms

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.

*On failure the temporary comment vanishes and an error explains why*

```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>}
    </>
  );
}
```

> **Outside forms:** Actions are not limited to forms. For a like button or a drag-and-drop reorder, wrap the async work in `startTransition` (or the function from `useTransition`) and you get the same pending tracking, and `useOptimistic` works there too.

## 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.
