useReducer and Advanced State
What you'll learn
- Why
useStateis secretly auseReducer, and what a reducer actually is - The action pattern: describing intent with named events instead of setting state everywhere
- When reducers beat
useState, plus two bonuses: stabledispatchand lazyinit useOptimisticanduseActionState, conceptually, in one minute each- The state-machine mindset that makes impossible states unrepresentable
A form grows up. First it has isSubmitting. Then error. Then successMessage, retryCount, wasSubmitted. Soon setState calls are scattered across six handlers, and you ship a bug where the spinner and the error show at the same time because two booleans contradicted each other. useReducer offers a different deal: instead of setting state from everywhere, you send named messages, "submit started", "submit failed", and one function decides what each message does to the state. That single indirection changes how you debug, test, and think.
useState is a reducer in a trench coat
Jargon: "reducer". A pure function
(state, action) => newState. Give it the current state and a description of what happened; it returns the next state. No side effects, no mutation, same inputs, same output.
Here is the secret:
Pseudocode model, not real source:
// Conceptually, useState is useReducer with this trivial reducer:function basicStateReducer(state, action) {// setState accepts a new value OR an updater functionreturn typeof action === 'function' ? action(state) : action;}
So setCount(c => c + 1) was dispatching an "action" all along, one that happens to be a function. useReducer generalizes the idea: your actions become descriptive objects, and the state-transition logic moves out of event handlers into one pure function.
The todo app, reducer style
import { useReducer, useState } from 'react';
let nextId = 3;
function todosReducer(todos, action) {
switch (action.type) {
case 'add_item':
return [...todos, { id: nextId++, text: action.text, done: false }];
case 'toggle':
return todos.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case 'remove':
return todos.filter((t) => t.id !== action.id);
case 'clear':
return [];
default:
return todos;
}
}
export default function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, [
{ id: 1, text: 'Learn hooks', done: true },
{ id: 2, text: 'Learn reducers', done: false },
]);
const [text, setText] = useState('');
function handleAdd() {
if (!text.trim()) return;
dispatch({ type: 'add_item', text });
setText('');
}
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={handleAdd}>Add</button>
<button onClick={() => dispatch({ type: 'clear' })}>Clear all</button>
<ul>
{todos.map((t) => (
<li key={t.id}>
<label style={{ textDecoration: t.done ? 'line-through' : 'none' }}>
<input
type="checkbox"
checked={t.done}
onChange={() => dispatch({ type: 'toggle', id: t.id })}
/>
{t.text}
</label>
<button onClick={() => dispatch({ type: 'remove', id: t.id })}>×</button>
</li>
))}
</ul>
</div>
);
}
Jargon: "action". A plain object describing what happened, conventionally
{ type: 'add_item', ...payload }. Actions describe intent, not implementation.
Jargon: "dispatch". The function that sends an action into the reducer and schedules a re-render with the result. It is the
setStateof the reducer world.
What happens:
- You type "buy milk" and click Add.
handleAdddispatches{ type: 'add_item', text: 'buy milk' }. - React calls
todosReducer(currentTodos, action). Theswitchreturns a new array with the item appended, no mutation. - React stores the returned state and re-renders; the list shows the new todo.
- Toggle, remove, and clear work the same way: handlers say what happened; the reducer decides what it means.
Notice the division of labor: event handlers know the user's intent; the reducer owns the shape of the state. Neither does the other's job.
Debugging: actions are data
Because actions are plain, serializable objects, you can log them by wrapping the reducer:
import { useReducer } from 'react';
function todosReducer(todos, action) {
switch (action.type) {
case 'add_item':
return [...todos, { id: Date.now(), text: action.text, done: false }];
default:
return todos;
}
}
function loggingReducer(state, action) {
const next = todosReducer(state, action);
console.log(action.type, '| payload:', action, '| next state:', next);
return next;
}
export default function App() {
const [todos, dispatch] = useReducer(loggingReducer, []);
return (
<button onClick={() => dispatch({ type: 'add_item', text: 'logged!' })}>
Add a todo — then check the console ({todos.length} items)
</button>
);
}
What happens: every interaction prints a readable story, add_item → add_item → toggle → remove. And because actions are data (not functions), you could save that log, replay it from the initial state, and reconstruct any moment: fold the same actions through the same reducer and you get the same states. That is the idea behind time-travel debugging tools, and it only works because transitions are pure (state, action) → state.
When reducers win
- Many related transitions over the same state. Four actions on one todo list, one owner of the rules.
- Next state depends on previous in complex ways. The reducer always receives the latest state, no stale-closure juggling across handlers.
- Testing. Reducers are pure functions; you can test them with no component and no DOM:
// a plain unit test — no React involved
const before = [{ id: 1, text: 'a', done: false }];
const after = todosReducer(before, { type: 'toggle', id: 1 });
console.assert(after[0].done === true, 'toggle flips done');
When not to bother: a lone text input, an independent toggle. useState is the right tool for simple, unrelated values.
Two bonuses: stable dispatch and lazy init
dispatch never changes identity. It is bound to the component instance, not to any render, so it is always safe in dependency arrays: an effect written as useEffect(() => { /* ...dispatch things... */ }, [dispatch]) still runs exactly once, and lint tools know they may trust (or omit) dispatch as a dep.
Lazy init. Pass a third argument, an init function, and the initial state is computed once, on mount, as init(initialArg):
import { useReducer } from 'react';
function init(key) {
try {
return JSON.parse(localStorage.getItem(key)) ?? [];
} catch {
return [];
}
}
function listReducer(list, action) {
switch (action.type) {
case 'add_item':
return [...list, action.text];
default:
return list;
}
}
export default function SavedList() {
const [items, dispatch] = useReducer(listReducer, 'my-list-key', init);
return (
<div>
<button onClick={() => dispatch({ type: 'add_item', text: `item ${items.length + 1}` })}>
Add
</button>
<ul>{items.map((t, i) => <li key={i}>{t}</li>)}</ul>
</div>
);
}
What happens: init runs exactly once on mount, reading localStorage. Without the third argument you would either read storage on every render (wasteful) or not at all.
useOptimistic in one minute
The concept: show the result of an async action instantly, before the server confirms. If reality disagrees, React rolls back to the real state. Perfect for likes, reactions, chat messages:
import { useOptimistic, useState } from 'react';
async function saveLikeToServer() {
await new Promise((r) => setTimeout(r, 800));
if (Math.random() < 0.5) throw new Error('server said no');
}
export default function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes);
const [shown, bump] = useOptimistic(likes, (current) => current + 1);
async function likeAction() {
bump(); // show +1 instantly
await saveLikeToServer(); // if this throws and likes never changes...
setLikes((n) => n + 1); // ...React shows the real value again
}
return (
<form action={likeAction}>
<button>♥ {shown}</button>
</form>
);
}
What happens: while the action is in flight, shown displays the optimistic computation (likes + 1). When the action settles, React goes back to displaying the real state, which either moved up (success) or did not (failure, so the +1 quietly rolls back). The mental model: a temporary overlay on real state, never a second source of truth.
useActionState in one minute
The concept: wire a form's submit to an async function and get back the latest result plus a pending flag, without hand-rolling isSubmitting and error state:
import { useActionState } from 'react';
async function fakeSubscribe(email) {
await new Promise((r) => setTimeout(r, 500));
return email.includes('@') ? null : 'That email looks wrong';
}
async function subscribe(previousState, formData) {
const email = formData.get('email');
const error = await fakeSubscribe(email);
return error ?? `Subscribed ${email}!`;
}
export default function Newsletter() {
const [message, formAction, isPending] = useActionState(subscribe, null);
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={isPending}>
{isPending ? 'Sending…' : 'Subscribe'}
</button>
{message && <p>{message}</p>}
</form>
);
}
What happens: submit → React calls subscribe(previousMessage, formData) → isPending flips to true while it runs → the returned value becomes the new message. Pending, result, and reset, the whole async lifecycle, come from the hook instead of three pieces of local state.
The state-machine mindset
Back to the opening pain. Three booleans, isLoading, error, data, have 2³ = 8 combinations, and several are nonsense (loading and errored and holding data?). Model the status as one explicit value instead:
import { useEffect, useReducer } from 'react';
const initial = { status: 'idle' };
function fetchReducer(state, action) {
switch (action.type) {
case 'start':
return { status: 'loading' };
case 'success':
return { status: 'success', data: action.data };
case 'failure':
return { status: 'error', error: action.error };
case 'reset':
return { status: 'idle' };
default:
return state;
}
}
export default function UserCard({ userId }) {
const [state, dispatch] = useReducer(fetchReducer, initial);
useEffect(() => {
let ignore = false;
dispatch({ type: 'start' });
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((r) => r.json())
.then((data) => !ignore && dispatch({ type: 'success', data }))
.catch((error) => !ignore && dispatch({ type: 'failure', error }));
return () => { ignore = true; };
}, [userId]);
if (state.status === 'idle' || state.status === 'loading') return <p>Loading…</p>;
if (state.status === 'error') return <p>Failed: {state.error.message}</p>;
return <h2>{state.data.name}</h2>;
}
What happens: at every moment, the component is in exactly one named status; the reducer's switch is the state machine, cases are transitions, statuses are states. The impossible combinations ("loading and error at once") are no longer reachable because they cannot be expressed. Adding a retry means adding one transition, not reconciling a new boolean everywhere.
Jargon: "state machine". A model where the system is always in exactly one named state, and named events move it between states. Forbidden combinations cannot be expressed, so they cannot happen.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
useReduceris for big apps, or replaces Redux. Reality: it is a local component hook; it shines whenever one component has many related transitions. - Misconception: reducers can fetch data or start timers. Reality: reducers must be pure, same
(state, action)in, same state out. Side effects live in handlers and effects; only descriptions of what happened go throughdispatch. - Misconception:
dispatchupdates state immediately. Reality: likesetState, it schedules a re-render; reading the state right after dispatching shows the old value. - Misconception:
dispatchcan change identity or needs memoizing. Reality: it is guaranteed stable for the life of the component. - Misconception: you should convert every
useStatetouseReducer. Reality: independent, simple values (a text input) are happier asuseState; reducers pay off when transitions are related and numerous. - Misconception:
useOptimisticchanges your real state. Reality: it is a temporary overlay shown while an action is in flight; the real state underneath decides what remains.
Why it works this way
- Naming actions separates what happened from how state changes, handlers stay thin, and the rules live in exactly one place.
- A pure
(state, action) → statetransition is testable, loggable, and replayable, none of which is possible whensetStatecalls are smeared across handlers. dispatchcan be stable because it is bound to the component's update queue, not to any render's scope, so it never closes over stale values.useOptimisticanduseActionStateexist because the async action lifecycle, pending, result, rollback, has the same shape everywhere; encoding it once beats hand-rolled boolean soup.- Explicit statuses beat overlapping booleans because a type system (or just a
switch) can rule out combinations that should never coexist.
Try it yourself
- Wrap the todo reducer in
loggingReducer, perform each action, and read the story in the console. Then replay it by hand: fold the logged actions throughtodosReducerand confirm you reach the same final state. - Write three unit assertions for
todosReducer(add, toggle, remove) in a plain JS file, no React import needed. This is the payoff of purity. - In
UserCard, try to reach "loading and error at the same time", you can't. Then rewrite it with anisLoading/error/databoolean trio and notice how easily the impossible state appears. - Make
saveLikeToServeralways throw in theLikeButtondemo; click and watch the count bump, then roll back on its own.
Recap
useStateisuseReducerwith a trivial reducer;useReducergeneralizessetStateinto named actions plus one pure transition function.- Actions are serializable descriptions of intent → logging, replay, time-travel, and easy tests.
- Reducers win when transitions are many and related, or the next state depends intricately on the previous one;
useStatestays right for independent values. dispatchnever changes identity, safe in any dependency array. The third argument (init) computes initial state lazily, once.useOptimisticshows a temporary result during an async action and rolls back if reality disagrees;useActionStatepackages a form action's pending flag and result.- Model explicit statuses (
idle | loading | success | error) instead of overlapping booleans, make impossible states unrepresentable.