React
useReducer
State Management
React Hooks
TypeScript
Frontend

useReducer in React: When It Beats useState

useReducer keeps related state in one place. Compare it with useState, type reducers with discriminated unions, and see where it beats a state library.

10 min read
Chamikara Nayanajith

Every React codebase eventually grows a component like this: six calls to useState at the top, and a handful of event handlers that each have to update three of them in the right order. It works until someone adds a seventh, forgets to reset the second, and the UI ends up in a state that should not be possible.

useReducer is the built-in fix. It is not a smaller Redux and it is not for "complex state" in the vague sense people usually mean. It is for state where the transitions matter as much as the values.

What is a reducer?

A reducer is a plain function that takes the current state and an action, and returns the next state. No React in it, no hooks, nothing async.

That definition is worth taking literally, because everything else follows from it. The useReducer referencedocuments the hook, but the property that matters is inherited from the plain function underneath: the same state and the same action produce the same result every time, with no way for the outside world to interfere.

tsx
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'reset':
      return { ...state, count: 0 };
    default:
      return state;
  }
}

useReducer takes that function and an initial state, and hands back the current state plus a dispatch function for sending actions to it.

tsx
const [state, dispatch] = useReducer(reducer, { count: 0 });

<button onClick={() => dispatch({ type: 'increment' })}>+1</button>

The component no longer describes how state changes. It describes what happened, and the reducer decides what that means. That indirection is the entire trade: you give up locality to gain a single place where every transition is written down.

useReducer vs useState

Both are the same feature underneath. useState is implemented in terms of useReducer inside React. So the question is never capability, it is which one makes the code easier to get right.

PreferWhen
useStateOne value that changes on its own. A boolean, a string, a selected id.
useStateValues that are related but never need to change together in one event
useReducerOne user action has to update several pieces of state at once
useReducerThe next state depends on the current state in a non-trivial way
useReducerCertain combinations of values are invalid and you want to make them unrepresentable

The row I care most about is the last one, and it deserves its own example.

Making impossible states impossible

Here is the pattern that made me change how I write async UI. The obvious version, with a state per concern:

tsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<User | null>(null);

Three booleans-worth of state means eight combinations, and only three of them are meaningful. Nothing stops loading being true while error is set and data is stale, and every handler has to remember to clear the other two. The bug is always the same: an error stays on screen after a successful retry, because the success path set data and forgot setError(null).

A reducer over a union type lets you write down only the states that exist:

tsx
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; user: User }
  | { status: 'error'; message: string };

type Action =
  | { type: 'fetch' }
  | { type: 'resolved'; user: User }
  | { type: 'rejected'; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'fetch':
      return { status: 'loading' };
    case 'resolved':
      return { status: 'success', user: action.user };
    case 'rejected':
      return { status: 'error', message: action.message };
  }
}

Each case returns a complete new state rather than patching the old one, so there is no stale field to forget. And because the union is discriminated on status, TypeScript narrows it in the JSX:

tsx
function Profile() {
  const [state, dispatch] = useReducer(reducer, { status: 'idle' });

  if (state.status === 'loading') return <Spinner />;
  if (state.status === 'error') return <Error message={state.message} />;
  if (state.status === 'success') return <Card user={state.user} />;
  return <button onClick={() => dispatch({ type: 'fetch' })}>Load</button>;
}

Reading state.user in the error branch is now a compile error rather than a runtime one. The union types doing that work are the same feature I covered in React and TypeScript, and this is the highest-value place to use them.

Reducers have to be pure

A reducer takes state and an action and returns new state. That is the whole contract. No fetch calls, no localStorage writes, no Math.random(), no mutation of the state you were given.

tsx
// Broken: mutates the existing state object.
case 'add':
  state.items.push(action.item);   // React may not see a change at all
  return state;

// Correct: returns a new object.
case 'add':
  return { ...state, items: [...state.items, action.item] };

The mutating version fails in a specific and confusing way. React compares the returned state to the previous one, sees the same reference, and skips the re-render. The data changed and the screen did not. Because Strict Mode calls your reducer twice in development, a mutating reducer also produces doubled results, so you might see an item added twice locally and once in production. If either symptom shows up, look for a mutation before you look anywhere else.

The third argument nobody uses

useReducer takes an optional lazy initialiser. It matters when building the initial state is expensive, because the second argument is evaluated on every render even though only the first result is used.

tsx
// Runs JSON.parse on every render and throws the result away.
useReducer(reducer, JSON.parse(localStorage.getItem('draft') ?? '{}'));

// Runs once, on mount.
function init(key: string): State {
  return JSON.parse(localStorage.getItem(key) ?? '{}');
}
useReducer(reducer, 'draft', init);

It is also the clean way to implement a reset: keep the same init function and call it from a 'reset' action, so the initial state is defined in exactly one place.

Name actions after events, not setters

The single biggest difference between a reducer that helps and one that is just a switch statement is how the actions are named. Most first attempts look like this:

tsx
dispatch({ type: 'setLoading', value: true });
dispatch({ type: 'setError', value: null });
dispatch({ type: 'setUser', value: user });

That is three setters with extra steps. The caller still knows the shape of the state, still has to remember the order, and still has to remember to clear the error. Every problem useReducer was supposed to solve is intact.

Name the action after the thing that happened in the UI instead:

tsx
dispatch({ type: 'submitted' });
dispatch({ type: 'serverRejected', message: 'Email already in use' });
dispatch({ type: 'retryClicked' });

Now the reducer owns the consequences. "Submitted" means clear the error, set status to loading and disable the button, and it means that in one place rather than at every call site. When a designer asks for the form to also scroll to the top on submit, there is one obvious function to change.

A useful test: if you renamed the state fields, how many action types would you have to rename? With event-shaped actions the answer is zero. With setter-shaped actions it is all of them, which tells you the abstraction was not doing anything.

Reducers and Context

The combination people reach for next is a reducer at the top of a subtree with the state and dispatch handed down through Context. It is a reasonable pattern and it has one property worth knowing about: dispatch is referentially stable for the life of the component, so consumers that only dispatch never need to re-render when the state changes.

tsx
// Two contexts, so components that only dispatch do not re-render
// every time the state changes.
const StateContext = createContext<State | undefined>(undefined);
const DispatchContext = createContext<Dispatch<Action> | undefined>(undefined);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

Splitting the two is the point. Put state and dispatch in one object and every consumer re-renders on every change, including the buttons that only ever send actions. This is the re-render trap I described in the React Context API guide, and a reducer makes it unusually easy to avoid because dispatch is already stable.

Where does this stop being enough?

Reducer plus Context is not a state management library, and pretending otherwise leads somewhere unpleasant. What it does not give you: selective subscriptions, so any consumer of the state context re-renders on any change; devtools with a readable action log; middleware; or anything for server state, caching and revalidation.

My rough boundary is the subtree. A reducer owning one feature's state, a checkout flow, a multi-step form, a canvas editor's undo stack, is a good fit and I would not add a dependency for it. State that the whole application reads and writes wants a real store. Zustand takes about the same amount of code and gives you selectors, which is the thing Context cannot do. I compared the options in React state management.

Server data is a separate question again, and neither a reducer nor a store is the right shape for it. If your reducer's action types are mostly fetch, resolved and rejected, you are hand-rolling a cache, and a data fetching library does that better.

What I actually reach for

I start with useState, always. Most components never need anything else, and a reducer for a single boolean is ceremony.

I switch to useReducer at the point where one event handler is calling three setters, or where I notice I am writing a comment explaining which combinations of state are valid. Both are signals that the transitions have become the interesting part, and that is precisely what a reducer is for. The rewrite is usually twenty minutes and it almost always turns up a bug that was already there.

Frequently asked questions

When should I use useReducer instead of useState?

Switch to useReducer when one user action has to update several pieces of state at once, when the next state depends on the current state in a non-trivial way, or when certain combinations of values are invalid and you want to make them unrepresentable. A single independent value should stay in useState. The signal I watch for is a handler that calls three setters in a row: those three calls are one conceptual event, and splitting them means every future change has to remember all three. A reducer turns that into one dispatch and one place where the consequences live. It also makes the logic testable without rendering anything, because a reducer is a pure function you can call with a state and an action. It is also the natural step before extracting state into a store, because the reducer usually moves across unchanged when you do.

Is useReducer the same as Redux?

They share the reducer idea and nothing else. useReducer is local component state with no store, no middleware, no devtools and no selectors. Combining it with Context gives you a shared value but still no selective subscriptions, so every consumer re-renders on every change no matter which field it reads. For application-wide state, a real store such as Zustand gives you the selectors Context cannot provide. The confusion is worth clearing up because the two get recommended as alternatives when they solve different problems: useReducer is about how one component organises its own state transitions, while Redux is about where state lives and who can reach it. You can use useReducer in an app that has no global store at all. Redux Toolkit borrows the same pure-function shape, which is why the two feel familiar even though almost nothing is actually shared between them.

Why is my reducer running twice?

React Strict Mode calls reducers twice in development to surface impure ones. This is development only and never happens in production. If your results are doubled, the reducer is mutating its arguments or performing a side effect rather than returning a new state object: pushing to an array from state instead of returning a new array is the usual cause. A correct reducer is pure, so calling it twice with the same inputs produces the same output and the doubling is invisible. Treat it the same way as the double effect invocation, as a free correctness check rather than a bug in React. Anything that logs, fetches or writes to a ref belongs outside the reducer. If you need a side effect in response to an action, dispatch first and let an effect react to the resulting state, or do the work in the handler before dispatching.

Should reducer actions be named after setters?

No. Actions named setLoading or setUser are setters with extra steps, and every call site still has to know the shape of the state and which fields to update together. Name actions after what happened in the UI, such as submitted, retryClicked or responseReceived, so the reducer owns the consequences and there is exactly one place to change when the behaviour changes. The difference shows up the first time a requirement moves: adding a retry counter to a submitted action is a change inside the reducer, while adding it to a set of setters is a change at every call site. Event-shaped actions also read like a log of what the user did, which makes the devtools timeline useful. A useful check is whether an action name would still make sense to someone who has never seen the state shape. If not, it is describing the mechanism rather than the event.

Related Articles