Skip to main content

βš™οΈ The useReducer Hook

You don't change a bank balance by reaching in and rewriting the number β€” you perform an action: deposit, withdraw, transfer. Each action follows the rules and produces a new, recorded balance. useReducer brings that same discipline to React state: components dispatch actions describing what happened, and one pure function decides how the state changes.

Week 5 · Monday: React Hooks Deep Dive · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the state β†’ action β†’ reducer β†’ new state cycle
  • Call useReducer(reducer, initialState) and read state / dispatch
  • Write a pure reducer that returns new state without mutation
  • Choose between useState and useReducer for a given situation
  • Model async flows (loading / success / error) as reducer actions
  • Combine useReducer with Context for app-wide state

Estimated Time: 65 minutes

Practice: Build a counter reducer, a todo reducer, and a small async data reducer.

In This Lesson

The Dispatch β†’ State Cycle

Every useReducer app runs the same loop. The user does something, the component dispatches an action (a plain object describing the event), the reducer receives the current state plus that action and returns the next state, and React re-renders with it. The reducer is the single place where state transitions live β€” which makes the whole flow predictable and easy to test.

graph LR A[Current State] --> C[Reducer<br/>pure function] B[Action<br/>dispatched] --> C C --> D[New State] D --> E[React re-renders UI] E --> F[User interacts] F --> B

Read it clockwise: state and an action flow into the reducer, a new state flows out, the UI updates, the user acts, and the next action begins the cycle again. Nothing mutates state directly β€” every change is a fresh object returned by the reducer.

Anatomy of useReducer

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

// state        β†’ the current state value
// dispatch     β†’ a function you call with an action, e.g. dispatch({ type: 'INCREMENT' })
// reducer      β†’ (state, action) => newState   (must be PURE)
// initialState β†’ the starting state
TermWhat it isShape
actionA description of an event{ type: 'ADD', payload: … }
dispatchSends an action to the reducerdispatch(action)
reducerComputes the next state(state, action) => state
stateThe current value React rendersAny object / array / value

πŸ’‘ Why "reducer"?

The name comes from Array.prototype.reduce: you feed it an accumulator (the state) and an item (the action) and it returns the new accumulator. A React reducer is the same idea applied over time β€” each dispatched action reduces into the next state.

Your First Reducer

The classic counter, rewritten with actions. Notice the component never computes new state itself β€” it only describes what happened.

import { useReducer } from 'react';

const initialState = { count: 0 };

// Pure: same (state, action) always yields the same result, no side effects.
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      // Unknown action: return state unchanged.
      return state;
  }
}

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

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>βˆ’</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
}

What happens on a click

click "+"  β†’ dispatch({ type: 'INCREMENT' })
           β†’ reducer({ count: 0 }, { type: 'INCREMENT' })
           β†’ returns { count: 1 }
           β†’ React re-renders β†’ "Count: 1"

useState vs useReducer

Both manage state. Reach for useReducer when the logic gets complex enough that scattered setX calls become hard to follow.

Prefer useState when…Prefer useReducer when…
State is a single simple valueState is a shape with several related fields
Updates are independentThe next state depends on the previous one
Few transitionsMany distinct transitions / event types
Logic lives happily in the componentYou want transition logic testable in isolation

Here's the same todo state both ways. With useState, related updates sprawl across many setters:

// useState β€” several setters, easy to let them drift out of sync
function TodosWithState() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  const [sortBy, setSortBy] = useState('date');
  const [searchTerm, setSearchTerm] = useState('');

  const addTodo = (text) =>
    setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);

  const toggleTodo = (id) =>
    setTodos(prev =>
      prev.map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  // …plus delete, filter, sort, search setters
}

With useReducer, one state object and one reducer hold every transition in a single readable place:

const initialState = { todos: [], filter: 'all', sortBy: 'date', searchTerm: '' };

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: Date.now(), text: action.payload, completed: false },
        ],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(t =>
          t.id === action.payload ? { ...t, completed: !t.completed } : t
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(t => t.id !== action.payload),
      };
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    case 'SET_SORT':
      return { ...state, sortBy: action.payload };
    case 'SET_SEARCH':
      return { ...state, searchTerm: action.payload };
    default:
      return state;
  }
}

function TodosWithReducer() {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  return (
    <button onClick={() => dispatch({ type: 'ADD_TODO', payload: 'New todo' })}>
      Add Todo
    </button>
  );
}

βœ… The payoff

Every way the todo state can change is listed in one switch. Components shrink to "describe the event and dispatch it." You can unit-test todoReducer as a plain function β€” no React, no rendering required.

Practical Patterns

Action creators

Instead of hand-writing action objects everywhere, wrap them in small functions. Typos become impossible and the call sites read like sentences.

const todoActions = {
  addTodo:    (text) => ({ type: 'ADD_TODO', payload: text }),
  toggleTodo: (id)   => ({ type: 'TOGGLE_TODO', payload: id }),
  deleteTodo: (id)   => ({ type: 'DELETE_TODO', payload: id }),
  setFilter:  (f)    => ({ type: 'SET_FILTER', payload: f }),
};

dispatch(todoActions.addTodo('Learn useReducer'));

Name action types as constants

A stray string like 'ADD_TOOD' silently falls through to default. Centralizing the strings turns those bugs into obvious reference errors.

const ActionTypes = {
  ADD_TODO: 'ADD_TODO',
  TOGGLE_TODO: 'TOGGLE_TODO',
  DELETE_TODO: 'DELETE_TODO',
};

// In the reducer:
case ActionTypes.ADD_TODO: /* … */

Derive, don't store

Just like with context, compute totals from state rather than adding another action to keep them in sync.

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  // Derived on every render β€” always correct, never stale.
  const totalItems = state.items.reduce((sum, i) => sum + i.quantity, 0);
  const totalPrice = state.items.reduce(
    (sum, i) => sum + i.price * i.quantity,
    0
  );

  return (
    <div>
      <h2>Cart ({totalItems} items)</h2>
      <h3>Total: ${totalPrice.toFixed(2)}</h3>
    </div>
  );
}

Async Flows as Actions

A data fetch has three natural states: started, succeeded, failed. Modeling each as an action keeps loading, data, and error from ever contradicting each other. Note: the await lives in the event handler, not in the reducer β€” reducers stay pure.

function asyncReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function DataFetcher() {
  const [state, dispatch] = useReducer(asyncReducer, {
    data: null,
    loading: false,
    error: null,
  });

  const fetchData = async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const res = await fetch('/api/data');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  };

  return (
    <div>
      {state.loading && <LoadingSpinner />}
      {state.error && <ErrorMessage error={state.error} />}
      {state.data && <DataDisplay data={state.data} />}
      <button onClick={fetchData}>Fetch Data</button>
    </div>
  );
}

⚠️ Keep the reducer pure

Never put fetch, setTimeout, or logging inside a reducer. A reducer must be a pure function of (state, action). Perform side effects in the event handler or a useEffect, then dispatch the result.

Combining with Context

The power move from the previous lesson: put a reducer's state and dispatch into Context so the whole app can read state and dispatch actions. Splitting them into two contexts means components that only dispatch don't re-render when state changes.

const AppStateContext = createContext(null);
const AppDispatchContext = createContext(null);

function appReducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'ADD_NOTIFICATION':
      return {
        ...state,
        notifications: [...state.notifications, action.payload],
      };
    default:
      return state;
  }
}

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, {
    user: null,
    theme: 'light',
    notifications: [],
  });

  return (
    <AppStateContext.Provider value={state}>
      <AppDispatchContext.Provider value={dispatch}>
        {children}
      </AppDispatchContext.Provider>
    </AppStateContext.Provider>
  );
}

// Guarded custom hooks, one per context.
function useAppState() {
  const ctx = useContext(AppStateContext);
  if (ctx === null) throw new Error('useAppState needs an AppProvider');
  return ctx;
}
function useAppDispatch() {
  const ctx = useContext(AppDispatchContext);
  if (ctx === null) throw new Error('useAppDispatch needs an AppProvider');
  return ctx;
}

function UserProfile() {
  const { user } = useAppState();
  const dispatch = useAppDispatch();
  return (
    <div>
      <h2>Welcome, {user?.name ?? 'guest'}!</h2>
      <button onClick={() => dispatch({ type: 'SET_USER', payload: null })}>
        Logout
      </button>
    </div>
  );
}

πŸ’‘ dispatch is stable

React guarantees the dispatch function identity never changes across renders. That's why putting it in its own context is safe β€” the dispatch context value stays constant, so dispatch-only components never re-render from it.

Practice & Quiz

πŸ‹οΈ Exercise 1: A game state reducer

Goal: Write gameReducer handling START_GAME, PAUSE_GAME, UPDATE_SCORE (adds action.payload), NEXT_LEVEL, and GAME_OVER.

const initialGameState = {
  isPlaying: false, isPaused: false, score: 0, level: 1, lives: 3,
};

function gameReducer(state, action) {
  // TODO
}
πŸ’‘ Hint

Each case returns { ...state, /* changed fields */ }. UPDATE_SCORE should add to the existing score: score: state.score + action.payload.

βœ… Solution
function gameReducer(state, action) {
  switch (action.type) {
    case 'START_GAME':
      return { ...state, isPlaying: true, isPaused: false };
    case 'PAUSE_GAME':
      return { ...state, isPaused: !state.isPaused };
    case 'UPDATE_SCORE':
      return { ...state, score: state.score + action.payload };
    case 'NEXT_LEVEL':
      return { ...state, level: state.level + 1 };
    case 'GAME_OVER':
      return { ...state, isPlaying: false, lives: 0 };
    default:
      return state;
  }
}

πŸ‹οΈ Exercise 2: Undo / redo

Goal: Build a reducer for a text editor supporting WRITE, UNDO, and REDO over a history of past and future states.

const initialState = { past: [], present: '', future: [] };
βœ… Solution
function historyReducer(state, action) {
  const { past, present, future } = state;
  switch (action.type) {
    case 'WRITE':
      return { past: [...past, present], present: action.payload, future: [] };
    case 'UNDO':
      if (past.length === 0) return state;
      return {
        past: past.slice(0, -1),
        present: past[past.length - 1],
        future: [present, ...future],
      };
    case 'REDO':
      if (future.length === 0) return state;
      return {
        past: [...past, present],
        present: future[0],
        future: future.slice(1),
      };
    default:
      return state;
  }
}

🎯 Quick Quiz

Question 1: What does a reducer function receive and return?

Question 2: Which is the correct, pure way to increment a count in a reducer?

Question 3: Where should the await fetch(...) for a data load go?

Best Practices & Pitfalls

βœ… Do

  • Keep reducers pure: return new state, no mutation, no side effects
  • Always spread the previous state: { ...state, changed: … }
  • Handle the default case by returning state unchanged
  • Name action types with constants and consider action creators
  • Split state and dispatch contexts when going app-wide

❌ Don't

  • Mutate state (state.count++, state.items.push(...)) inside a reducer
  • Perform fetch, timers, or logging inside the reducer
  • Reach for useReducer when a single useState is clearer
  • Forget the default branch β€” an unknown action would return undefined

⚠️ The mutation trap

// ❌ Mutates the existing object β€” React may skip the re-render
function badReducer(state, action) {
  state.count += 1;
  return state;
}

// βœ… Returns a new object β€” React sees a new reference and re-renders
function goodReducer(state, action) {
  return { ...state, count: state.count + 1 };
}

React decides whether to re-render by comparing references. Return the same object and it may conclude nothing changed.

Summary

πŸŽ‰ Key Takeaways

  • The cycle is state β†’ action β†’ reducer β†’ new state β†’ render
  • Components dispatch actions; the reducer decides how state changes
  • Reducers must be pure: new state out, no mutation, no side effects
  • Prefer useReducer for complex, interdependent state; useState for simple values
  • Model async as START / SUCCESS / ERROR actions, and pair with Context for app-wide state

πŸ“š Additional Resources

πŸš€ What's Next?

You've now met useState, useContext, and useReducer. The next lesson shows how to package any combination of them into your own reusable tools: Custom Hooks β€” extracting stateful logic like useLocalStorage and useFetch.

πŸŽ‰ Solid progress!

Predictable, testable state transitions are a hallmark of professional React. You've got them now.