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 fromreact-domand 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 newfetch()inside render will loop.use(context)can be called conditionally, whichuseContextcannot.
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:
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
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.
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.
initialStateis ignored after the first dispatch. Changing the prop later does not reset the state.- Setting other state after
awaitinside the action currently needs its ownstartTransition, according to the docs.
How useOptimistic works
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
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
startTransitionin click handlers. - Keep the reducer pure. It may run more than once. No
fetch, noDate.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:
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:
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
// 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
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.
// 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
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
Keep reading
React7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact7 min read
React Hook Form + Zod: Type-Safe Form Validation Guide
Learn React Hook Form with Zod 4 for type-safe form validation: zodResolver setup, inferred types, cross-field rules, field arrays and server errors.
Read articleReact8 min read
React Compiler: Do You Still Need useMemo and useCallback?
React Compiler 1.0 memoizes components for you. Learn when useMemo and useCallback are still needed, how to enable it in Vite or Next.js 16, and what breaks.
Read article