6.2 What changed in React 19
React 19 added a small family of new hooks and APIs — use(), Server Actions, useFormStatus, useActionState, and useOptimistic. They look unrelated at first, but almost all of them exist to solve one recurring chore: handing an async job to a form and keeping the screen honest while that job runs. This lesson is the map of what is new and how the pieces snap together.
The thread that ties it together: an Action
Start with the one idea the rest hang from. In React 19 an Action is just an async function you hand to a form's action prop. No onSubmit, no manual fetch, no preventDefault.
Once React owns that submit, it can also own the boring parts around it: is it still pending? what did it return? what should we show in the meantime? Each new hook below answers one of those questions. So learn the Action shape first, then the helpers make sense.
1 · use() — the universal unwrapper
Every hook you have met so far obeys the Rules of Hooks: call it unconditionally, at the top level. use() breaks that rule on purpose. Think of it as a universal unwrapper: give it a Promise and it suspends until the value arrives; give it a Context and it reads that context — and unlike useContext, you may call it inside a conditional.
use() has two modes. In Promise mode it cooperates with the nearest <Suspense> boundary above it.
The real code
// ── Promise mode: suspends until resolved ──
function UserProfile({ userPromise }) {
const user = use(userPromise); // suspends while pending
return <h1>{user.name}</h1>;
}
// Wrap with Suspense as usual:
<Suspense fallback={<Loading />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>
In Context mode it works like useContext — but is allowed inside an if block, which no other hook permits.
// ── Context mode: conditional reads ARE allowed ──
function Component({ shouldLoadTheme }) {
let theme = 'light';
if (shouldLoadTheme) {
theme = use(ThemeContext); // ✅ conditional is fine with use()
}
return <div className={theme}>...</div>;
}
Why is conditional use() safe when conditional useState corrupts everything? Because use() does not store anything in the positional hook list. It is a runtime read, not a persistent state node — so skipping it on some renders shifts nothing. We will see the exact code that proves this below.
Here is the simplified logic React runs when it processes the hook:
function use(usable) {
if (isPromise(usable)) {
if (usable.status === 'fulfilled') {
return usable.value; // already resolved — return synchronously
}
throw usable; // Suspend! Caught by nearest Suspense boundary
}
if (isContext(usable)) {
return readContext(usable); // read from the context stack
}
}
The Promise path is the same throw-and-catch trick Suspense always used. What use() adds is that React attaches status metadata ('pending' / 'fulfilled' / 'rejected') onto the Promise itself, so the second render can short-circuit instead of re-throwing:
const promise = fetchData();
// Render 1: promise is pending → throws → Suspense shows fallback
// Promise resolves → React schedules a re-render
// Render 2: promise.status === 'fulfilled' → returns value immediately
2 · Server Actions — a function you put straight in JSX
A Server Action is a function that lives on the server but can be referenced directly as a form's action prop. No hand-written endpoint, no fetch(), no URL. The 'use server' directive marks the module boundary; the React Flight protocol serialises the reference across the network.
The real code
// server-actions.js (Server Component file — 'use server' directive)
'use server';
export async function createTodo(formData) {
const title = formData.get('title');
await db.todos.create({ title });
revalidatePath('/todos');
}
// TodoForm.jsx (Client Component)
import { createTodo } from './server-actions';
function TodoForm() {
return (
<form action={createTodo}>
<input name="title" />
<button type="submit">Add Todo</button>
</form>
);
}
What actually happens on submit:
1. Browser fires submit event 2. React intercepts → POST to special /_actions endpoint 3. React Flight protocol encodes the action reference 4. Server receives request → executes createTodo(formData) 5. Server streams back RSC payload (updated UI) 6. Client reconciles the new UI
Because it starts as a real <form>, it degrades gracefully: with JavaScript, React intercepts and handles it; without JavaScript, the browser posts the form normally and the server responds with HTML.
3 · useFormStatus — read the form's state from a child
While an Action is in flight you usually want to disable the submit button or show "Submitting…". useFormStatus gives a descendant component the live status of the nearest ancestor <form> — think of it as a context reader scoped to that form. React provides the submission state through a hidden context, so any child can read it without prop-drilling.
The real code
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// Usage — SubmitButton must live INSIDE the <form>:
function Form() {
return (
<form action={serverAction}>
<input name="title" />
<SubmitButton />
</form>
);
}
The full shape of what it returns:
const status = useFormStatus();
// {
// pending: boolean, // true while form is submitting
// data: FormData, // form data being sent (null when not pending)
// method: string, // 'get' or 'post'
// action: function // the action being invoked
// }
4 · useActionState — state driven by what the Action returns
Sometimes the Action's return value is the thing you want on screen — a validation error, a success message. useActionState is useState whose setter is the Action's return value. You give it an action and an initial state; you get back the current state, a wrapped action to put on the form, and an isPending flag.
The real code
import { useActionState } from 'react';
// Server Action (or any async function)
async function updateName(previousState, formData) {
const name = formData.get('name');
if (name.length < 2) {
return { error: 'Name too short' }; // becomes the new state
}
await db.users.update({ name });
return { success: true, name };
}
function NameForm() {
const [state, formAction, isPending] = useActionState(
updateName,
{ error: null, success: false } // initial state
);
return (
<form action={formAction}>
<input name="name" />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Saved: {state.name}</p>}
</form>
);
}
One detail that trips people up: when an action is wrapped by useActionState, its signature changes to (previousState, formData) instead of just (formData). That extra first argument is how accumulated state threads through successive submissions.
It is easy to confuse the two form hooks, so hold them side by side:
| Hook | Purpose | Returns |
|---|---|---|
useFormStatus |
Track submission status of the parent form | { pending, data, method, action } |
useActionState |
Manage state derived from action return values | [state, formAction, isPending] |
5 · useOptimistic — show the guess, then settle
Optimistic UI means showing the user the expected outcome before the server confirms it. useOptimistic keeps two values: the real value (from props/state) and an optimistic value (shown while the action is in flight). When the action ends — success or failure — the optimistic value is simply discarded and the real value takes over. No rollback code.
The real code
import { useOptimistic } from 'react';
function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
// Updater: (currentState, optimisticValue) => newState
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
async function addTodo(formData) {
const newTodo = { id: Date.now(), title: formData.get('title') };
addOptimisticTodo(newTodo); // ← UI updates immediately
await saveTodo(newTodo);
// When saveTodo completes, `todos` prop is updated by parent
// → optimistic state resets automatically
}
return (
<form action={addTodo}>
<input name="title" />
<button>Add</button>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
</form>
);
}
In plain terms, the flow — and notice failure is handled by doing nothing special:
User submits form
→ addOptimisticTodo() called → UI updates immediately (pending item visible)
→ Async action runs (saveTodo)
→ `todos` prop updates from server
→ optimistic state automatically resets
→ real data replaces optimistic data
If the action fails:
→ parent does NOT update `todos` prop
→ optimistic state resets to unchanged `todos` (item "reappears")
6 · The React Compiler (React Forget)
Not a hook, but the other big React 19-era change worth naming. The React Compiler is a build-time plugin that reads your component source and inserts useMemo / useCallback / React.memo for you. You write plain JavaScript; it writes the memoized version with identical runtime behaviour.
Show the before/after
// Before — manual memoization
function TodoList({ todos, filter }) {
const filteredTodos = useMemo(
() => todos.filter(t => t.status === filter),
[todos, filter]
);
const handleClick = useCallback(
(id) => markComplete(id),
[]
);
return <MemoizedList items={filteredTodos} onClick={handleClick} />;
}
const MemoizedList = React.memo(List);
// After — compiler inserts memoization automatically
function TodoList({ todos, filter }) {
const filteredTodos = todos.filter(t => t.status === filter);
const handleClick = (id) => markComplete(id);
return <List items={filteredTodos} onClick={handleClick} />;
}
// ↑ The compiler transforms this output to include memoization
It can only do this if your code already follows React's rules: Strict Mode compatible, no mutation during render, Rules of Hooks followed, and pure render functions (same inputs → same output). Code that violated those rules was already broken — the compiler just cannot help it.
Proof in the source: why use() can sit in an if
The claim above — that use() is allowed inside conditionals because it does not touch the positional hook list — is something the real implementation makes obvious. use() never calls mountWorkInProgressHook() or updateWorkInProgressHook():
The real code — use()
function use(usable) {
if (null !== usable && "object" === typeof usable) {
if ("function" === typeof usable.then) return useThenable(usable); // Promise
if (usable.$$typeof === REACT_CONTEXT_TYPE) return readContext(usable); // Context
}
throw Error("An unsupported type was passed to use(): " + String(usable));
}
The Promise branch routes into useThenable, which uses a separate counter (thenableIndexCounter) and a separate thenableState array — both reset to zero/null after every render. That is why skipping a use() call on some renders shifts nothing: there is no cross-render slot to misalign.
Show the suspend-or-return code (trackUsedThenable)
This is where the Promise's status decides between returning a value and suspending the component:
function trackUsedThenable(thenableState, thenable, index) {
var trackedThenables = thenableState.thenables;
index = trackedThenables[index];
void 0 === index
? trackedThenables.push(thenable) // First time: store it
: index !== thenable && (/* warn: uncached promise */ thenable = index);
switch (thenable.status) {
case "fulfilled": return thenable.value; // Resolved → return value
case "rejected": throw thenable.reason; // Rejected → throw error
default:
suspendedThenable = thenable;
throw SuspenseException; // Pending → SUSPEND
}
}
SuspenseException is a singleton sentinel — not a real error. React catches it, shows the nearest <Suspense> fallback, and re-renders when the promise resolves.
Proof in the source: how useOptimistic reverts with two lanes
The "no rollback code" promise of useOptimistic comes from one trick: its update object carries two lane fields. One (lane) says when to show the optimistic value; the other (revertLane) says when to discard it.
The real code — dispatchOptimisticSetState
// dispatchOptimisticSetState — called when you invoke setOptimistic()
function dispatchOptimisticSetState(fiber, throwIfDuringRender, queue, action) {
action = {
lane: 2, // SyncLane — render IMMEDIATELY
revertLane: requestTransitionLane(), // TransitionLane — when to REVERT
action: action, // the optimistic value
hasEagerState: false,
eagerState: null,
next: null
};
root = enqueueConcurrentHookUpdate(fiber, queue, action, 2);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, 2); // schedule with SyncLane only
}
}
Contrast a normal useState dispatch — its revertLane is always 0, which means "never revert, always apply":
// dispatchSetState — for useState / useReducer
var update = {
lane: requestUpdateLane(fiber), // whatever lane was requested
revertLane: 0, // ← zero means "never revert"
action: action,
hasEagerState: false,
eagerState: null,
next: null
};
The real value and the optimistic value live in two different places, and the real value is re-synced on every render:
hook.baseState = REAL value (synced from passthrough prop on every render) update.action = OPTIMISTIC value (stored in the update queue)
So while the transition is still running, the optimistic update is applied on top of baseState. The moment the transition's lane renders, that same update is skipped, and memoizedState falls back to baseState — whatever the real todos/items prop now is. Success and failure use the identical path; the only difference is whether the parent changed the prop.
Show the full revertLane mechanism
┌─────────────────────────────────────────────────────────┐
│ The revertLane Mechanism │
├─────────────────────────────────────────────────────────┤
│ │
│ useState / useReducer: { lane: X, revertLane: 0 } │
│ ↑ │
│ always 0 = always apply │
│ │
│ useOptimistic: { lane: 2, revertLane: 64 } │
│ ↑ ↑ │
│ SyncLane TransitionLane │
│ (instant) (when to revert) │
│ │
│ During Action (T lane not rendering): apply optimistic │
│ After Action (T lane IS rendering): SKIP, use real │
│ │
└─────────────────────────────────────────────────────────┘
And the timeline for setOptimistic('b') followed by the real setValue(newValue):
T=0ms: startTransition(async () => { setOptimistic('b'); ... })
Creates: { lane: 2 (Sync), revertLane: 64 (Transition), action: 'b' }
scheduleUpdateOnFiber(..., SyncLane)
T=1ms: Render with SyncLane (renderLanes = 2)
revertLane = 64; (2 & 64) = 0 ≠ 64 → CASE 3: apply 'b'
fiber.lanes |= 64 (marked for later)
UI shows: 'b' (optimistic)
T=100ms: await saveChanges('b') completes
setValue(newValue) → schedules TransitionLane (64) update
T=101ms: Render with TransitionLane (renderLanes = 64)
updateOptimisticImpl: hook.baseState = newValue (real value synced)
revertLane = 64; (64 & 64) = 64 → CASE 2: SKIP optimistic
pendingQueue stays = baseState = newValue
UI shows: newValue (real, in the SAME render as the skip)
The key takeaway: revertLane schedules nothing on its own. It is merely a flag checked during the normal update-processing loop. The optimistic and real states converge in the same render when the transition completes — there is no extra render to "clear" the guess.