Skip to main content

useReducer and Advanced State

What you'll learn

  • Why useState is secretly a useReducer, 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: stable dispatch and lazy init
  • useOptimistic and useActionState, 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 function
return 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 setState of the reducer world.

What happens:

  1. You type "buy milk" and click Add. handleAdd dispatches { type: 'add_item', text: 'buy milk' }.
  2. React calls todosReducer(currentTodos, action). The switch returns a new array with the item appended, no mutation.
  3. React stores the returned state and re-renders; the list shows the new todo.
  4. 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

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: useReducer is 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 through dispatch.
  • Misconception: dispatch updates state immediately. Reality: like setState, it schedules a re-render; reading the state right after dispatching shows the old value.
  • Misconception: dispatch can change identity or needs memoizing. Reality: it is guaranteed stable for the life of the component.
  • Misconception: you should convert every useState to useReducer. Reality: independent, simple values (a text input) are happier as useState; reducers pay off when transitions are related and numerous.
  • Misconception: useOptimistic changes 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) → state transition is testable, loggable, and replayable, none of which is possible when setState calls are smeared across handlers.
  • dispatch can 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.
  • useOptimistic and useActionState exist 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

  1. 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 through todosReducer and confirm you reach the same final state.
  2. Write three unit assertions for todosReducer (add, toggle, remove) in a plain JS file, no React import needed. This is the payoff of purity.
  3. In UserCard, try to reach "loading and error at the same time", you can't. Then rewrite it with an isLoading / error / data boolean trio and notice how easily the impossible state appears.
  4. Make saveLikeToServer always throw in the LikeButton demo; click and watch the count bump, then roll back on its own.

Recap

  • useState is useReducer with a trivial reducer; useReducer generalizes setState into 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; useState stays right for independent values.
  • dispatch never changes identity, safe in any dependency array. The third argument (init) computes initial state lazily, once.
  • useOptimistic shows a temporary result during an async action and rolls back if reality disagrees; useActionState packages a form action's pending flag and result.
  • Model explicit statuses (idle | loading | success | error) instead of overlapping booleans, make impossible states unrepresentable.

Next

The main thread problem →