useState vs useReducer: Building a Counter Two Ways

Intermediate10 min interview
Skills tested:
Implementing a counter with useState using a single numberImplementing the same counter with useReducer and typed actionsComparing the two approaches for readability, testability, and scalabilityKnowing the functional updater pattern for useState (prev => prev + 1)Knowing when to migrate from useState to useReducer as complexity grows

Advertisement

🧩 Scenario

Comparing useState and useReducer side by side on the same problem is the clearest way to understand when each is the right tool. A counter is a simple enough problem that both implementations are short, but complex enough to reveal the structural difference between the two hooks. Interviewers use this comparison to probe your mental model of React state management.

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

AspectuseStateuseReducer
BoilerplateMinimalMore: action types, reducer, dispatch
Update logic locationInline at call siteCentralized in reducer
TestabilityComponent-levelPure function, no render needed
ScalabilityGets messy with 4+ actionsScales well with many actions
Re-render behaviorIdenticalIdentical

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

  1. How would you use useCallback to memoize the increment, decrement, and reset handlers in the useState version to prevent child re-renders?
  2. How does useReducer with useContext create a shared state that multiple components can dispatch to?
  3. How does Zustand's create() API compare to useReducer + useContext for global state?
  4. How would you persist the counter state to localStorage so it survives a page reload, and which hook makes this easier?
  5. React's useReducer and Redux reducers follow the same pattern. What does Redux add on top that useReducer does not provide?

Common Candidate Mistakes

  • Thinking useReducer is more performant than useState — both trigger re-renders exactly the same way
  • Using useState with count + 1 instead of prev => prev + 1, which reads stale closure values in concurrent mode or inside async callbacks
  • Reaching for useReducer for a simple single-value counter to "be more professional" when it adds unnecessary ceremony
  • Scattering constraint logic across multiple useState handlers when useReducer would 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 useState using the functional updater pattern?
  • Can you implement the same counter with useReducer and 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.

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

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