状態の局所化(State Colocation)の原則:グローバル汚染を防ぎ、Reactのレンダリング機構を極限までハックする
こんにちは。日々、巨大化していくReactアプリケーションのパフォーマンスプロファイリングとコードベースの浄化に明け暮れているチーフアーキテクトだ。
君はこんな経験はないだろうか?
「ボタンをクリックした時のモーダルの開閉フラグごときが、なぜか最上位のReduxストアや、アプリ全体のルートにある`App.js`の`useState`に鎮座している」
「たった一つの入力フォームの文字入力が、アプリ全体のツリーを巻き込んで再描画を引き起こしている」
……溜息が出るよな。世の中のチュートリアルや初学者向けの教材は、やれ「グローバルステートが正義だ」「とりあえずContext APIに突っ込め」と無責任に囁く。その結果、生まれたのは、コンポーネントが肥大化し、どこで何が更新されているのか誰も追えない「スパゲッティ・DOMツリー」だ。
今回は、Reactの内部挙動――Fiberアーキテクチャやリコンシリエーション(差分検出処理)のメカニズムを踏まえながら、「状態の局所化(State Colocation)」という、上級エンジニアなら骨身に染み込ませておくべき設計原則について、徹底的に深掘りしていこう。
—
1. なぜグローバルステートの乱用は「悪」なのか?(内部挙動の視点)
Reactのレンダリングは、基本的に「親が再描画されれば、その子孫コンポーネントもデフォルトで全て再描画される」というドミノ倒しの構造を持っている。もちろん、`React.memo`や`useMemo`で防衛線を張ることはできるが、それらは銀の弾丸ではない。
もし、画面の片隅にある「アコーディオンの開閉状態(boolean)」を、親やグローバルなストアに持たせたとしたらどうなるか?
その状態が更新された瞬間、Reactはルート、あるいは共通の親からの差分計算(Reconciliation)を強制される。ブラウザのメインスレッドは無駄なVDOMの比較にCPUサイクルを奪われ、ユーザーのインタラクティブな操作に対する遅延(Jank)を生み出す。
メモリ効率の観点からも最悪だ。本来、そのコンポーネントが破棄されれば一緒にガベージコレクションされるはずの状態やコールバックが、不要に生存期間(Lifetime)を引き伸ばされることになる。
—
2. 状態の局所化(State Colocation)とは何か?
定義は極めてシンプルだ。
> 「状態は、それを必要とする最も近い共通の祖先、あるいはそのコンポーネント自身の中に閉じ込めよ」
これだけだ。だが、この原則を徹底するだけで、アプリケーションの結合度は劇的に下がり、モジュール性と再利用性が跳ね上がる。
百聞は一見にしかず。実際のコードを通じて、アンチパターンと正しい局所化のアプローチを見比べてみよう。
アンチパターン:全ての状態を親に集約する病
以下は、よくある「全てを親で管理したがる」ジュニア・ミドル層のコードだ。
import React, { useState } from ‘react’;
// 良くない例:すべての状態が親に集中している
export function DashboardApp() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(”);
const [selectedTab, setSelectedTab] = useState(‘overview’);
// サーチクエリが変わるたびに、サイドバーやタブとは一切関係ない
// DashboardApp全体の不必要な再描画が走る。
return (
);
}
この構成では、`searchQuery`が1文字変わるたびに、`Sidebar`や無関係なタブまで巻き込んでレンダリングの嵐が起きる。
—
3. 実践:状態を限界まで「沈める(Colocate)」
では、これを局所化の原則に従ってリボーンさせてみよう。状態は、それを必要とする最小限のスコープまで落とし込む。
import React, { useState, useMemo } from ‘react’;
// 1. サイドバーの状態は、サイドバー自身、あるいはそれを囲む最小限のラッパーに閉じる
function SidebarSection() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
);
}
// 2. 検索とコンテンツの関心事をまとめたコンポーネント
function SearchableContent() {
const [searchQuery, setSearchQuery] = useState(”);
// ここで状態を持つことで、Sidebarの開閉はこのコンポーネントに一切影響を与えない
return (
{/ 検索結果に応じた重いコンポーネントをここに閉じ込める /}
);
}
// ルートはただのレイアウトコンテナとして機能する
export function DashboardAppOptimized() {
return (
);
}
この設計がもたらす圧倒的なメリット
1. レンダリングの局所化: `searchQuery`の更新は、`SearchableContent`とその配下だけで完結する。`SidebarSection`の再描画は完全にブロックされる。
2. 認知負荷の軽減(Locality of Behavior): コードを読む際、「この変数はどこから来ているのか?」を上へ上へと探す必要がない。そのコンポーネントのファイル、あるいは関数内を上から下まで読めば全てが完結する。
3. 並列・独立した開発: 他の開発者とのコンフリクトが起きにくく、パーツ単位でのテスト(Testing Library等)が極めて容易になる。
—
4. 「どこまで落とすべきか?」の判断基準
現場で設計をしていると、「この状態はどこに置くべきか?」と迷う瞬間が必ず訪れる。その時に立ち返るべき判断基準を授けよう。
1. その状態を使っているのは、単一のコンポーネントか?
- $\rightarrow$ 迷わずそのコンポーネント内の`useState`にする。カスタムフック(`useCustomLogic`)に切り出すのも手だ。
2. その状態を使っているのは、ある親と、その直下の子一匹だけか?
- $\rightarrow$ 親に置いてプロパティ(Props)で渡すか、あるいは「子コンポーネント自体を親のJSXの `children` として渡す(内容の逆転・Inversion of Control)」ことで親の再描画を回避できないか検討する。
3. 複数の離れたコンポーネント(例:ヘッダーのカートアイコンと、商品一覧の「カートに追加」ボタン)で共有する必要があるか?
- $\rightarrow$ ここで初めてグローバルステート(Context API, Zustand, Redux Toolkit等)の出番だ。
例外:コンポジション(Composition)による最適化
どうしても状態を親に持たせざるを得ない場合でも、Reactのコンポジションパターンを使えば、無駄な再描画を防げる。
// 子要素をchildrenとして受け取ることで、親のstate変更による子全体の再描画を回避するテクニック
function ParentWithChildren() {
const [count, setCount] = useState(0);
return (
{/ 渡されたchildrenは、Parentのレンダリング時点で既に評価されているため、
countが変化しても再描画の伝播を回避できるケースがある /}
);
}
—
5. チーフアーキテクトからの結びの言葉
「状態管理ライブラリを入れたら設計がキレイになる」というのは、現代のフロントエンドにおける最大級の幻想だ。便利ツールは、時として設計の怠慢を隠すための麻薬になる。
State Colocation(状態の局所化)は、単なるコーディング規約ではない。ReactというUIエンジンの特性を深く理解し、CPUサイクルとメモリ、そして人間の認知負荷を最適化するための「極めてエンジニアリング的なアプローチ」だ。
次に `useState` や `useContext` を書くとき、その手を一度止めて自問してほしい。
「この状態、本当にそんな高いレイヤーに置く必要あるか?」
その一手間が、数ヶ月後のプロダクトのパフォーマンスを救い、君を真のフロントエンド・スペシャリストへと押し上げるはずだ。さあ、エディタを開いて、コードベースの不必要なグローバル汚染を刈り取りに行こうか。

コメント