1 / 3

一緒に変わるべき処理はまとめてしまう

誰も間違えていない。みんな一番近いコードを真面目に真似しただけだ。

1年前

管理者向けの商品一覧画面。最初は状態が二つだけだった。検索語とページ。

検索語が変わったらページを1に戻す。二行で十分だった。

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

それからの1年

要望が一つずつ積み重なった。カテゴリ、並び替え、価格帯、在庫ありのみ、そして複数の商品を選んで一括で非公開にする一括選択。

毎回違う人が、毎回「金曜までに」だった。状態は八つになり、変えるコードは四つのファイルに散らばった。

ルールも一緒に積み重なった。フィルタが変われば結果が変わるので、ページを1に、選択も解除。並び替えは結果が同じなので、ページだけ1に、選択は維持。この違いは、各ファイルを書いた人の頭の中にしかなかった。

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
// フィルタ: ページ1 + 選択解除
<CategorySelect
  onChange={(c) => {
    setCategory(c);
    setPage(1);
    setSelected([]);
  }}
/>
SortSelect.tsx
// 並び替え: ページ1、選択は維持
<Dropdown
  onChange={(v) => {
    setSort(v);
    setPage(1);
  }}
/>
useUrlSync.ts
// URLから復元するときも同じルールを再実装
useEffect(() => {
  const q = parse(location.search);
  if (q.category !== category) {
    setCategory(q.category);
    setPage(1);
    setSelected([]);
  }
  // ...
}, [location.search]);

ブランドフィルタ

新しく来た開発者は真面目だった。ゼロから書かずに既存コードを探した。ドロップダウン一つで値を選ぶコンポーネント、一番似ているのは SortSelect.tsx だった。それをコピーした。

並び替えとフィルタのルールが違うことは、どこにも書かれていなかった。レビュアーも「既存のパターンにちゃんと沿ってますね」と承認した。

BrandFilter.tsx
// SortSelect.tsx をコピー
<Dropdown
  onChange={(b) => {
    setBrand(b);
    setPage(1);
  }}
/>
// 選択は解除されない

月曜の朝

運用担当者がセールの終わったAブランドの商品を30件選択した。それから確認したいことがあって、ブランドをBに変えた。Bブランドの商品を5件選んで「選択項目を非公開」を押した。

35件が非公開になった。画面に見えていなかったAブランドの30件まで。その中には、今日の午前に広告を出している商品もあった。

なぜこうなったか

間違ったコードを書いた人はいなかった。みんな一番近いコードに倣った。問題は、倣うコードが二種類あって、どちらに倣うべきかが八つの状態と四つのファイルのどこにもなかったことだ。

状態が二つのうちは、ルールが目に見える。八つになると、ルールはコードではなく人の記憶に住む。そして人は入れ替わる。

壊れる前に

「結果が変わる変更」と「結果が同じ変更」を、それぞれ関数一つにまとめる。ページをいくつにするか、選択を消すかは、呼ぶ側ではなくその関数が決める。

もうブランドフィルタを足す人は changeFilter を呼ぶだけでいい。ページの初期化も選択解除もついてくる。URLの復元も同じ関数を呼ぶ。間違って真似するコードがそもそもない。

reducerでも、ストアでも、関数一つでも、形は問わない。フレームワークも問わない。大事なのは、一緒に起きるべき処理をひとかたまりにまとめて、ばらして呼べなくすることだ。

listState.ts
export type ListState = {
  filters: Filters;
  sort: Sort;
  page: number;
  selected: string[];
};
 
// 結果が変わる → ページ・選択を初期化
export function changeFilter(s: ListState, patch: Partial<Filters>): ListState {
  return { ...s, filters: { ...s.filters, ...patch }, page: 1, selected: [] };
}
 
// 結果は同じ → 選択は維持
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]);