Skip to main content

Command Palette

Search for a command to run...

useReducer in React: A Simple Counter, Then the Bigger Picture

Published
9 min read
useReducer in React: A Simple Counter, Then the Bigger Picture

Why Does useReducer Even Exist?

Before writing a single line of code, let's build the right mental model.

You already know useState. It's clean, it's simple, and it works great when a piece of state is self-contained — like a toggle, a string input, or a counter. But as your component grows, you'll notice something uncomfortable: the logic for how state changes starts spreading everywhere. One button calls setCount(count + 1), another calls setCount(0), another calls setCount(count - 1), and suddenly your JSX is littered with little inline decisions about how state should change.

useReducer says: what if all those decisions lived in one place?

That one place is called a reducer — a pure function (returns same results with same set of inputs) that takes your current state and an action, and returns the new state. That's the whole pattern. Everything else is just details.


The Counter Example

Let's start with the simplest possible example: a counter with increment, decrement, and reset.

Step 1: Define Your State and Actions

First, think about what your state looks like and what can happen to it. For a counter, the state is just a number. The things that can happen are: go up, go down, reset.

// This is what our state looks like
const initialState = { count: 0 };

// These are the "things that can happen" — we call them actions.
// Each action is just a plain object with a 'type' field.
// { type: 'INCREMENT' }
// { type: 'DECREMENT' }
// { type: 'RESET' }

Step 2: Write the Reducer

The reducer is a function with a simple contract: given the current state and an action, return the next state. It never mutates — it always returns a fresh value.

function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };

    case 'DECREMENT':
      return { count: state.count - 1 };

    case 'RESET':
      return { count: 0 };

    default:
      // If we receive an unknown action, return state unchanged.
      // This is a safety net — never throw away state accidentally.
      return state;
  }
}

Notice what's happening here: the reducer is a map from (state, action) → new state. It has no side effects. It doesn't call any APIs, doesn't touch the DOM, and doesn't depend on anything outside its arguments. This purity is what makes reducers so predictable and easy to test.

Step 3: Wire It Into the Component

Now you use useReducer(reducer, initialState). It hands you back two things — the current state, and a dispatch function. When you call dispatch({ type: 'INCREMENT' }), React runs your reducer with that action and re-renders the component with the new state.

import { useReducer } from 'react';

function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      return state;
  }
}

const initialState = { count: 0 };

export default function Counter() {
  // useReducer returns [currentState, dispatchFunction]
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <div>
      <h2>Count: {state.count}</h2>

      {/* Each button just dispatches an action — it doesn't know HOW state changes */}
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>−</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
}

Take a moment to notice how the JSX is now clean. The buttons don't contain logic — they just declare intent. "I want to increment." The reducer decides what that means.


The Key Mental Shift: Events vs. Mutations

Here's the most important idea behind the reducer pattern, and it's worth sitting with.

With useState, you think like this: "When this button is clicked, set the count to count + 1." You're thinking about mutations — directly manipulating the value.

With useReducer, you think like this: "When this button is clicked, something happened — an INCREMENT event occurred." You're thinking about events (or actions), and you're letting the reducer decide what the state should look like in response to that event.

This is the same shift that makes Redux, Flux, and even event-sourced databases so powerful. Your component becomes a sender of events, not a manager of state. The reducer owns all the logic.


Actions with Payloads

Real-world actions often carry data. Suppose you want an "add by N" button. You'd pass the number as a payload inside the action object:

// Dispatching an action with a payload
dispatch({ type: 'ADD_BY', payload: 5 });

// And in the reducer...
case 'ADD_BY':
  return { count: state.count + action.payload };

The convention is to use type for the action name and payload for the data it carries. This isn't enforced by React — it's just a widely adopted community pattern that keeps things readable.


When to Prefer useReducer Over useState

The React docs themselves give a clear rule of thumb here, and it maps well to real experience.

Reach for useReducer when your state has multiple sub-values that change together (like a form with name, email, and password), when the next state depends on the previous state in complex ways, or when you have many different ways to update state and the component is getting hard to read. If you ever find yourself writing three or four setState calls in a single event handler to keep things in sync, that's a strong signal to consolidate into a reducer.

On the other hand, useState remains the right tool for simple, isolated values. A modal's isOpen flag doesn't need a reducer. A single text input doesn't need a reducer. Match the tool to the complexity.


A Slightly Richer Example: Form State

To cement the idea, here's how the same reducer pattern scales gracefully to a login form — the kind of thing that would get messy fast with three separate useState calls.

const initialState = {
  username: '',
  password: '',
  isSubmitting: false,
};

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      // action.payload = { field: 'username', value: 'yashraj' }
      return { ...state, [action.payload.field]: action.payload.value };

    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };

    case 'SUBMIT_DONE':
      return { ...state, isSubmitting: false };

    default:
      return state;
  }
}

function LoginForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (e) => {
    dispatch({
      type: 'SET_FIELD',
      payload: { field: e.target.name, value: e.target.value },
    });
  };

  const handleSubmit = async () => {
    dispatch({ type: 'SUBMIT_START' });
    await fakeApiCall(state);
    dispatch({ type: 'SUBMIT_DONE' });
  };

  return (
    <div>
      <input name="username" value={state.username} onChange={handleChange} />
      <input name="password" type="password" value={state.password} onChange={handleChange} />
      <button onClick={handleSubmit} disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </div>
  );
}

Notice that SET_FIELD handles all fields with a single case — because the field name is part of the payload. All three fields stay in one state object, transitions are all visible in one reducer, and the component is left responsible only for rendering and dispatching.


The Bigger Picture: Where This Pattern Lives

useReducer is React's take on a much older idea. The reducer pattern is borrowed from functional programming — specifically from the Array.prototype.reduce idea that you fold a list of changes into a final value. Redux, which became enormously popular in the React ecosystem, is essentially this pattern at the application level.

Understanding useReducer well also makes the leap to Redux Context patterns, Zustand, and other state libraries much easier — because they all share this same vocabulary of actions, reducers, and dispatch.


Notes

The reducer pattern is fundamentally about separating what happened from what it means. Your UI dispatches events. Your reducer decides what those events mean for state. That separation keeps your components clean, your logic testable, and your state transitions auditable — you can read the reducer top to bottom and know every possible way your state can change.

Start with the counter. Internalize the (state, action) => newState contract. Then notice how naturally it scales when your state grows more complex. That's the pattern working as designed.