Frontend Master

JavaScript Curriculum

useReducer
+60 XP

useReducer

medium
~30 min·60 XP

The coffee shop cart has items, a total, a loading flag, and an error state — all changing together on every action. useState means four setters scattered across handlers. useReducer means one reducer with all the logic in one place.

useReducer

useReducer is an alternative to useState for complex state logic. Instead of calling multiple setters, you dispatch an action — the reducer function computes the entire new state from the previous state and the action.

Cart Reducer — Full CRUD

Dispatch ADD_ITEM, REMOVE_ITEM, UPDATE_QTY, CLEAR_CART:

useReducer-visualizer.jsx
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 })
//          ↑ current state       ↑ dispatch fn    ↑ reducer fn    ↑ initial state
dispatch(action)
Current state
{
  "items": [],
  "total": 0
}
Last action
// No actions dispatched yet
// Reducer — pure function, same input → same output
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': return { ...state, items: [...state.items, action.payload] }
    case 'CLEAR_CART': return { items: [], total: 0 }
    default: return state
  }
}
ℹ️The reducer contract
A reducer is a pure function: `(state, action) => newState`. No side effects, no async, no mutation. Given the same state and action, it always returns the same next state. This makes it trivially testable and easy to reason about.

Action Patterns — Three Styles

Flux standard, TypeScript discriminated unions, and Immer:

action-patterns.jsx

Most actions carry data in a payload property. The reducer uses payload to compute the next state.

// Action object:
{ type: 'ADD_ITEM', payload: { id: 1, name: 'Espresso', price: 2.50 } }
{ type: 'SET_FILTER', payload: { category: 'coffee' } }

// Dispatch:
dispatch({ type: 'ADD_ITEM', payload: item })

// Reducer case:
case 'ADD_ITEM':
  return {
    ...state,
    items: [...state.items, { ...action.payload, qty: 1 }]
  }
💡Action creators prevent typos
Instead of `dispatch({ type: 'ADD_ITEM', payload: item })` everywhere, write `const addItem = (item) => ({ type: 'ADD_ITEM', payload: item })` and call `dispatch(addItem(item))`. Centralises the action shape — change it once, fixed everywhere.

useState vs useReducer — When to Switch

Side-by-side comparison and code:

reducer-todo.jsx
Grind coffee beans
Steam the milk
Clean the machine
State
{
  "todos": [
    {
      "id": 1,
      "text": "Grind coffee beans",
      "done": true
    },
    {
      "id": 2,
      "text": "Steam the milk",
      "done": false
    },
    {
      "id": 3,
      "text": "Clean the machine",
      "done": false
    }
  ],
  "filter": "all",
  "nextId": 4
}
⚠️useReducer is not Redux
useReducer gives you a local reducer per component. It does not provide global state, middleware, or time-travel debugging. When you need those, add Zustand or Redux Toolkit. For component-level complex state, useReducer alone is sufficient.

Your Challenge

Write a cartReducer function — pure, no side effects. Actions: ADD_ITEM (add or increment qty), REMOVE_ITEM (filter out), UPDATE_QTY (map to update, min 1), CLEAR_CART (reset). State: { items: [], total: 0 }. Test it: call the function directly with different states and actions, verify the output.

Challenge

Write a cartReducer with actions: ADD_ITEM, REMOVE_ITEM, UPDATE_QTY, CLEAR_CART. State: { items, total }. Each action must correctly update both items and total atomically. Add TypeScript discriminated union types for the actions.

useReducerreducerdispatchactionaction-typepayloadinitialStatecomplex-statediscriminated-unionpure-function
useReducer | Nexus Learn