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 a reducer is

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.

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 this stops 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.

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. For application-wide state, a real store such as Zustand gives you selectors that Context cannot.

Why is my reducer running twice?

React Strict Mode calls reducers twice in development to surface impure ones. If your results are doubled, the reducer is mutating its arguments or performing a side effect rather than returning a new state object. A correct reducer is pure, so calling it twice with the same inputs produces the same output.

Should reducer actions be named after setters?

No. Actions named setLoading or setUser are just setters with extra steps, and every call site still has to know the shape of the state. Name actions after what happened in the UI, such as submitted or retryClicked, so the reducer owns the consequences and there is one place to change when the behaviour changes.

Related Articles