Skip to content

useReducer vs useState

intermediate14 min read

When useState Becomes Painful

useState works for simple, independent state values. But when state logic grows — multiple related values, complex transitions, validation rules — useState handlers become tangled:

// useState pain: related state with complex transitions
function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);
  const [promoApplied, setPromoApplied] = useState(false);

  function addItem(item) {
    setItems(prev => [...prev, item]);
    setTotal(prev => prev + item.price);
    // Bug: what if discount needs recalculating?
    // Bug: what if promoApplied affects the total differently?
  }

  function removeItem(itemId) {
    setItems(prev => {
      const item = prev.find(i => i.id === itemId);
      setTotal(t => t - item.price); // Nested setState — fragile
      return prev.filter(i => i.id !== itemId);
    });
  }
  // Every handler must coordinate 4 state values correctly.
  // One mistake and state becomes inconsistent.
}
Mental Model

Think of useState as a light switch — on or off, one value changes. useReducer is a control panel with labeled buttons. Each button (action) triggers a predefined sequence of changes. The control panel (reducer) guarantees that pressing "ADD_ITEM" always produces a consistent state, no matter who pressed it or when. You cannot accidentally update total without updating items — the reducer handles both atomically.

useReducer: Centralized State Logic

const initialState = {
  items: [],
  total: 0,
  discount: 0,
  promoApplied: false,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const newItems = [...state.items, action.item];
      const newTotal = newItems.reduce((sum, i) => sum + i.price, 0);
      return {
        ...state,
        items: newItems,
        total: state.promoApplied ? newTotal * (1 - state.discount) : newTotal,
      };
    }
    case 'REMOVE_ITEM': {
      const newItems = state.items.filter(i => i.id !== action.itemId);
      const newTotal = newItems.reduce((sum, i) => sum + i.price, 0);
      return {
        ...state,
        items: newItems,
        total: state.promoApplied ? newTotal * (1 - state.discount) : newTotal,
      };
    }
    case 'APPLY_PROMO': {
      const rawTotal = state.items.reduce((sum, i) => sum + i.price, 0);
      return {
        ...state,
        promoApplied: true,
        discount: action.discount,
        total: rawTotal * (1 - action.discount),
      };
    }
    default:
      return state;
  }
}

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD_ITEM', item: newItem })}>
        Add Item
      </button>
      <p>Total: ${state.total}</p>
    </div>
  );
}

Every state transition is defined in one place. The reducer is a pure function — easy to test, easy to reason about, impossible to accidentally skip a related update.

dispatch Is Referentially Stable

Here's a hidden gem that most tutorials skip over: dispatch has a stable identity across renders. It never changes:

function Parent() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    // dispatch reference never changes — Child never re-renders due to prop change
    <MemoizedChild onAction={dispatch} />
  );
}

const MemoizedChild = React.memo(function Child({ onAction }) {
  console.log('Child rendered');
  return <button onClick={() => onAction({ type: 'INCREMENT' })}>+</button>;
});

With useState, you would need useCallback to prevent the handler from changing every render:

// useState requires useCallback for stable handlers
const handleIncrement = useCallback(() => {
  setCount(prev => prev + 1);
}, []);

With useReducer, dispatch is automatically stable. No useCallback needed.

Why dispatch is stable

React creates the dispatch function once during mount and binds it to the Fiber and queue. It is stored on the hook's queue object and never recreated. This is different from setState — while setState is also stable, complex handlers built on top of setState typically create new closures each render. dispatch passes the logic to the reducer, keeping the callback simple and stable.

Reducer as State Machine

This is where reducers really shine. For complex UI flows, you can model your state as a finite state machine:

function reducer(state, action) {
  switch (state.status) {
    case 'idle': {
      if (action.type === 'FETCH') {
        return { ...state, status: 'loading' };
      }
      return state;
    }
    case 'loading': {
      if (action.type === 'SUCCESS') {
        return { status: 'success', data: action.data, error: null };
      }
      if (action.type === 'ERROR') {
        return { status: 'error', data: null, error: action.error };
      }
      return state; // Ignore other actions during loading
    }
    case 'success': {
      if (action.type === 'FETCH') {
        return { ...state, status: 'loading' };
      }
      return state;
    }
    case 'error': {
      if (action.type === 'FETCH') {
        return { ...state, status: 'loading', error: null };
      }
      return state;
    }
    default:
      return state;
  }
}

This pattern prevents impossible states. You cannot have status: 'loading' with data: someValue or dispatch SUCCESS while idle. The state machine enforces valid transitions.

When to Use Each

ScenariouseStateuseReducer
Single boolean/string/numberBestOverkill
Two independent valuesGoodFine
Multiple related values that update togetherAwkwardBest
Complex state transitions with rulesFragileBest
State machine with defined transitionsPoorBest
Passing update function to memoized childrenNeeds useCallbackdispatch is stable
Simple toggleBestOverkill
Common Trap

Do not use useReducer for everything. A simple boolean toggle (const [isOpen, setIsOpen] = useState(false)) is clearer with useState. useReducer adds indirection — the action type, the switch statement, the dispatch call. This indirection pays off when state logic is complex, but it is overhead when state logic is trivial.

Production Scenario: Form State Machine

const formReducer = (state, action) => {
  switch (action.type) {
    case 'FIELD_CHANGE':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' }, // Clear error on change
        touched: { ...state.touched, [action.field]: true },
      };
    case 'FIELD_BLUR':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
        errors: {
          ...state.errors,
          [action.field]: validateField(action.field, state.values[action.field]),
        },
      };
    case 'SUBMIT_START':
      return { ...state, submitting: true, submitError: null };
    case 'SUBMIT_SUCCESS':
      return { ...state, submitting: false, submitted: true };
    case 'SUBMIT_ERROR':
      return { ...state, submitting: false, submitError: action.error };
    case 'RESET':
      return initialFormState;
    default:
      return state;
  }
};

function RegistrationForm() {
  const [form, dispatch] = useReducer(formReducer, initialFormState);

  return (
    <form onSubmit={e => {
      e.preventDefault();
      dispatch({ type: 'SUBMIT_START' });
      submitForm(form.values)
        .then(() => dispatch({ type: 'SUBMIT_SUCCESS' }))
        .catch(err => dispatch({ type: 'SUBMIT_ERROR', error: err.message }));
    }}>
      <input
        value={form.values.email}
        onChange={e => dispatch({
          type: 'FIELD_CHANGE', field: 'email', value: e.target.value
        })}
        onBlur={() => dispatch({ type: 'FIELD_BLUR', field: 'email' })}
      />
      {form.touched.email && form.errors.email && (
        <span>{form.errors.email}</span>
      )}
      <button disabled={form.submitting}>
        {form.submitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}
Execution Trace
Action dispatched
dispatch({ type: 'ADD_ITEM', item })
Dispatch called from event handler
Queue update
Update queued on the hook
Similar to setState — queued, not immediate
Process
reducer(currentState, action)
React calls your reducer with current state + action
Compare
Object.is(prevState, newState)
If reducer returns same reference → bail out
Render
Component re-renders with new state
New state available via the state variable
What developers doWhat they should do
Using useReducer for a simple boolean toggle
useReducer adds indirection (action types, switch cases) that is not justified for trivial state. A simple setIsOpen(prev => !prev) is clearer.
Use useState for simple, independent values
Mutating state in the reducer: state.items.push(item); return state
Reducers must be pure. Returning the same reference after mutation causes Object.is to return true → no re-render. The mutation is invisible.
Always return a new object: return { ...state, items: [...state.items, item] }
Using useCallback around dispatch: useCallback((item) => dispatch(`{type: 'ADD', item}`), [dispatch])
dispatch never changes. Wrapping it in useCallback is unnecessary overhead. Pass it directly to memoized children.
Pass dispatch directly — it is already referentially stable
Putting async logic inside the reducer
Reducers must be synchronous pure functions. Dispatch the action before the async call (LOADING), then dispatch again with the result (SUCCESS/ERROR).
Keep reducers pure — handle async in event handlers or effects, dispatch results
Quiz
Why is dispatch from useReducer referentially stable?
Quiz
What happens if a reducer returns the same object reference as the current state?
Quiz
When should you choose useReducer over useState?
Key Rules
  1. 1useReducer centralizes state transitions — all update logic in one pure function
  2. 2dispatch is referentially stable — no useCallback needed when passing to children
  3. 3Reducers must be pure: no mutations, no side effects, always return new objects for state changes
  4. 4Use state machines in reducers to prevent impossible states
  5. 5Choose useReducer when state values are related and must update together consistently

Challenge: Convert useState to useReducer

Challenge: Reducer Migration

// Convert this useState-based component to useReducer.
// Ensure all state transitions are handled atomically.

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  const [nextId, setNextId] = useState(1);

  function addTodo(text) {
    setTodos(prev => [...prev, { id: nextId, text, completed: false }]);
    setNextId(prev => prev + 1);
  }

  function toggleTodo(id) {
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  function clearCompleted() {
    setTodos(prev => prev.filter(t => !t.completed));
  }
}
Show Answer
const initialState = {
  todos: [],
  filter: 'all',
  nextId: 1,
};

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: state.nextId,
          text: action.text,
          completed: false,
        }],
        nextId: state.nextId + 1,
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(t =>
          t.id === action.id ? { ...t, completed: !t.completed } : t
        ),
      };
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(t => !t.completed),
      };
    case 'SET_FILTER':
      return { ...state, filter: action.filter };
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const { todos, filter } = state;

  // addTodo is now atomic — todos and nextId update together
  // dispatch is stable — safe to pass to memoized children
}

Key improvements:

  • ADD_TODO atomically adds the item and increments nextId — impossible to have them out of sync
  • The reducer is a pure function — easy to unit test without rendering a component
  • dispatch is referentially stable — no useCallback needed for child components