useState vs useReducer: Building a Counter Two Ways
Advertisement
🧩 Scenario
Architecture Walkthrough
useState: Simple and Direct
useState is a single value with a setter. For a counter, the state is a number and the operations are increment, decrement, and reset. Each operation calls the setter directly. The update logic is inline with the JSX, which is easy to read for simple cases.
The functional updater form (prev => prev + 1) is important when the next state depends on the previous. It guarantees that you are working with the current state, not a potentially stale closure value. This matters especially inside event handlers set up in useEffect or in async callbacks.
useReducer: Explicit Transitions
useReducer separates the state transition logic into a reducer function. Instead of calling setCount(count + 1) directly, you dispatch an action like { type: 'increment' }. The reducer handles all the business logic for that action type. The component body only contains dispatches, not update logic.
For a counter this seems like extra work, but the pattern scales: when you add a "step" feature, the reducer handles it centrally. When you add constraints (no below 0, max 100), they live in one place. When you want to test the update logic, you test the reducer as a pure function without rendering the component.
Which to Choose
Start with useState. Migrate to useReducer when: you have three or more distinct actions, when the update logic gets complex enough that the component body is harder to read, or when you want to test the state logic independently.
Key Code Explained
// --- APPROACH 1: useState ---
import { useState } from 'react';
interface CounterStateProps {
min?: number;
max?: number;
initial?: number;
}
function CounterWithState({ min = 0, max = 100, initial = 0 }: CounterStateProps) {
const [count, setCount] = useState(initial);
// Constraints enforced at the call site — spread across three handlers
const increment = () => setCount((prev) => Math.min(max, prev + 1));
const decrement = () => setCount((prev) => Math.max(min, prev - 1));
const reset = () => setCount(initial);
return (
<div>
<p>Count: {count}</p>
<button onClick={decrement} disabled={count <= min}>-</button>
<button onClick={increment} disabled={count >= max}>+</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// --- APPROACH 2: useReducer ---
import { useReducer } from 'react';
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset'; payload: number };
interface CounterReducerState {
count: number;
min: number;
max: number;
}
// Constraints enforced centrally in the reducer
function counterReducer(state: CounterReducerState, action: CounterAction): CounterReducerState {
switch (action.type) {
case 'increment':
return { ...state, count: Math.min(state.max, state.count + 1) };
case 'decrement':
return { ...state, count: Math.max(state.min, state.count - 1) };
case 'reset':
return { ...state, count: action.payload };
default:
return state;
}
}
interface CounterReducerProps {
min?: number;
max?: number;
initial?: number;
}
function CounterWithReducer({ min = 0, max = 100, initial = 0 }: CounterReducerProps) {
const [state, dispatch] = useReducer(counterReducer, { count: initial, min, max });
return (
<div>
<p>Count: {state.count}</p>
<button
onClick={() => dispatch({ type: 'decrement' })}
disabled={state.count <= state.min}
>
-
</button>
<button
onClick={() => dispatch({ type: 'increment' })}
disabled={state.count >= state.max}
>
+
</button>
<button onClick={() => dispatch({ type: 'reset', payload: initial })}>
Reset
</button>
</div>
);
}
// Testing the reducer — no component render needed
import { counterReducer } from './counterReducer';
test('increment respects max', () => {
const state = { count: 100, min: 0, max: 100 };
const next = counterReducer(state, { type: 'increment' });
expect(next.count).toBe(100); // capped at max
});
test('decrement respects min', () => {
const state = { count: 0, min: 0, max: 100 };
const next = counterReducer(state, { type: 'decrement' });
expect(next.count).toBe(0); // floored at min
});
Comparing the two constraint implementations reveals the key difference: in the useState version, the min/max logic is spread across two handler functions in the component. In the useReducer version, it lives centrally in the reducer. If a third action later needed to enforce the same constraint, the useState version requires a third copy of the logic; the useReducer version enforces it in one place.
Tradeoffs
| Aspect | useState | useReducer |
|---|---|---|
| Boilerplate | Minimal | More: action types, reducer, dispatch |
| Update logic location | Inline at call site | Centralized in reducer |
| Testability | Component-level | Pure function, no render needed |
| Scalability | Gets messy with 4+ actions | Scales well with many actions |
| Re-render behavior | Identical | Identical |
What Interviewers Actually Check
- Whether you know both hooks and can implement the same feature with either
- Whether you use functional updater with useState when state depends on previous state
- Whether you know the key difference: update logic location (inline vs reducer)
- Whether you can choose the right hook for a given complexity level
- Whether you know both hooks trigger re-renders the same way
Follow-Up Questions
- How would you use
useCallbackto memoize theincrement,decrement, andresethandlers in theuseStateversion to prevent child re-renders? - How does
useReducerwithuseContextcreate a shared state that multiple components can dispatch to? - How does Zustand's
create()API compare touseReducer+useContextfor global state? - How would you persist the counter state to localStorage so it survives a page reload, and which hook makes this easier?
- React's
useReducerand Redux reducers follow the same pattern. What does Redux add on top thatuseReducerdoes not provide?
Common Candidate Mistakes
- Thinking
useReduceris more performant thanuseState— both trigger re-renders exactly the same way - Using
useStatewithcount + 1instead ofprev => prev + 1, which reads stale closure values in concurrent mode or inside async callbacks - Reaching for
useReducerfor a simple single-value counter to "be more professional" when it adds unnecessary ceremony - Scattering constraint logic across multiple
useStatehandlers whenuseReducerwould centralize it cleanly - Not extracting the reducer to a separate file, making it impossible to unit test the state logic without rendering
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you implement a counter with
useStateusing the functional updater pattern? - Can you implement the same counter with
useReducerand typed actions? - Can you compare the two approaches and state when you would choose one over the other?
- Can you explain why both hooks have the same re-render behavior?
- Can you add a constraint (min/max) to both implementations and explain where it lives more naturally?
Summary
useState and useReducer both manage component state and trigger re-renders when state changes. The difference is where the update logic lives. With useState, the logic is inline: setCount(prev => Math.min(max, prev + 1)) lives in the button's click handler. With useReducer, the logic is centralized: the reducer function handles all update cases, and the component only dispatches intent.
For a simple counter with two or three operations, useState is cleaner with less boilerplate. For a counter with five or more operations, constraints, history tracking, or step sizes, useReducer is cleaner because all the logic is in one testable function. This scalability threshold (roughly three or more distinct update operations) is the practical signal to reach for useReducer.
Both hooks have identical re-render behavior. A common myth is that useReducer is more performant. It is not. The performance difference in some codebases comes from extracting the reducer outside the component body (preventing re-creation on each render), not from anything inherent to the hook itself.
Is useReducer always better than useState for complex state?
Not always. useReducer adds boilerplate (action types, reducer function, dispatch). It is worth the cost when state has multiple related values that transition together, when there are 3+ distinct update actions, or when you want the state logic to be extractable and testable. For simpler cases, useState is cleaner.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement