Skip to content

React8 min read

React 19 Hooks: use, useActionState, useOptimistic Explained

React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.

React 19 added four APIs that change how you write forms and async UI: use reads a Promise or context during render, useActionState tracks the result and pending state of an action, useOptimistic shows a temporary value while that action runs, and useFormStatus lets a child component know its parent form is submitting. Together they remove most of the useState + useEffect + isLoading boilerplate that React forms used to need. Below is how each one works, with TypeScript examples that run on the current stable release (React 19.3.1 at the time of writing).

Key takeaways

  • useActionState(action, initialState) returns [state, dispatch, isPending]. The action receives the previous state first, then the payload.
  • useOptimistic(value, reducer?) returns a value that only differs from the real one while an Action is pending. Its setter must run inside an Action.
  • useFormStatus() comes from react-dom and only reports on a parent <form>, never a form in the same component.
  • use(promise) suspends until the Promise resolves, but the Promise must be cached or passed in from a Server Component. A new fetch() inside render will loop.
  • use(context) can be called conditionally, which useContext cannot.

What changed with actions in React 19

Before React 19, a typical "save" button looked like this: a useState for loading, another for errors, a try/finally to reset the loading flag, and an onSubmit that called e.preventDefault(). React 19 introduced Actions: any function passed to a <form action={...}>, a formAction prop, or run inside startTransition. React wraps these in a Transition, tracks the pending state, and resets uncontrolled form fields after a successful submit.

The new hooks are built on top of that idea. If you understand "an Action is an async function React runs inside a Transition", the rest falls into place.

API Package What it gives you Typical use
useActionState react Result of the last action + isPending Form submit results, validation errors
useOptimistic react Temporary value during an action Likes, todo items, chat messages
useFormStatus react-dom pending, data, method, action of the parent form Reusable submit buttons
use react Resolved value of a Promise or a context Reading async data inside Suspense

How useActionState works

The signature from the React docs:

TypeScript
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?);

reducerAction gets (previousState, payload) and returns the next state. When you pass dispatchAction straight to a form's action prop, the payload is the FormData.

A login form with useActionState

TSX
import { useActionState } from 'react';

type LoginState = { error: string | null; ok: boolean };

async function login(prev: LoginState, formData: FormData): Promise<LoginState> {
  const email = String(formData.get('email') ?? '');
  const password = String(formData.get('password') ?? '');

  if (!email.includes('@')) return { error: 'Enter a valid email', ok: false };
  if (password.length < 8) return { error: 'Password is too short', ok: false };

  const res = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) return { error: 'Wrong email or password', ok: false };
  return { error: null, ok: true };
}

export function LoginForm() {
  const [state, formAction, isPending] = useActionState(login, { error: null, ok: false });

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button disabled={isPending}>{isPending ? 'Signing in...' : 'Sign in'}</button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.ok && <p>Welcome back!</p>}
    </form>
  );
}

There is no useState for loading, no preventDefault, and no finally block. In Next.js, login can be a Server Function marked with 'use server', and the same component works. I cover that side in the Next.js Server Actions guide.

Calling dispatch from a button instead of a form

If you call dispatchAction from an onClick, wrap it in startTransition. Otherwise React logs an error in development and isPending will not update.

TSX
import { useActionState, startTransition } from 'react';

async function addToCart(prevCount: number): Promise<number> {
  await fetch('/api/cart', { method: 'POST' });
  return prevCount + 1;
}

export function AddToCart() {
  const [count, dispatch, isPending] = useActionState(addToCart, 0);

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => dispatch())}
    >
      Add to cart ({count})
    </button>
  );
}

useActionState gotchas

  • Calls are queued. Multiple dispatches run one after another, and each receives the previous result. That is usually what you want for a counter, but it means a slow request blocks the next one.
  • Errors go to the Error Boundary. If the action throws, React cancels queued actions and shows the nearest Error Boundary. For expected failures (validation, 401), return an error in state instead of throwing.
  • initialState is ignored after the first dispatch. Changing the prop later does not reset the state.
  • Setting other state after await inside the action currently needs its own startTransition, according to the docs.

How useOptimistic works

TypeScript
const [optimisticState, setOptimistic] = useOptimistic(value, reducer?);

optimisticState equals value whenever no Action is pending. While an Action runs, it equals whatever you set (or what your reducer returns). When the Action finishes, React drops the optimistic value and shows the real value again. You never have to "roll back" by hand: if the request fails and the real state never changes, the UI returns to the real state automatically.

Optimistic todo list in TypeScript

TSX
import { useOptimistic, useState, startTransition } from 'react';

type Todo = { id: string; title: string; pending?: boolean };

async function saveTodo(title: string): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title }),
  });
  if (!res.ok) throw new Error('Failed to save');
  return res.json();
}

export function Todos({ initial }: { initial: Todo[] }) {
  const [todos, setTodos] = useState<Todo[]>(initial);
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (current: Todo[], title: string) => [
      ...current,
      { id: `temp-${current.length}`, title, pending: true },
    ],
  );

  async function addTodo(formData: FormData) {
    const title = String(formData.get('title') ?? '').trim();
    if (!title) return;

    addOptimistic(title);
    try {
      const saved = await saveTodo(title);
      startTransition(() => setTodos((prev) => [...prev, saved]));
    } catch {
      // The optimistic item disappears on its own when the action ends.
    }
  }

  return (
    <>
      <form action={addTodo}>
        <input name="title" placeholder="New todo" />
        <button>Add</button>
      </form>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
            {t.title}
          </li>
        ))}
      </ul>
    </>
  );
}

Because addTodo is passed to action, React already runs it inside a Transition, so addOptimistic can be called directly. The startTransition around setTodos follows the docs' note about updating state after an await.

useOptimistic gotchas

  • Calling the setter outside an Action. React shows a warning and the optimistic value renders briefly, then reverts. Use startTransition in click handlers.
  • Keep the reducer pure. It may run more than once. No fetch, no Date.now() based IDs that you depend on later.
  • Showing a failure. The optimistic item disappears silently on error. Pair it with an error message in state or a toast so users know why.

How useFormStatus works

useFormStatus is imported from react-dom, not react:

TSX
import type { ReactNode } from 'react';
import { useFormStatus } from 'react-dom';

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

Then use it inside any form that has a function action:

TSX
export function ProfileForm({ save }: { save: (fd: FormData) => Promise<void> }) {
  return (
    <form action={save}>
      <input name="name" />
      <SubmitButton>Save profile</SubmitButton>
    </form>
  );
}

It returns { pending, data, method, action }. data is the FormData being submitted, which is handy for showing "Saving 'Sundar'..." while the request runs.

The useFormStatus mistake almost everyone makes

TSX
// Wrong: the form is rendered by the same component
function BrokenForm() {
  const { pending } = useFormStatus(); // always false
  return (
    <form action={save}>
      <button disabled={pending}>Save</button>
    </form>
  );
}

The hook only reads a parent form. Move the button into its own component, as in SubmitButton above. It feels like an unnecessary extra component at first, but it is what makes the button reusable across every form in your app.

How the use() API works

use reads a resource during render. Despite the name, the React docs are explicit that it is not a Hook, which is why it can be called inside if blocks and loops.

use(promise) with Suspense

TSX
import { use, Suspense } from 'react';

type Album = { id: number; title: string; year: number };

function Albums({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  const albums = use(albumsPromise);
  return (
    <ul>
      {albums.map((a) => (
        <li key={a.id}>{a.title} ({a.year})</li>
      ))}
    </ul>
  );
}

export function AlbumsPage({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
  return (
    <Suspense fallback={<p>Loading albums...</p>}>
      <Albums albumsPromise={albumsPromise} />
    </Suspense>
  );
}

The best source of that Promise is a Server Component that starts the fetch and passes the Promise down without awaiting it. In a client-only app, cache the Promise outside render (a module-level Map, or a library like TanStack Query) so the same Promise instance is reused across renders.

TSX
// This creates a new Promise on every render and never settles into a stable UI
function Albums() {
  const albums = use(fetch('/api/albums').then((r) => r.json()));
  // ...
}

Errors thrown by the Promise go to the nearest Error Boundary. You cannot wrap use in try/catch.

use(context) inside conditions

TSX
import { use, createContext } from 'react';

const ThemeContext = createContext<'light' | 'dark'>('light');

function Divider({ show }: { show: boolean }) {
  if (!show) return null;
  const theme = use(ThemeContext);
  return <hr className={theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} />;
}

With useContext this early return before the hook would break the Rules of Hooks. With use it is allowed.

React 19.3 also added a browser() helper in react-dom that you pass to use (use(browser())) to skip server rendering for a component. It must sit inside a <Suspense> boundary during server rendering. It replaces the old useEffect(() => setMounted(true), []) trick.

Common mistakes with React 19 hooks

Mistake What happens Fix
dispatch() in onClick without startTransition Dev error, isPending stays false Wrap in startTransition
useFormStatus in the component that renders <form> pending is always false Move it into a child component
use(fetch(...)) in a client component New Promise every render Cache the Promise or create it on the server
Throwing for validation errors in an action Error Boundary replaces the form Return { error } in state
Importing useFormStatus from react Import error Import from react-dom
Expecting useOptimistic to keep the value after success Value snaps back Update the real state in the action

If you are also turning on the React Compiler, these hooks work fine with it. I explain what the compiler changes in React Compiler: do you still need useMemo and useCallback?. For full form validation, I still pair actions with a schema; see the React Hook Form + Zod guide.

FAQ

What replaced useFormState in React 19?

useActionState replaced the canary-only useFormState from react-dom. It lives in the react package and returns three values: the current state, a dispatch function, and an isPending flag.

Can I call useOptimistic's setter outside a form action?

Yes, but it has to run inside an Action. Wrap the call in startTransition, or call it from a function passed to a form's action prop. Outside an Action, React warns and the optimistic value only flashes for a moment.

Why does useFormStatus always return pending false?

useFormStatus only reads the status of a parent form. If you call it in the same component that renders the form, it has no parent form and returns pending false. Move the button into a child component rendered inside the form.

Is use() a hook?

React's docs say that despite its name, use is not a Hook. It must still be called inside a component or a Hook, but unlike Hooks it can be called inside conditions and loops.

Wrapping up

React 19's hooks are small, but they change the default shape of a form: an action function, a state object for results, and a child button that knows when to disable itself. Start by replacing one hand-rolled isLoading form with useActionState, then add useOptimistic where latency is visible to users. Once that clicks, use and Server Components make the data side just as simple.

  • #react
  • #hooks
  • #react-19
  • #forms
  • #typescript