画像生成の 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 を受け取っているかどうかです。
