1 / 3

Bundle the updates that have to happen together

Nobody got it wrong. Everyone carefully copied the code closest to hand.

A year ago

An admin screen that listed products. At first it had only two pieces of state: the search query and the page.

When the query changed, the page went back to 1. Two lines were enough.

ProductListPage.tsx
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
 
function onSearch(q: string) {
  setQuery(q);
  setPage(1);
}

Over the next year

Requests came in one at a time. Category, sort order, price range, in-stock only, and a bulk selection for unpublishing several products at once.

A different person each time, and each time it was due Friday. The state grew to eight variables, and the code that changed them spread across four files.

The rules grew too. Changing a filter changes the results, so reset to page 1 and clear the selection. Changing the sort keeps the same results, so reset only the page and keep the selection. That difference lived only in the heads of whoever wrote each file.

ProductListPage.tsx
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [sort, setSort] = useState<Sort>("newest");
const [minPrice, setMinPrice] = useState<number | null>(null);
const [maxPrice, setMaxPrice] = useState<number | null>(null);
const [inStock, setInStock] = useState(false);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);
FilterSidebar.tsx
// Filter: page 1 + clear selection
<CategorySelect
  onChange={(c) => {
    setCategory(c);
    setPage(1);
    setSelected([]);
  }}
/>
SortSelect.tsx
// Sort: page 1, keep selection
<Dropdown
  onChange={(v) => {
    setSort(v);
    setPage(1);
  }}
/>
useUrlSync.ts
// Restoring from the URL re-implements the same rule
useEffect(() => {
  const q = parse(location.search);
  if (q.category !== category) {
    setCategory(q.category);
    setPage(1);
    setSelected([]);
  }
  // ...
}, [location.search]);

The brand filter

The new developer was diligent. Instead of starting from scratch, they looked for existing code. A component that picks one value from a dropdown: the closest match was SortSelect.tsx. They copied it.

Nothing anywhere said that sorting and filtering follow different rules. The reviewer approved it with "Nice, follows the existing pattern."

BrandFilter.tsx
// Copied from SortSelect.tsx
<Dropdown
  onChange={(b) => {
    setBrand(b);
    setPage(1);
  }}
/>
// The selection is not cleared

Monday morning

Someone on the operations team selected 30 products from brand A, whose sale had just ended. Then, to check something, they switched the brand filter to B. They picked 5 brand B products and clicked "Unpublish selected."

35 products were unpublished. That included the 30 brand A products that were no longer on screen. Some of them had ads running that morning.

Why it happened

Nobody wrote wrong code. Everyone followed the nearest code. The problem was that there were two kinds of code to follow, and nothing in the eight pieces of state or the four files said which one applied.

With two pieces of state, the rules are visible. With eight, the rules live in people's memory instead of in the code. And people move on.

Before it breaks

Put "changes that alter the results" and "changes that keep the results" into one function each. Whether the page resets or the selection clears is decided by that function, not by whoever calls it.

Now anyone adding a brand filter only has to call changeFilter. The page reset and the cleared selection come with it. Restoring from the URL calls the same function. There is no wrong code left to copy.

A reducer, a store, a plain function: the shape doesn't matter, and neither does the framework. What matters is bundling the updates that have to happen together into one unit, so nobody can call them separately.

listState.ts
export type ListState = {
  filters: Filters;
  sort: Sort;
  page: number;
  selected: string[];
};
 
// Results change → reset page and selection
export function changeFilter(s: ListState, patch: Partial<Filters>): ListState {
  return { ...s, filters: { ...s.filters, ...patch }, page: 1, selected: [] };
}
 
// Results stay the same → keep the selection
export function changeSort(s: ListState, sort: Sort): ListState {
  return { ...s, sort, page: 1 };
}
BrandFilter.tsx
<Dropdown
  onChange={(brand) => setList((s) => changeFilter(s, { brand }))}
/>
useUrlSync.ts
useEffect(() => {
  setList((s) => changeFilter(s, parse(location.search)));
}, [location.search]);