02フロントエンド5 min

グローバルなストアが居場所を勝ち取るとき

グローバルな状態は、便利だからという理由では正当化できません。途中のコンポーネントが、下に渡すためだけに使わない props を受け取っているとき、それは正当化されます。

著者: Salva Castells

カバー画像: Fabian Horst, CC BY-SA 4.0.
この記事の内容

画像生成の SaaS に、子が二つあるページがありました。コントロールを置くサイドバーと、ギャラリーのダイアログです。ページ自体は何も描画しません。こうなっていました。

export default function BoardPage() {
  const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);
  const [galleryOpen, setGalleryOpen] = useState(false);
  const [galleryFilters, setGalleryFilters] = useState<GalleryFilters>({
    type: "all",
    models: [],
  });

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar attachedImages={attachedImages} ... />

この状態はどれもページ自身が使っていません。宣言していたのは、実際に使う二つの共通の祖先だったからです。これは prop-drilling のもっとも正直な形です。状態はいちばん邪魔にならないところまで上げられ、props の配管を通って下りてきます。

移行を決める具体的なサイン

「props が多い」ではありません。もっと具体的です。あるコンポーネントが prop を受け取り、それを読まず、そのまま次に渡している。

このボードでは、サイドバーが selectedModels と onModelsChange を受け取っていましたが、モデルのポップオーバーに転送するためだけでした。中身は見ていません。ページは自分の useState から galleryFilters を受け取っていましたが、ダイアログに渡すためだけでした。生成用のフィールドを追加するたびに三つのファイルに触れることになり、そのどれにもそのフィールドに関するロジックはありませんでした。

一つ目から派生する二つ目のサイン。props の型が状態の鏡になります。GalleryDialogProps には四つの項目、galleryOpen、galleryFilters、onGalleryClose、onGalleryFiltersChange がありましたが、どれもダイアログが何をするかを説明していません。説明しているのは、どうやってそれを伝えるかです。

移行は、ある午後の連続した四つのコミットでした。まずギャラリーと添付画像を持つ use-board-store.ts を作り、ページがそれを使う形にしました。次に子が直接ストアを読むようにすると、ページはこうなりました。

export default function BoardPage() {
  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar />
      <GalleryDialog onImageClick={...} />

状態の props はゼロです。コンポーネントは、名乗っていたとおりのもの、つまりレイアウトに戻りました。三つ目のコミットで prompt、selectedAspectRatio、selectedModels、imageCount をストアに入れたとき、サイドバーは 83 行減って 9 行増えました。

ストアに入れないもの

こうした移行のあとは、全部を入れたくなります。やってはいけませんし、差分そのものがそれを教えてくれます。

モデルのポップオーバーには props が四つありました。open、onOpenChange、selectedModels、onModelsChange。ストアに移ったのは二つだけです。残りの二つは残りましたが、置き場所が下がりました。

export function ModelsPopover() {
  const { selectedModels, setSelectedModels } = useBoardStore();
  const [open, setOpen] = useState(false);

基準は、その値を誰が読む必要があるかです。selectedModels はポップオーバーが読み、推定コストを計算するサイドバーが読み、利用可能な比率を知るために縦横比のセレクタが読みます。open はポップオーバー自身以外、誰も読みません。ストアに上げていたら、理由もなく xOpen という真偽値がグローバル状態に五つ並び、ある日二つ同時に開くバグが出ます。

導出できるものもストアに入れません。利用可能な縦横比の計算は、選択された各モデルが対応する比率の積集合です。selectedModels の関数なので、使う場所に置いて再計算します。ストアに availableAspectRatios フィールドを置けば、同期を保たなければならない複製になり、ずれた日にユーザーはモデルが受け付けない比率を目にします。

  • ストアへ: ツリーの別々の枝が読むもの。
  • コンポーネントのローカルへ: 操作にともなう一時的なもの。開閉、ホバー、フォーカス。
  • どこにも置かない: 上のものから計算できるもの。

ツリーが平らでも、やはりストアを使わないキャンバスエディタ

同じ時期、Konva の上でオフィスの図面エディタを作っていました。Figma のようなもので、対象はデスクです。直感は「これこそストアが要る」と言います。キャンバスがあり、ツールバーがあり、プロパティのパネルがあり、全部が同じ図形に触れるからです。

ストアはありません。いまもルートコンポーネントの useState です。

const [floorPlan, setFloorPlan] = useState<FloorPlan>(initialPlan || DEFAULT_FLOOR_PLAN);
const [editorState, setEditorState] = useState<EditorState>({
  selectedShapeId: null, tool: "select", zoom: 1, isDragging: false,
});

これで動くのは、ツリーが平らだからです。エディタには直接の子が三つあり、それぞれが本当に使うものだけを受け取ります。誰も転送していません。上の基準を良いほうで満たしています。読まない props を受け取る中間のコンポーネントがひとつもないのです。

このエディタで痛かったのは状態ではなく、型の爆発でした。デスクの種類が九つ、別々の型として存在していました。dual-desk-facing、quad-desk-parallel、hex-desk-facing、octo-desk-parallel。名前以外は同一のインターフェースが九つ、キャンバスの switch に九つの分岐、コンポーネントのファイルが九つ。これを直したリファクタリングは、seats フィールドを持つ multi-desk-facing と multi-desk-parallel の二つに畳みました。

{Array.from({ length: seats }, (_, index) => (
  <SingleDesk key={index} ... />
))}

ファイルを七つ削除し、予約画面で図面を描くコンポーネントから 453 行を取り除きました。どんなストアでもこれは直せません。問題は状態の置き場所ではなく、形がいくつあるかだったからです。

状態が props から出ると何が変わるか

失うものは確かにあります。コンポーネントのシグネチャを見ても、何に依存しているか分からなくなります。開いて確かめる必要があります。テストは props で状態を注入できなくなり、ストアを用意しなければなりません。そして、結合しすぎていると知らせてくれていた摩擦が消えます。props の配管は醜いものでしたが、結合度の正直なカウンタでもありました。

だからストアは居場所を勝ち取るものであって、与えられるものではありません。問うべきは props がいくつあるかではありません。経路上のどれかのコンポーネントが、使わない prop を受け取っているかどうかです。