How to Build a Counter with useReducer in React

Intermediate10 min interview
Skills tested:
Writing a typed reducer function that handles multiple action typesUsing useReducer with an initial state object and dispatching typed actionsUnderstanding that the reducer must return a new state object without mutating the previous oneAdding payload to actions for parameterized state changesKnowing when useReducer is better than useState and when useState is sufficient

Advertisement

🧩 Scenario

A counter with useReducer is the canonical example for learning the reducer pattern before applying it to more complex state (shopping carts, multi-step forms, undo/redo stacks). The counter keeps the state simple enough to focus on the pattern: dispatch an action, reducer handles it, new state is returned.

Architecture Walkthrough

The Reducer Pattern

useReducer takes a reducer function and an initial state, and returns the current state and a dispatch function. When you call dispatch(action), React calls reducer(currentState, action) and replaces the state with whatever the reducer returns. The component re-renders with the new state.

The reducer is a pure function: same inputs always produce the same output, and it never modifies the state argument directly. It receives the current state and an action object, and returns the next state. The action.type field is a string that identifies what happened; the action.payload field (optional) carries any data the reducer needs to compute the new state.

This pattern is the same as Redux reducers, and understanding it is a prerequisite for Redux, Zustand with immer, and React Server Components data mutations.

When to Use It

useReducer is worth the extra boilerplate when state has multiple related values that update together, when there are more than two or three distinct actions, when transitions are complex enough that reading a switch is clearer than reading multiple useState setters, or when you want to test state logic as a pure function independently of the component.

For simple counters, useState is sufficient. useReducer on a single number is overengineered. The counter example is a teaching tool, not a production recommendation for this specific case.

Immutability in the Reducer

The reducer must return a new object (or primitive) rather than mutating the previous state. React compares the old and new state by reference. If you mutate in place and return the same object reference, React sees no change and does not re-render. For primitives (return state.count + 1), this is automatic. For objects, always spread or use structuredClone.


Key Code Explained

// Typed action union — no magic strings
type CounterAction =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset' }
  | { type: 'addBonus'; payload: number };

interface CounterState {
  count: number;
  history: number[];  // tracks all previous counts
}

// Pure function: same input, same output, no side effects
function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'increment':
      return {
        count: state.count + 1,
        history: [...state.history, state.count],
      };

    case 'decrement':
      return {
        // Constraint: count cannot go below 0
        count: Math.max(0, state.count - 1),
        history: [...state.history, state.count],
      };

    case 'reset':
      return {
        count: 0,
        history: [],
      };

    case 'addBonus':
      return {
        count: state.count + action.payload,
        history: [...state.history, state.count],
      };

    default:
      // Always return current state for unknown actions
      return state;
  }
}

const INITIAL_STATE: CounterState = { count: 0, history: [] };

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, INITIAL_STATE);

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

      <div>
        <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
        <button onClick={() => dispatch({ type: 'increment' })}>+</button>
        <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
        <button onClick={() => dispatch({ type: 'addBonus', payload: 5 })}>
          +5 Bonus
        </button>
      </div>

      {state.history.length > 0 && (
        <p>History: {state.history.join(' -> ')}</p>
      )}
    </div>
  );
}


// Testing the reducer in isolation — no React needed
import { counterReducer } from './counterReducer';

describe('counterReducer', () => {
  it('increments the count', () => {
    const state = { count: 5, history: [] };
    const next = counterReducer(state, { type: 'increment' });
    expect(next.count).toBe(6);
  });

  it('does not go below 0 on decrement', () => {
    const state = { count: 0, history: [] };
    const next = counterReducer(state, { type: 'decrement' });
    expect(next.count).toBe(0);
  });

  it('adds bonus payload', () => {
    const state = { count: 10, history: [] };
    const next = counterReducer(state, { type: 'addBonus', payload: 5 });
    expect(next.count).toBe(15);
  });
});

Extracting the reducer to a separate file makes it testable as a pure function with standard unit tests, no React render needed. This is one of the key advantages of the useReducer pattern: all state transition logic is a plain function that takes inputs and returns outputs, with no component lifecycle or hook dependencies.


Tradeoffs

ApproachBoilerplateReadability for complex stateTestabilityUse when
useStateLowGood for 1-2 valuesIndirectSimple, independent state values
useReducerMediumBetter for 3+ related updatesDirectMulti-action, related state

What Interviewers Actually Check

  • Whether you write a typed discriminated union for action types
  • Whether you return new state without mutating the previous state
  • Whether you return the current state in the default case
  • Whether you can add a payload to an action
  • Whether you can articulate when useReducer is better than useState

Follow-Up Questions

  1. How does useImmerReducer simplify writing reducers by letting you write mutating-style code that is converted to immutable updates behind the scenes?
  2. How would you combine useReducer with useContext to create a Redux-like global store without a library?
  3. How does Redux Toolkit's createReducer and createSlice relate to the plain useReducer pattern?
  4. How would you implement undo/redo functionality using useReducer with a history array in the state?
  5. What is the lazy initialization third argument of useReducer and when is it useful?

Common Candidate Mistakes

  • Writing state.count++; return state; inside the reducer, which mutates state in place and prevents re-renders because the reference does not change
  • Forgetting the default: return state; case, which causes the component to receive undefined as state when an unrecognized action is dispatched
  • Using useReducer for a single boolean that two useState calls would handle more simply
  • Not using TypeScript discriminated unions for action types, leaving action.type as a plain string with no autocomplete or exhaustive check
  • Dispatching inside the reducer instead of returning new state (the reducer should be a pure function with no side effects)

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you write a reducer function with increment, decrement, reset, and a payload-carrying action?
  • Can you use useReducer and dispatch actions from button click handlers?
  • Can you explain why the reducer must return new state rather than mutating the existing state object?
  • Can you add a payload to an action and use it in the reducer?
  • Can you articulate when useReducer is a better choice than useState?

Summary

useReducer manages state through a reducer function and dispatched action objects. When dispatch(action) is called, React calls reducer(currentState, action) and replaces state with the returned value. The component re-renders with the new state. The reducer must be a pure function: it must not mutate the state argument and must always return a new state object.

Action types are best modeled as a TypeScript discriminated union, which gives exhaustive type checking and autocomplete on action.type. A payload field carries data for parameterized actions. The default case of the switch must return the current state so that unknown actions do not wipe out state.

The main benefit of useReducer over useState is that all state transition logic is in a single pure function that can be tested independently. Use it when state has multiple related values that update together, when there are more than two or three actions, or when the component's update logic has grown complex enough that separate useState setters are hard to follow. For simple independent values, useState remains the better choice.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

When should I use useReducer instead of useState?

Use useReducer when: state has multiple sub-values that transition together, you have more than two or three actions, the next state depends on the previous state in complex ways, or you want to make state transitions explicit and testable as pure functions.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement