4.4 Context — 使いどころと避けどころ
Part 3.5 で扱った props は、親から子への 明示的な データ伝達の手段でした。深い階層に値を渡したいだけなのに、途中のコンポーネント全部に props を書かされる状況 —— これを prop drilling(バケツリレー) と呼びます。Context は、この prop drilling を避けるための仕組みです。最初は「どこで使う / 使わない」の線引きで迷いやすいので、使いどころと避けどころを対にして整理していきましょう。
メンタルモデル: 祖先の「空気」を子孫が読む
Context は、祖先コンポーネントが提供(provide)した値を、子孫コンポーネントがどの階層にいても読み取れる 仕組みです。途中のコンポーネントはその値を知らなくて構いません。
Button は Page / Sidebar / Menu を経由せずに、useContext(ThemeContext) で直接 Root の値 "dark" を読めます。
触って比べる — prop drilling と Context
下のデモで階層の深さを変えたり、モードを切り替えたりして、両方の挙動を見比べてみてください。prop drilling では中間の Layer すべてに theme を渡さないといけませんが、Context では中間を素通りして末端の Button が直接値を読めることが伝わるはずです。
階層を深くすると、prop drilling モードでは全ての中間 Layer に theme を書き渡す必要があることが伝わります。 Context モードでは中間は素通りし、Button が直接値を読みます。
3 ステップで使う
Step 1: Context を作る
// theme-context.ts
import { createContext } from "react";
export type Theme = "light" | "dark";
export const ThemeContext = createContext<Theme>("light");createContext(defaultValue) の引数は、Provider が上に 1 つもなかった場合に使われるフォールバック値 です(テストやコンポーネント単体表示用の安全装置として機能します)。
Step 2: Provider として値を提供する
React 19 では <Context> 自体を Provider として使える記法が入りました。
// React 19 での推奨形
import { useState } from "react";
import { ThemeContext, Theme } from "./theme-context";
export function App({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
return (
<ThemeContext value={theme}>
{children}
</ThemeContext>
);
}React 18 までは <ThemeContext.Provider value={theme}> と書く必要がありました。React 19 では <ThemeContext value={theme}> で同じ意味 になります。.Provider は将来的に非推奨になる予定です。
Step 3: 子孫で読む
import { useContext } from "react";
import { ThemeContext } from "./theme-context";
function Button() {
const theme = useContext(ThemeContext);
return <button data-theme={theme}>click</button>;
}use(ThemeContext) でも同じように読めます。違いは、use なら 条件分岐や早期 return の後 でも呼べる点です(4.2 参照)。
図 4.4.1 Context の 3 ステップ
典型的な使いどころ
公式ドキュメントが挙げている用途は次の通りです。
- テーマ: ダーク / ライト切り替え
- 認証ユーザー: ログインユーザー情報
- ルーティング: 現在のルート情報(React Router などの内部実装で使われています)
- i18n: 表示言語・翻訳テーブル
共通項は、「アプリ全体 / 大きな木で共有され、更新頻度が低く、読みたいコンポーネントが広く散らばっている値」です。
避けどころ
Context は強力ですが、すべての「値を共有したい場面」に向くわけではありません。公式ドキュメントでも、Context を使う前に次を検討するよう促しています。
- props で渡せないか: 深い階層に渡すのが面倒でも、「誰が何に依存しているか」がコードで明示されるのは大きな利点です。
- コンポーネント合成(composition)で回避できないか: 中間コンポーネントに
childrenを取らせて、データを知っているコンポーネントを直接子として置く方法です。tsx// 避けたい: Layout に posts を渡して、中で Posts にバケツリレー <Layout posts={posts} /> // 望ましい: Posts を直接 children として渡す <Layout> <Posts posts={posts} /> </Layout>
Context に向かない値
- 頻繁に変わる値(秒単位で更新される時刻、アニメーションのフレームなど): Context の値が変わるたびに、
useContextを呼んでいるすべての子孫が再レンダーされます。 - 大きな配列 / 深いオブジェクト: 上記と同じ理由で、一部が変わっただけでも全コンシューマーが再レンダーされてしまいます。
- 全く関係のない複数の値をまとめる: 1 つの Context に
user/theme/localeを全部入れると、どれか 1 つの変更で全員が再レンダーされてしまいます。関心ごとに Context を分ける のが基本です。
再レンダーの仕組み
useContext(MyContext) を呼ぶコンポーネントは、MyContext の value が Object.is で変わった瞬間に再レンダー されます。途中のコンポーネントが memo されていても、これは止まりません。
// これは毎レンダーで新しいオブジェクトが作られてしまう
<AuthContext value={{ user, login }}>
{children}
</AuthContext>上記は毎レンダーで value の参照が変わるため、全コンシューマーが再レンダーされます。対策は useMemo で参照を安定させることです。
import { useMemo } from "react";
const authValue = useMemo(() => ({ user, login }), [user, login]);
<AuthContext value={authValue}>{children}</AuthContext>;useReducer + Context の組み合わせ(軽量な状態管理代替)
中規模アプリで「Redux を入れるほどではないけれど、useState を各所で書くのは辛い」というときは、useReducer と Context を組み合わせた構成がよく使われます。公式ドキュメントの Scaling Up with Reducer and Context でも紹介されている手法です。
要点: state と dispatch を 別の Context に分ける ことで、dispatch しか使わないコンポーネントは state 変化で再レンダーされなくなります。
// tasks-context.tsx (構造のみ抜粋)
import { createContext, useContext, useReducer, ReactNode } from "react";
type Task = { id: string; title: string; done: boolean };
type Action =
| { type: "add"; title: string }
| { type: "toggle"; id: string }
| { type: "remove"; id: string };
const TasksContext = createContext<Task[] | null>(null);
const TasksDispatchContext = createContext<React.Dispatch<Action> | null>(null);
function reducer(state: Task[], action: Action): Task[] {
switch (action.type) {
case "add":
return [...state, { id: crypto.randomUUID(), title: action.title, done: false }];
case "toggle":
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "remove":
return state.filter((t) => t.id !== action.id);
}
}
export function TasksProvider({ children }: { children: ReactNode }) {
const [tasks, dispatch] = useReducer(reducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
const tasks = useContext(TasksContext);
if (tasks === null) throw new Error("TasksProvider の外側です");
return tasks;
}
export function useTasksDispatch() {
const dispatch = useContext(TasksDispatchContext);
if (dispatch === null) throw new Error("TasksProvider の外側です");
return dispatch;
}利用側はこうなります。
function AddTask() {
const dispatch = useTasksDispatch(); // dispatch だけ欲しい
return (
<button onClick={() => dispatch({ type: "add", title: "Buy milk" })}>
Add
</button>
);
}
function TaskList() {
const tasks = useTasks(); // tasks だけ欲しい
return (
<ul>
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}図 4.4.2 Reducer + Context の分離設計
state の変化は TaskList だけを再レンダーさせ、AddTask は静かに居られます。
Part 7 に進む前に
Context + Reducer で足りないケース(グローバルな読み取り頻度が高い、複雑な更新ロジックが絡む、デバッグツールが欲しい)では、Zustand / Redux Toolkit などを検討します。詳しくは 7.2 状態管理 で扱います。