How to Build a Counter with useReducer in React
Advertisement
🧩 Scenario
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
| Approach | Boilerplate | Readability for complex state | Testability | Use when |
|---|---|---|---|---|
| useState | Low | Good for 1-2 values | Indirect | Simple, independent state values |
| useReducer | Medium | Better for 3+ related updates | Direct | Multi-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
- How does
useImmerReducersimplify writing reducers by letting you write mutating-style code that is converted to immutable updates behind the scenes? - How would you combine
useReducerwithuseContextto create a Redux-like global store without a library? - How does Redux Toolkit's
createReducerandcreateSlicerelate to the plainuseReducerpattern? - How would you implement undo/redo functionality using
useReducerwith a history array in the state? - What is the
lazy initializationthird argument ofuseReducerand 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 receiveundefinedas state when an unrecognized action is dispatched - Using
useReducerfor a single boolean that twouseStatecalls would handle more simply - Not using TypeScript discriminated unions for action types, leaving
action.typeas 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
useReducerand 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
payloadto an action and use it in the reducer? - Can you articulate when
useReduceris a better choice thanuseState?
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.
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