Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 11 of 17 · ~4 min

useReducer for Complex State

Our filter bar has grown. It now has a search query, a stage filter, a minimum score, a sort column, a sort direction, and a page number:

const [query, setQuery] = useState("");
const [stage, setStage] = useState("all");
const [minScore, setMinScore] = useState(0);
const [sortBy, setSortBy] = useState("score");
const [sortDir, setSortDir] = useState("desc");
const [page, setPage] = useState(1);

Six pieces of state that refuse to stay independent. Change the query and the page must reset to 1, or the reviewer lands on page 4 of a 2-page result and sees nothing. Same for the stage filter. Same for the minimum score. Clicking a column header that is already sorted flips the direction; clicking a different one sets the column and resets the direction to descending. And "Clear filters" has to reset five of the six correctly.

Every one of those rules now lives inside every handler that could break it, and the invariant "changing a filter resets the page" is written out four separate times. The fifth handler somebody adds will forget it, and the bug will be "sometimes the list is empty after filtering", which is a miserable thing to track down.

useReducer moves all of those transitions into one function, where the rules are written once and every update goes through them:

const INITIAL_FILTERS = {
  query: "", stage: "all", minScore: 0, sortBy: "score", sortDir: "desc", page: 1,
};

function filtersReducer(state, action) {
  switch (action.type) {
    case "query_changed":
      return { ...state, query: action.value, page: 1 };   // the rule, written once

    case "stage_changed":
      return { ...state, stage: action.value, page: 1 };

    case "min_score_changed":
      return { ...state, minScore: action.value, page: 1 };

    case "sort_toggled":
      return state.sortBy === action.column
        ? { ...state, sortDir: state.sortDir === "asc" ? "desc" : "asc" }
        : { ...state, sortBy: action.column, sortDir: "desc", page: 1 };

    case "page_changed":
      return { ...state, page: action.value };

    case "filters_cleared":
      return { ...INITIAL_FILTERS, sortBy: state.sortBy, sortDir: state.sortDir };

    default:
      throw new Error(`Unknown filter action: ${action.type}`);
  }
}

function FilterBar() {
  const [filters, dispatch] = useReducer(filtersReducer, INITIAL_FILTERS);

  return (
    <input
      value={filters.query}
      onChange={(e) => dispatch({ type: "query_changed", value: e.target.value })}
    />
  );
}

Look at what moved. The components no longer know that changing a filter resets the page — they announce what the user did, and the reducer decides what the state becomes. A new filter is one new case in one file, and the page-reset rule is impossible to forget because it is not the component's job any more.

Notice the action names, too: query_changed, sort_toggled. They describe what happened, not what to set. set_page_to_1 would have moved the rule back out into the caller and given up the whole benefit.

useStateuseReducer
Best forIndependent values that change on their ownSeveral values that change together, or non-trivial transition rules
Updates viasetX(value) calls, wherever they are neededDispatching named actions into one reducer
Where the rules liveIn each handler, repeatedIn one function, once
TestabilityInline in components, hard to isolateA plain function: given state and action, expect state
Reading the codeSpread across handlersThe reducer is a complete list of everything that can happen

Three practical notes.

A reducer must be pure. Same state and action in, same new state out, every time, with no fetching, no Math.random(), no mutation of the state it was handed. React may call it more than once for the same action in development, precisely to surface impurity.

dispatch is referentially stable. React guarantees the same dispatch function for the life of the component, which means passing it to a React.memo'd child or listing it in a dependency array costs you nothing — no useCallback required. (The same guarantee holds for the setter from useState.) That is a small thing that turns out to matter a lot once memoization is in play.

Expensive initial state can be computed lazily, using the third argument: useReducer(reducer, savedFilters, parseFilters) calls parseFilters(savedFilters) once, on mount, instead of on every render.

The signal that it is time to switch is not a line count. It is noticing that several useState values always move together, or that you had to write the same rule in more than one handler.