React 19 Hooks: use(), useActionState & useOptimistic
Reviewed & published by Brayan K
React 19 ships a set of hooks built for the async, form-driven UIs you actually build. use() reads the value out of a promise (or context) and plugs into Suspense, so you stop hand-writing loading/error/data branches. useActionState manages a form action's returned state plus a pending flag in one hook. useFormStatus lets a child button know the surrounding form is submitting. And useOptimistic shows the expected result instantly, then reconciles with reality. Together they turn fiddly async glue into a few lines. By the end you'll know what each unwraps, when to reach for it, and how they pair with Server Actions.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Read a promise's value with use() + Suspense
- Why the promise must be created outside render
- Read context with use() — even conditionally
- Manage form state + pending with useActionState
- Show a submit spinner with useFormStatus
- Build instant optimistic UI with useOptimistic
- How these hooks pair with Server Actions
- Common pitfalls and how to avoid them
1️⃣ use(): Read Promises & Context
The use() hook unwraps a value. Pass it a promise and it returns the resolved data — suspending to the nearest <Suspense> while pending, and throwing to an error boundary if it rejects. The result: your component reads as if the data is simply there, with no loading/error branches in the body.
import { use, Suspense } from "react";
// The promise is created OUTSIDE render and passed in as a prop.
function UserName({ userPromise }) {
const user = use(userPromise); // suspends until it resolves
return <h2>{user.name}</h2>; // success path only — no isLoading branch
}
function Profile({ userPromise }) {
return (
<Suspense fallback={<p>Loading user…</p>}>
<UserName userPromise={userPromise} />
</Suspense>
);
}use() also reads context — and unlike every other hook, it can be called conditionally, inside an if or loop:
function Heading({ showThemed, children }) {
if (showThemed) {
const theme = use(ThemeContext); // ✅ allowed — use() can be conditional
return <h1 className={theme.heading}>{children}</h1>;
}
return <h1>{children}</h1>;
}The demo models the three outcomes use() reacts to — pending suspends, fulfilled returns, rejected throws. Run it to lock in the mental model:
// The use() hook UNWRAPS a value: given a settled promise, it hands you the
// data (or throws to Suspense while pending / to an error boundary on reject).
// We can't run Suspense here, so we model the three outcomes use() reacts to.
function describeUse(promiseState) {
switch (promiseState.status) {
case "pending":
// use() "suspends": React shows the nearest <Suspense fallback>
return "⏳ suspend -> show <Suspense fallback={<Spinner/>} />";
case "fulfilled":
// use() returns the resolved value to your component
return "✅ return value: " + JSON.stringify(promiseState.value);
case "rejected":
// use() throws: the nearest error boundary catches it
return "❌ throw -> nearest error boundary renders";
}
}
console.log(describeUse({ status: "pending" }));
console.log(describeUse({ status: "fulfilled", value: { name: "Ada" } }));
console.log(describeUse({ status: "rejected" }));
// ⏳ suspend -> show <Suspense fallback={<Spinner/>} />
// ✅ return value: {"name":"Ada"}
// ❌ throw -> nearest error boundary renders2️⃣ useActionState & useFormStatus: Forms Done Right
useActionState(action, initialState) returns [state, formAction, isPending]. Your action receives the previous state and the form data and returns the next state — perfect for validation errors or a success message. Wire formAction to the form and you get isPending for free.
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions"; // a Server Action
const initial = { error: null, message: null };
function Newsletter() {
const [state, formAction, isPending] = useActionState(subscribe, initial);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="you@dev.io" />
<button disabled={isPending}>
{isPending ? "Subscribing…" : "Subscribe"}
</button>
{state.error && <p className="text-red-600">{state.error}</p>}
{state.message && <p className="text-green-600">{state.message}</p>}
</form>
);
}When the submit button lives in its own reusable component, it can't see the parent's isPending. That's what useFormStatus is for — a child inside the <form> reads the surrounding form's status directly:
"use client";
import { useFormStatus } from "react-dom";
// Reusable across any form — no props needed.
function SubmitButton({ label = "Submit" }) {
const { pending } = useFormStatus(); // status of the nearest enclosing <form>
return (
<button type="submit" disabled={pending}>
{pending ? "Working…" : label}
</button>
);
}Now model the heart of useActionState: the action function that takes the previous state plus form data and returns the next state. Fill in the blanks:
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// useActionState(action, initialState) gives you [state, formAction, isPending].
// The "action" receives the PREVIOUS state + the form data, and RETURNS the
// next state. Let's model that action function for a login form.
async function loginAction(prevState, formData) {
const email = formData.email ?? "";
// 1) validate: if email is missing, return an error state
if (!email) {
return { ___: "Email is required" }; // 👉 error
}
// 2) success: return a fresh state object (this replaces prevState)
return { ___: "Welcome back!" }; // 👉 message
}
async function run() {
// initialState is {} ; React passes prev state as the first arg
console.log(await loginAction({}, { email: "" }));
console.log(await loginAction({}, { email: "ada@dev.io" }));
}
run();
// ✅ Expected output:
// { error: 'Email is required' }
// { message: 'Welcome back!' }3️⃣ useOptimistic: Instant, Reconciled UI
Network calls take time, but users want feedback now. useOptimistic(state, updateFn) returns an optimisticState and an addOptimistic function. You show the expected result immediately, run the real action, and when it finishes React automatically drops the optimistic value and re-renders from the true state — so a failure just snaps back, no manual undo.
"use client";
import { useOptimistic, useState } from "react";
import { saveLike } from "./actions";
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes); // real, confirmed state
const [optimisticLikes, addOptimistic] = useOptimistic(
likes,
(current, delta) => current + delta // how to apply the guess
);
async function handleLike() {
addOptimistic(1); // 1) UI jumps to likes + 1 instantly
const confirmed = await saveLike(postId); // 2) the real action runs
setLikes(confirmed); // 3) reconcile with the server's truth
}
return (
<button onClick={handleLike}>❤️ {optimisticLikes}</button>
);
}The demo models the show-then-reconcile timeline: the UI jumps to the optimistic value, then settles on the server's confirmed value. Run it:
// useOptimistic shows the EXPECTED result instantly, then reconciles with the
// real result when the async action finishes. We model that timeline: the UI
// jumps to the optimistic value, then settles on the server's truth.
async function fakeServerLike(current) {
// pretend the server confirms and returns the authoritative count
await Promise.resolve();
return current + 1;
}
async function likePost(realLikes) {
// 1) OPTIMISTIC: assume success, update the UI immediately (+1)
let displayed = realLikes + 1;
console.log("instant UI (optimistic):", displayed);
// 2) run the real action and get the source-of-truth value back
const confirmed = await fakeServerLike(realLikes);
// 3) RECONCILE: React drops the optimistic value, re-renders from real state
displayed = confirmed;
console.log("after server confirms: ", displayed);
return displayed;
}
likePost(41);
// instant UI (optimistic): 42
// after server confirms: 42🎯 Your Turn: Pick the Right Hook
Each scenario needs exactly one of these React 19 hooks. Finish pickHook so it returns the right name for each case — the decision you make in real code.
// Map a need to the React 19 hook that solves it.
function pickHook(need) {
switch (need) {
case "read a promise's value with Suspense":
return "use";
case "form state + pending in the form component":
return "useActionState";
case "spinner in a shared submit button":
return "useFormStatus";
case "show the result instantly before the server confirms":
return "useOptimistic";
default:
return "useState";
}
}
const needs = [
"read a promise's value with Suspense",
"form state + pending in the form component",
"spinner in a shared submit button",
"show the result instantly before the server confirms",
];
for (const n of needs) {
console.log(pickHook(n).padEnd(15), "<-", n);
}Reorder Challenge
These lines of an optimistic like handler are scrambled. Put them in the correct order:
A. const confirmed = await saveLike(postId);
B. async function handleLike() {
C. setLikes(confirmed);
D. addOptimistic(1);
E. }Quick Recall
1) Why must the promise passed to use() be created outside render?
Creating it in the body makes a new promise every render, which Suspense can never settle — an infinite loading loop. Create it outside render (prop, cache, or a Server Component) and pass the stable promise in.
2) What three things does useActionState return?
[state, formAction, isPending] — the latest state your action returned, the action to put on <form action>, and a pending flag while it runs.
3) Your reusable <SubmitButton> needs to know the form is submitting. Which hook?
useFormStatus (from react-dom). A child of the form reads { pending } directly — no prop drilling.
4) With useOptimistic, what happens if the real action fails?
React discards the optimistic value and re-renders from the real state — so the UI snaps back automatically. You never write manual undo logic.
Common Errors (and the fix)
- Creating the promise inside render: infinite Suspense. Make the promise outside render and pass it to use().
- No <Suspense> or error boundary around use(): nothing catches the pending/rejected states. Wrap the consumer in both.
- Importing useFormStatus from react: it lives in react-dom, and only works inside a <form>.
- Reading useFormStatus in the same component as the form: it reads the enclosing form, so it must be in a child, not the component that renders the <form>.
- Treating the optimistic value as real state: it's temporary. Persist the confirmed result in your real state (e.g. setLikes) when the action resolves.
📋 Quick Reference
| Hook | From | Use it to… |
|---|---|---|
| use(promise) | react | Read a promise's value with Suspense |
| use(context) | react | Read context — even conditionally |
| useActionState | react | Form state + pending in one hook |
| useFormStatus | react-dom | Pending status inside a child of a form |
| useOptimistic | react | Show the expected result instantly |
Mini-Challenge: Validate in an Action
Finish signupAction so it returns an error state for bad input and a message state on success — exactly the shape useActionState threads back into your form.
🎉 Lesson Complete
- ✅ use() unwraps a promise (with Suspense) or context — and can be conditional
- ✅ Create the promise outside render so Suspense can settle it
- ✅ useActionState gives you [state, formAction, isPending]
- ✅ useFormStatus (from react-dom) reads the enclosing form's pending status
- ✅ useOptimistic shows the expected result, then reconciles with the truth
Practice quiz
What does the use() hook do with a promise?
- Starts a new fetch
- Caches the result forever
- Reads (unwraps) the promise's value, suspending while pending
- Converts it to state
Answer: Reads (unwraps) the promise's value, suspending while pending. use() unwraps a promise: it suspends to the nearest <Suspense> while pending and returns the resolved value.
Why must the promise passed to use() be created outside render?
- Creating it in the body makes a new promise every render, which Suspense can never settle
- It runs faster
- Promises can't be created in components
- To avoid TypeScript errors
Answer: Creating it in the body makes a new promise every render, which Suspense can never settle. A new promise per render causes an infinite loading loop; create it outside render and pass a stable one.
Unlike other hooks, use() may be called…
- Only at the top level
- Only in class components
- Only once per app
- Conditionally, inside an if or a loop
Answer: Conditionally, inside an if or a loop. use() is unusual: it's allowed inside conditionals and loops, unlike the rules-of-hooks for other hooks.
What does useActionState return?
- [value, setValue]
- [state, formAction, isPending]
- [data, error, loading]
- [dispatch, state]
Answer: [state, formAction, isPending]. useActionState(action, initialState) returns [state, formAction, isPending].
A useActionState action function receives…
- The previous state and the form data, and returns the next state
- Only the form data
- Just the event object
- The component's props
Answer: The previous state and the form data, and returns the next state. The action gets (prevState, formData) and returns the next state — ideal for validation errors or a success message.
Which hook lets a reusable child SubmitButton know the form is submitting?
- useActionState
- useOptimistic
- useFormStatus
- useState
Answer: useFormStatus. useFormStatus, read by a child inside the <form>, exposes { pending } without prop drilling.
useFormStatus is imported from…
- react
- react-dom
- react-router-dom
- @reduxjs/toolkit
Answer: react-dom. useFormStatus comes from react-dom and only works inside a <form>.
With useOptimistic, what happens if the real action fails?
- You must manually undo the change
- The UI stays on the optimistic value
- It throws to an error boundary
- React discards the optimistic value and re-renders from the real state
Answer: React discards the optimistic value and re-renders from the real state. The optimistic value is disposable; React drops it and shows the true state, so the UI snaps back automatically.
useFormStatus must be read…
- In the same component that renders the <form>
- In a child component inside the <form>
- At the app root
- Inside useEffect
Answer: In a child component inside the <form>. It reads the nearest enclosing form, so it belongs in a child, not the component that renders the form.
Around a component that calls use() on a promise, you should wrap…
- Nothing extra is needed
- A Redux Provider
- A <Suspense> boundary and an error boundary
- A useMemo
Answer: A <Suspense> boundary and an error boundary. Suspense catches the pending state and an error boundary catches rejection; both are needed.
Continue this course
- Previous: React Server Components & Server Actions
- Next: useId for Stable Unique IDs — Generate SSR-safe IDs for accessible label/input and aria pairings
- Quick reference: React cheat sheet