あらゆるReact開発者が、最終的には同じ壁にぶつかります。トップレベルの App コンポーネント内でユーザーオブジェクトを取得し、それを下へと、さらに下へと渡していきます。ルートラッパー、レイアウトシェル、サイドバーコンテナを通り抜け、たった3層下にある小さなアバターコンポーネントがプロフィール画像を表示するためだけに、データを流し続けます。中間のコンポーネントはそのユーザーオブジェクトを必要としていません。ただ荷物を転送しているだけなのです。これが「Prop drilling」であり、クリーンなコンポーネントツリーを、フラストレーションの溜まる「伝言ゲーム」へと変えてしまいます。

本当の苦しみは、そのデータの構造が変わったときに始まります。例えば、バックエンドの仕様が変わり、user.avatar ではなく user.profile.avatar とネストされるようになったとします。すると、そのデータを自分では決して使わない5つのファイルにわたって、TypeScriptのインターフェースやPropTypesを修正しなければならなくなります。そこで登場するのが、React Context APIです。

Contextがどのようにデータフローを再構築するか

Contextを、自宅の中心に設置されたWiFiルーターだと考えてみてください。これがないと、ノートパソコンに信号を送るために、あらゆる部屋にイーサネットケーブルを這わせる必要があります。しかし、ルーターがあれば、電波は空中を介してブロードキャスト(放送)され、正しいパスワードを持つデバイスならどれでも直接接続できます。壁は関係ありません。

Reactの観点では、アプリのルートがコンポーネントツリーを通じてデータをブロードキャストでき、すべてのレイヤーに「運び屋」としての役割を強いる必要がなくなります。ネストされたコンポーネントであれば、どのコンポーネントからでもそのブロードキャストを購読(subscribe)し、必要なものだけを正確に受け取ることができます。

3つの核となる要素

Context APIは、3つの要素に集約されます。

React.createContext() は、ブロードキャスト用のチャンネルをセットアップします。これは、Providerと(古いコードでは)Consumerを含むオブジェクトを返します。特定の機能に対して、これを呼び出すのは一度だけで済みます。

The Provider は、ツリーの一部をラップするコンポーネントです。value という名前のプロップを1つ受け取ります。そのプロップに配置されたものは、どれほど深い階層にあっても、すべての後続の子コンポーネントから利用可能になります。

useContext は、関数コンポーネントがそのブロードキャストを利用できるようにするHookです。コンポーネント内で、作成したコンテキストオブジェクトを useContext に渡すと、現在の値が返されます。これだけです。ラッパーも、余計なプロップも必要ありません。

Hooksが登場する前は、render propsを用いたConsumerパターンを使う必要がありました。それも機能はしましたが、インデントが深くなり、ラッパーによるコードの乱雑さを招いていました。useContext は、それらすべてを関数本体の中のたった一行へと平坦化しました。

Contextが本当に有効な場面

習慣的にContextに頼ってはいけません。Contextは、ツリーの異なる枝にある、互いに関連のない多くのコンポーネント間で共有されるデータのために設計されています。適した候補には以下のようなものがあります:

  • テーマ設定。 ライトモードやダークモードだけでなく、スペーシングのトークン、カラーパレット、フォントスケールなど。これらをすべてのスタイリングされたボタンやモーダルに手動で渡していくのは、すぐに限界が来ます。
  • ユーザー認証。 ログイン状態、権限配列、または現在のユーザーオブジェクト。ヘッダーバー、ダッシュボードのウィジェット、プライベートルートのガードなどは、ツリーの全く異なる場所に存在する可能性があります。
  • 言語設定。 ロケール文字列、日付形式、通貨記号。フォームのラベルのような末端のコンポーネントは、経路上のすべての親コンポーネントにそれらを知られることなく、これらの情報を必要とします。
  • ショッピングカートのデータ。 アイテム数、合計金額、カート追加関数。ヘッダーのバッジとチェックアウトページは同じ状態を必要としますが、通常、それらは全く異なるレイアウトの枝の下に位置しています。

実践的なテーマスイッチャー

Contextの動作を確認する最も分かりやすい方法の一つは、テーマの切り替えです。重要な詳細を省略せずに、どのように実装するかを見てみましょう。

まず、ThemeContext.js ファイルを作成します。React.createContext() を呼び出し、その結果を保存します。次に、useState または useReducer を使って現在のテーマを管理する ThemeProvider コンポーネントを構築します。コンテキストのProviderで children をラップし、現在のテーマとそれを切り替える関数を含むオブジェクトを渡します。ThemeProvider とコンテキストオブジェクト自体の両方をエクスポートします。

次に、アプリのエントリーポイントに移動します。ThemeProvider をインポートし、アプリケーション全体をこれでラップします。このステップを飛ばすと、後でコンテキストを読み取ろうとするものは、デフォルトの値しか見ることができません。

最後に、Header または Content コンポーネント内で、コンテキストオブジェクトと useContext をインポートします。Hookを呼び出し、テーマと切り替え関数を分割代入で取り出し、CSSクラスを条件付きで適用します。切り替え関数を呼び出すボタンを追加します。このコンポーネントは、親から theme プロップを受け取ることはありません。空中から直接信号をキャッチしているのです。

Prop Drilling、Context、それともReduxか?

これらのツールの中からどれを選ぶかは、忠誠心の問題ではなく、ステートの構造の問題です。

Prop drilling は、2、3階層程度の深さであれば全く問題ありません。明示的で、IDEでの追跡も容易であり、依存関係も明確なまま保てます。問題が発生するのは、同じ prop を6、7層ものレイヤーにわたって通し始めたときだけです。

Context API は React 自体に組み込まれています。つまり、追加のバンドルサイズも外部セットアップも不要です。小規模から中規模のグローバルステート、特にテーマやユーザープロフィールのように頻繁には変更されないデータの扱いに非常に優れています。

Redux は追加のライブラリのインストールとボイラープレートの記述が必要です。しかし、ステートのロジックが複雑な場合や、複数のステートスライスが深く相互作用する場合、あるいはタイムトラベルデバッグやミドルウェアが必要な場合には、その価値があります。単純なグローバルデータに対して Redux を使うのは過剰(overkill)です。

誰も語らないパフォーマンスの現実

ジュニアレベルの実装とシニアレベルの実装を分ける落とし穴がここにあります。Context Provider の値が変更されると、そのコンテキストを消費しているすべてのコンポーネントが再レンダリングされます。そのコンポーネントが必要としている特定のデータ(スライス)が変わっていなくても関係ありません。React は新しい参照を検知し、更新をスケジュールします。

アプリケーション全体のステートを一つの巨大な StoreContext に詰め込んでしまうと、実質的に UI 全体を接着してしまうことになります。テーマ設定を変更するだけで、ショッピングカート、ダッシュボードのチャート、通知リストまでもが再レンダリングされてしまいます。これは不要な作業です。

コンテキストはドメインごとに分割しましょう。視覚的な設定には ThemeContext、プロフィールデータには UserContext、コマースの状態には CartContext を用意します。ユーザーが表示名を編集しても、商品グリッドに影響を与えることなくヘッダーだけを更新できます。また、Provider の value プロップに何を渡すかにも注意してください。レンダリング中に { theme, toggleTheme } のようなオブジェクトリテラルをインラインで渡すと、レンダリングのたびに新しい参照が作成され、不要な更新がトリガーされます。値に関数や非プリミティブなデータが含まれる場合は、useMemo を使ってその構造を安定させてください。

何時間ものロスを招くミス

チームが繰り返し陥る2つのエラーがあります。

コンテキストオブジェクトの export を忘れる。 ThemeProvider コンポーネントを export しておきながら、useContext(ThemeProvider) を呼び出そうとしてしまうミスがよくあります。これは正しい使い方ではありません。Hook が必要としているのは createContext によって返されたコンテキストオブジェクトであり、ラッパーコンポーネントではありません。Provider だけを export してしまうと、コンシューマー(利用側)が import するものがなくなってしまいます。

Provider の外で useContext を呼び出す。 Hook は createContext に渡したデフォルト値を返します。デフォルト値を渡していない場合は undefined が返されます。コンポーネントツリー内でコンシューマーが Provider よりも上の階層(DOM上)でレンダリングされていたり、Provider 自体が欠落していたりすると、データは届きません。index ファイルや root ファイルで、アプリが正しくラップされているか再確認してください。

真の教訓

React Context はステート管理の革命ではありません。それは、特定の空間的な問題、つまり「すべてのレイヤーを郵便局に変えることなく、遠く離れたコンポーネントにデータを届ける」ための特化型ツールです。真にグローバルなデータに対してのみ使用し、レンダリングパフォーマンスを守るためにコンテキストをドメインごとに分割し、データを読み取ろうとする前に必ず正しい Provider でツリーをラップしてください。これらの習慣を身につければ、コンポーネントツリーはクリーンで高速、かつ理解しやすい状態を保てるでしょう。