React 19 Hooks Explained: useActionState, useOptimistic, useFormStatus and use()
React 19 hooks explained with TypeScript: useActionState, useFormStatus, useOptimistic and use(), plus Next.js Server Actions, migration tips and mistakes.
By Harsh Thummar 12–14 min read

For years, every React form looked roughly the same: a useState for each field, another for isLoading, another for error, and an onSubmit handler that called e.preventDefault() before doing anything useful.
The new React 19 hooks remove most of that ceremony. Actions, useActionState, useFormStatus, useOptimistic and the use() API give you pending states, errors, optimistic updates and promise reading as first-class features instead of hand-rolled patterns.
The catch is that these hooks have rules that are easy to get wrong. useFormStatus returns nothing useful if you call it in the wrong component. useOptimistic throws a warning outside a transition. use() breaks if you create the promise during render.
In this guide, I'll walk through each hook with TypeScript examples, show how they plug into Next.js Server Actions, and cover when plain useState and useEffect are still the right tool.
What Are Actions in React 19?
An Action is any function that runs inside a transition. Usually it's async, and usually it performs a mutation: saving a form, adding a comment, toggling a like.
React 19 lets you pass a function directly to a form's action prop:
export function NewsletterForm() {
async function subscribe(formData: FormData) {
const email = formData.get("email");
await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
});
}
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
);
}There is no onSubmit and no preventDefault(). React receives the FormData, runs the function inside a transition, and resets uncontrolled fields after a successful submission.
Because the action runs in a transition, React tracks its pending state for you. That pending state is what the new hooks read.
Form actions also work with <button formAction={fn}> when one form needs several submit buttons that do different things.useActionState: Form State, Errors and Pending in One Hook
useActionState is the hook you will use most. It wraps an action and gives you back the action's latest result plus a pending flag.
The Signature
From the React 19 type definitions:
function useActionState<State, Payload>(
action: (state: Awaited<State>, payload: Payload) => State | Promise<State>,
initialState: Awaited<State>,
permalink?: string,
): [state: Awaited<State>, dispatch: (payload: Payload) => void, isPending: boolean];Three things to notice:
- Your action receives the previous state as its first argument, then the payload (usually
FormData). - Whatever the action returns becomes the new
state. isPendingistruewhile the action is running.
A Typed Signup Form
"use client";
import { useActionState } from "react";
type SignupState = {
ok: boolean;
message: string;
errors?: { email?: string; password?: string };
};
const initialState: SignupState = { ok: false, message: "" };
async function signup(
prevState: SignupState,
formData: FormData,
): Promise<SignupState> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
if (!email.includes("@")) {
return { ok: false, message: "Fix the errors below.", errors: { email: "Enter a valid email." } };
}
if (password.length < 8) {
return { ok: false, message: "Fix the errors below.", errors: { password: "Use at least 8 characters." } };
}
const res = await fetch("/api/signup", {
method: "POST",
body: formData,
});
if (!res.ok) return { ok: false, message: "Something went wrong. Try again." };
return { ok: true, message: "Account created." };
}
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, initialState);
return (
<form action={formAction} noValidate>
<input name="email" type="email" aria-invalid={!!state.errors?.email} />
{state.errors?.email && <p role="alert">{state.errors.email}</p>}
<input name="password" type="password" aria-invalid={!!state.errors?.password} />
{state.errors?.password && <p role="alert">{state.errors.password}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating account..." : "Sign up"}
</button>
{state.message && <p aria-live="polite">{state.message}</p>}
</form>
);
}One state object holds the result, the field errors and the status message. No separate useState for loading or errors.
Calling dispatch Outside a Form
You can call the returned dispatch function from a button click too. When you do, wrap it in startTransition, otherwise isPending won't update and React will warn you:
"use client";
import { useActionState, startTransition } from "react";
export function LikeCounter() {
const [likes, addLike, isPending] = useActionState(
async (current: number, amount: number) => {
await fetch("/api/like", { method: "POST" });
return current + amount;
},
0,
);
return (
<button disabled={isPending} onClick={() => startTransition(() => addLike(1))}>
Likes: {likes}
</button>
);
}Inside a form action or formAction, React handles the transition for you.
The permalink Argument
The optional third argument, permalink, matters for progressive enhancement with server functions. If someone submits the form before JavaScript has loaded, the browser navigates to the permalink URL.
React can then hand the action's result to the form on that page, as long as it renders the same form component with the same action and permalink. In practice this only does something in a framework that supports React Server Components, like Next.js. In a pure client-side app you can ignore it.
useFormStatus: Pending State for Child Components
useFormStatus reads the status of the parent form. It is imported from react-dom, not react:
import { useFormStatus } from "react-dom";
// While a submission is in flight:
// { pending: true, data: FormData, method: string, action: string | ((formData: FormData) => void | Promise<void>) }
// Otherwise:
// { pending: false, data: null, method: null, action: null }It is perfect for a reusable submit button that knows when its form is submitting:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ label, pendingLabel }: { label: string; pendingLabel: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? pendingLabel : label}
</button>
);
}The Rule Everyone Breaks Once
useFormStatus must be called from a component that is rendered inside the <form>. Calling it in the same component that renders the form returns pending: false forever:
// ❌ Wrong: the hook is not inside a form, it renders the form
function ContactForm() {
const { pending } = useFormStatus();
return <form action={send}><button disabled={pending}>Send</button></form>;
}
// ✅ Right: the hook lives in a child of the form
function ContactForm() {
return (
<form action={send}>
<SubmitButton label="Send" pendingLabel="Sending..." />
</form>
);
}If the component that owns the form already uses useActionState, just use its isPending. Reach for useFormStatus when the button lives in a shared component that doesn't know about the action.
useOptimistic: Instant UI While the Server Catches Up
useOptimistic shows a temporary value while an action is pending, then falls back to the real value when the action finishes. The React 19 types give it two forms:
function useOptimistic<State>(passthrough: State): [State, (action: State | ((pendingState: State) => State)) => void];
function useOptimistic<State, Action>(
passthrough: State,
reducer: (state: State, action: Action) => State,
): [State, (action: Action) => void];The first argument is the real state (usually props or server data). The returned value equals it whenever nothing is pending.
Optimistic Comments
"use client";
import { useOptimistic, useRef } from "react";
type Comment = { id: string; text: string; sending?: boolean };
export function Comments({
comments,
addComment,
}: {
comments: Comment[];
addComment: (formData: FormData) => Promise<void>;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(state: Comment[], text: string) => [
...state,
{ id: crypto.randomUUID(), text, sending: true },
],
);
async function submit(formData: FormData) {
addOptimistic(String(formData.get("text")));
formRef.current?.reset();
await addComment(formData);
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.6 : 1 }}>
{c.text}
</li>
))}
</ul>
<form ref={formRef} action={submit}>
<input name="text" required />
<button type="submit">Post</button>
</form>
</>
);
}The new comment appears immediately, slightly faded. When addComment finishes and the parent re-renders with fresh comments, the optimistic entry is replaced by the real one.
If the action throws, the optimistic value is discarded automatically and the UI goes back to the real state. You still need to tell the user it failed, usually with an error boundary or a returned error state.
The Transition Requirement
The setter returned by useOptimistic must be called inside an Action or startTransition. Form actions already count. In an onClick handler, wrap the work:
onClick={() =>
startTransition(async () => {
setOptimisticLiked(true);
await toggleLike(postId);
})
}use(): Reading Promises and Context
use is technically an API, not a hook, and that difference matters. Its signature is simple: use<T>(usable: Usable<T>): T, where a usable is a promise or a context.
Reading a Promise
When you pass a promise, the component suspends until it resolves. The nearest <Suspense> boundary shows its fallback, and a rejected promise goes to the nearest error boundary.
The idiomatic pattern in Next.js is to start the fetch in a Server Component and pass the promise down:
// app/dashboard/page.tsx (Server Component)
import { Suspense } from "react";
import { Stats } from "./stats";
import { getStats } from "@/lib/stats";
export default function DashboardPage() {
const statsPromise = getStats(); // not awaited
return (
<Suspense fallback={<p>Loading stats...</p>}>
<Stats statsPromise={statsPromise} />
</Suspense>
);
}// app/dashboard/stats.tsx
"use client";
import { use } from "react";
type StatsData = { visitors: number; signups: number };
export function Stats({ statsPromise }: { statsPromise: Promise<StatsData> }) {
const stats = use(statsPromise);
return (
<p>
{stats.visitors} visitors, {stats.signups} signups
</p>
);
}The page streams immediately and the stats fill in when ready. This pairs well with the streaming model covered in React Server Components Explained.
Do not create the promise inside a Client Component's render. A new promise on every render means React can never finish suspending. Create it in a Server Component, in a parent, or in a cache.
Reading Context, Conditionally
use(SomeContext) works like useContext, with one big difference: you can call it inside conditions and loops.
"use client";
import { createContext, use } from "react";
type Theme = "light" | "dark";
export const ThemeContext = createContext<Theme>("light");
export function Badge({ show }: { show: boolean }) {
if (!show) return null;
const theme = use(ThemeContext); // allowed after an early return
return <span data-theme={theme}>New</span>;
}That is illegal with useContext. Like all hooks, use must still be called inside a component or hook, and not inside a try/catch block.
React 19 also lets you render the context itself as a provider: <ThemeContext value="dark"> instead of <ThemeContext.Provider value="dark">.
ref as a Prop: forwardRef Is No Longer Needed
In React 19, function components receive ref as a regular prop. The forwardRef wrapper still works, but new code doesn't need it:
// React 18
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// React 19
type InputProps = React.ComponentProps<"input">;
export function Input({ ref, ...props }: InputProps) {
return <input ref={ref} className="field" {...props} />;
}React.ComponentProps<"input"> already includes ref, so your component accepts it with full type safety. This is especially nice for design-system primitives, which is the approach I use in the Tailwind CSS v4 Design System Guide.
How React 19 Hooks Work with Next.js Server Actions
React 19 hooks become really useful with Next.js Server Actions. A function marked "use server" can be passed straight into useActionState, and it receives prevState as its first argument:
// app/actions/contact.ts
"use server";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2),
email: z.email(),
message: z.string().min(10).max(2000),
});
export type ContactState = {
ok: boolean;
message: string;
fieldErrors?: Partial<Record<"name" | "email" | "message", string[]>>;
};
export async function sendMessage(
prevState: ContactState,
formData: FormData,
): Promise<ContactState> {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return {
ok: false,
message: "Please fix the highlighted fields.",
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}
// await saveMessage(parsed.data) / send email here
return { ok: true, message: "Thanks! I'll reply within a day or two." };
}// app/contact/contact-form.tsx
"use client";
import { useActionState } from "react";
import { sendMessage, type ContactState } from "@/app/actions/contact";
import { SubmitButton } from "@/components/submit-button";
const initialState: ContactState = { ok: false, message: "" };
export function ContactForm() {
const [state, formAction] = useActionState(sendMessage, initialState);
return (
<form action={formAction}>
<input name="name" />
{state.fieldErrors?.name && <p>{state.fieldErrors.name[0]}</p>}
<input name="email" type="email" />
{state.fieldErrors?.email && <p>{state.fieldErrors.email[0]}</p>}
<textarea name="message" />
{state.fieldErrors?.message && <p>{state.fieldErrors.message[0]}</p>}
<SubmitButton label="Send" pendingLabel="Sending..." />
{state.message && <p aria-live="polite">{state.message}</p>}
</form>
);
}Validation runs on the server, errors come back as plain serializable state, and useFormStatus drives the button. I go deeper on this exact pattern, with rate limiting and email delivery, in Secure Next.js Contact Form with Server Actions and Zod.
Two security points matter here:
- A Server Action is a public POST endpoint. Always re-check authentication and authorization inside it, as covered in Next.js 16 Authentication.
- Never trust client-side validation alone. The
safeParseon the server is the real gate.
For useOptimistic with server data, call revalidatePath (from next/cache) or a tag-based revalidation inside the action. The page re-renders with fresh props, and the optimistic value is replaced by the real data.
When to Still Use useState and useEffect
The new hooks don't replace the old ones. They replace one specific pattern: manually tracking a mutation's lifecycle.
Keep using useState for:
- Local UI state: open/closed menus, selected tabs, controlled inputs with live formatting
- Values that change on every keystroke, like a search box filtering a list
- Multi-step wizards where state lives across several screens
Keep using useEffect for:
- Subscribing to browser APIs (
resize,IntersectionObserver, WebSockets) - Syncing with a non-React widget, like a chart or map library
- Analytics or logging that must run after render
Avoid useEffect for data fetching in new Next.js code. Fetch in Server Components, or pass a promise to use(). On a large client-only dashboard like HSFin, a dedicated data library such as TanStack Query is still a reasonable choice.
Migrating from React 18: Practical Tips
You don't need to rewrite everything at once. This is the order I'd follow:
- Replace
useFormStatewithuseActionState.useFormStatefromreact-domwas the canary name. The new hook lives inreactand addsisPendingas the third tuple value. - Convert
onSubmithandlers on simple forms toaction. Delete thepreventDefault(), the loading state and the error state. - Extract a shared
SubmitButtonwithuseFormStatus. One component replaces a dozendisabled={isLoading}props. - Drop
forwardReffrom new components. Migrate existing ones when you touch them; there's no rush. - Swap
<Context.Provider>for<Context>in new code. - Move
useEffectfetches to the server where possible, usinguse()for client components that need the data.
Also update @types/react and @types/react-dom to the 19.x versions, otherwise TypeScript won't know about the new hooks.
If you are deciding between a plain React SPA and Next.js for this kind of work, React vs Next.js covers the trade-offs.
Common Mistakes with React 19 Hooks
- Calling
useFormStatusin the form's own component. It only reads a parent<form>, so it returnspending: falseforever. - Importing
useFormStatusfromreact. It is exported fromreact-dom. - Forgetting the
prevStateparameter. WithuseActionState, the first argument is the previous state, notFormData. This causes confusing "formData.get is not a function" errors. - Calling
dispatchor an optimistic setter outside a transition. Wrap button-click calls instartTransition. - Creating a promise during render and passing it to
use(). Create it on the server or cache it. - Returning non-serializable state from a Server Action. Class instances, functions and
Errorobjects don't cross the server-client boundary cleanly. Return plain objects. - Treating optimistic UI as the source of truth. It's a preview. Always show an error when the real action fails.
React 19 Hooks Checklist
@types/reactand@types/react-domupgraded to 19.x- Forms use
actioninstead ofonSubmit+preventDefault()where possible useActionStateactions accept(prevState, formData)and return plain objects- Shared
SubmitButtonusesuseFormStatusfromreact-domand is rendered inside the form - Every
dispatchor optimistic update outside a form runs insidestartTransition - Optimistic updates have a visible failure path
- Promises passed to
use()are created on the server or cached, and wrapped in<Suspense> - Server Actions validate input with Zod and check auth on every call
- New components accept
refas a prop instead of usingforwardRef
Related React Guides
- React Server Components Explained →How server and client boundaries work in Next.js 16.
- Secure Next.js Contact Form →Server Actions, Zod validation and useActionState in a real form.
- Next.js 16 Authentication →Protect pages, Route Handlers and Server Actions.
- TypeScript with React and Node.js →Type-safe patterns across the full stack.
- Next.js 16 Performance Optimization →Techniques for faster pages and better Core Web Vitals.
Frequently Asked Questions
What is the difference between useActionState and useFormState?
useActionState is the stable React 19 name for what was briefly useFormState in react-dom. It moved to the react package and returns an extra isPending boolean as its third value.
Why does useFormStatus always return pending: false?
You are almost certainly calling it in the component that renders the <form>. useFormStatus only reads the status of a parent form, so move it into a child component such as a submit button.
Can I use useOptimistic without a form?
Yes. Call the optimistic setter inside startTransition from any event handler, then await your mutation in the same transition.
Is use() a hook?
use is a React API that behaves like a hook but can be called inside conditions and loops. It must still be called from a component or hook, and not inside try/catch.
Do I still need forwardRef in React 19?
No. Function components receive ref as a normal prop in React 19. forwardRef still works for existing code, but new components don't need it.
Do React 19 hooks work without Next.js?
Yes. Actions, useActionState, useFormStatus, useOptimistic and use() are all part of React itself. Next.js adds Server Actions, so your actions can run on the server.
Conclusion
React 19 hooks change how forms and mutations feel to build. useActionState holds the result and pending state of an action, useFormStatus lets any child of a form react to submission, useOptimistic makes the UI feel instant, and use() reads promises and context with fewer restrictions.
My recommendation is to start small: convert one form to useActionState with a Server Action, add a shared SubmitButton, and see how much state management disappears. Then bring in useOptimistic where latency is actually visible to users.
Combined with React Server Components and server-side validation, these React 19 hooks give you less code, fewer loading-state bugs and a faster-feeling app.