Chapter 2 of 4

Writing a Reducer

Purity, action design, and the signature in detail.

useReducer(reducer, initialState) returns the current state and a dispatch function. Calling dispatch with an action runs the reducer and schedules a re-render.

Reducers must be pure

  • Same state and action in, same state out, every time.
  • No fetching, no timers, no writing to the DOM, no Math.random() or Date.now().
  • Never mutate the state argument - return a new object.
// Wrong: mutation
case "added":
  state.items.push(action.item);
  return state;

// Right: a new object and a new array
case "added":
  return { ...state, items: [...state.items, action.item] };
// Impure: a different id every call
case "added":
  return { ...state, items: [...state.items, { id: crypto.randomUUID() }] };

// Pure: the caller generates the id and puts it in the action
dispatch({ type: "added", id: crypto.randomUUID() });

Designing actions

Name actions after what happened, not after which fields to set. The reducer decides the consequences; the component just reports the event.

// Setter-style: the component has to know the internals
dispatch({ type: "setIsSubmitting", value: true });
dispatch({ type: "setErrors", value: {} });

// Event-style: one action, the reducer works out the rest
dispatch({ type: "submitted" });

Lazy initialisation

A third argument lets React build the initial state by calling a function, which is useful when it is expensive or derived from a prop.

function init(initialCount) {
  return { count: initialCount, history: [] };
}

const [state, dispatch] = useReducer(reducer, props.startAt, init);

dispatch is stable

React guarantees dispatch keeps the same identity for the life of the component. It is safe to leave out of dependency arrays and safe to pass through context without memoising.