🌐 Context APIの仕組み
Context APIはなぜprop drillingを回避できるのか。 ProviderとConsumerの内部実装、value変更時の再レンダリング範囲、 パフォーマンス問題の原因と対策を深く理解します。
🤔 問題提起:prop drillingとは何か
コンポーネントが深くネストしているとき、深い階層のコンポーネントにデータを渡すために 途中のコンポーネントがそのデータを「素通し」でpropsとして渡し続ける問題を prop drilling(バケツリレー)と呼びます。
// ❌ prop drilling: 途中のコンポーネントが使わないデータを渡し続ける
function App() {
const [user, setUser] = useState({ name: 'Alice', theme: 'dark' });
return <Layout user={user} />; // Layoutはuserを使わないが渡す必要がある
}
function Layout({ user }) {
return <Sidebar user={user} />; // Sidebarもuserを使わないが渡す
}
function Sidebar({ user }) {
return <UserMenu user={user} />; // Sidebarもuserを使わないが渡す
}
function UserMenu({ user }) {
// ← ここでやっとuserを使う!
return <div>こんにちは、{user.name}さん</div>;
}
// 問題:
// 1. Layout・Sidebarは user を使わないのにpropsに追加しなければならない
// 2. userの型が変わると、全ての中間コンポーネントを修正する必要がある
// 3. コードが読みにくくなる 🎯 結論から言う
Contextは「コンポーネントツリーをまたいで値を配布する仕組み」です。
ProviderはFiberツリーに値を「注入」し、useContextはそれを「購読」します。 valueが変わると、そのContextを購読している全コンポーネントが再レンダリングされます。
🗺️ 構造図:ContextのProvider/Consumer
// Context の基本構造
// 1. Contextを作成
const ThemeContext = React.createContext('light'); // デフォルト値
// 2. Providerで値を配布
function App() {
const [theme, setTheme] = useState('dark');
return (
// value が変わると、このProvider以下の購読者が再レンダリング
<ThemeContext.Provider value={theme}>
<Layout /> {/* themeを props で受け取らなくていい */}
</ThemeContext.Provider>
);
}
// 3. 中間コンポーネントはContextを意識しなくていい
function Layout() {
return <Sidebar />; // userを渡さなくていい!
}
function Sidebar() {
return <UserMenu />;
}
// 4. 購読したいコンポーネントでuseContextを使う
function UserMenu() {
const theme = useContext(ThemeContext); // ← Contextから値を取得
return <div className={theme === 'dark' ? 'bg-black' : 'bg-white'}>...</div>;
} Contextは親から子へのpropsのバケツリレーを省略し、Provider → Consumer に直接届く
📚 概念説明:Provider内部の実装
Reactの内部では、ContextはFiberツリーを通じて「スタック」として管理されています。
// React内部のContextスタック(概念的な実装)
// createContextが作るオブジェクト
function createContext(defaultValue) {
const context = {
$$typeof: REACT_CONTEXT_TYPE,
_currentValue: defaultValue, // 現在の値(スタックで管理)
Provider: null,
Consumer: null,
};
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
};
return context;
}
// Providerがレンダリングされるとき(render phase)
function updateContextProvider(workInProgress, context, newValue) {
const oldValue = context._currentValue;
// 値が変わったか Object.is() で確認
if (Object.is(oldValue, newValue)) {
// 変わっていない → 購読者の再レンダリングは不要
} else {
// 変わった! → 購読者を探してレンダリングをスケジュール
propagateContextChange(workInProgress, context, renderLanes);
}
// スタックに新しい値をプッシュ
pushProvider(workInProgress, context, newValue);
}
// コンポーネントがアンマウント(またはProviderの外に出る)とき
// スタックから値をポップする
function popProvider(context) {
context._currentValue = previousValue; // 上位のProviderの値に戻す
} ネストしたProviderの動作
// Providerはネストできる(スタック構造で管理)
function App() {
return (
<ThemeContext.Provider value="light">
<div>
{/* この中では theme = "light" */}
<ThemeContext.Provider value="dark">
{/* この中では theme = "dark" (上書き)*/}
<InnerComponent />
</ThemeContext.Provider>
<OuterComponent /> {/* theme = "light" に戻る */}
</div>
</ThemeContext.Provider>
);
}
// useContext は「最も近い上位のProvider」の値を取得する デフォルト値が使われるケース
createContext(defaultValue)のデフォルト値は、
対応するProviderが存在しないときにのみ使われます。
<Provider value={undefined}>のように
明示的にundefinedを渡した場合は
デフォルト値ではなくundefinedが使われます。
💻 コード例:value変更時の再レンダリング範囲
Contextの最も重要(そして誤解されやすい)特性:
valueが変わると、そのContextを購読しているコンポーネントが全て再レンダリングされます。
中間コンポーネントがReact.memoでラップされていても無効化されます。
const UserContext = React.createContext(null);
function App() {
const [user, setUser] = useState({ name: 'Alice', theme: 'dark' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
// React.memoでラップしていても、Contextの変更は伝わる
const Layout = React.memo(function Layout() {
console.log('Layout レンダリング'); // Contextと無関係なら呼ばれない
return <Sidebar />;
});
const Sidebar = React.memo(function Sidebar() {
console.log('Sidebar レンダリング'); // Contextと無関係なら呼ばれない
return <UserDisplay />;
});
function UserDisplay() {
const user = useContext(UserContext);
console.log('UserDisplay レンダリング'); // userが変わると必ず呼ばれる
return <div>{user.name}</div>;
}
// user.theme だけ変わったとき:
// - Layout: React.memoがpropsを確認 → 変わってない → スキップ ✓
// - Sidebar: 同様にスキップ ✓
// - UserDisplay: UserContextを購読しているので再レンダリング
// (nameは同じなのにthemeが変わったので再レンダリングされてしまう!) 重要:React.memoはContextの再レンダリングを止められない
React.memoはpropsの変更による
再レンダリングを防ぎますが、Contextの変更による再レンダリングは防げません。
ContextをuseContextで購読している限り、
valueが変わると必ず再レンダリングが発生します。
🔧 仕組み分解:Object.isによるvalue比較
Providerのvalueもstateのupdateと同様にObject.is()で比較されます。
これがContextの「よくあるパフォーマンス問題」の原因になります。
// ❌ よくあるパフォーマンスの落とし穴
function App() {
const [user, setUser] = useState({ name: 'Alice' });
return (
// レンダリングのたびに新しいオブジェクトが作られる!
<UserContext.Provider value={{ user, setUser }}>
{/* ↑ { user, setUser } は毎回新しい参照 */}
{/* → Object.is() で比較すると常に「変わった」と判断 */}
{/* → 全ての購読者が毎回再レンダリングされる! */}
<App />
</UserContext.Provider>
);
}
// ✓ useMemoでvalueをキャッシュ
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const contextValue = useMemo(
() => ({ user, setUser }),
[user] // userが変わったときだけ新しいオブジェクトを作る
);
return (
<UserContext.Provider value={contextValue}>
<Main />
</UserContext.Provider>
);
} useContextの内部実装(概念)
// useContextの内部(概念的な実装)
function useContext(context) {
const fiber = getCurrentFiber(); // 現在実行中のコンポーネント
// このFiberがContextを購読していることを登録
// (後でContextが変わったときに通知するため)
readContext(fiber, context);
// 現在のProvider valueを返す
return context._currentValue;
}
// propagateContextChange(Context変更の伝播)
function propagateContextChange(workInProgress, context, renderLanes) {
// Fiberツリーを走査して購読者を探す
let fiber = workInProgress.child;
while (fiber !== null) {
// このFiberのdependenciesを確認
const dependencies = fiber.dependencies;
if (dependencies !== null) {
// このFiberが変更されたContextを購読しているか確認
let dependency = dependencies.firstContext;
while (dependency !== null) {
if (dependency.context === context) {
// 購読している! → 再レンダリングをスケジュール
scheduleContextWorkOnParentPath(fiber.return, renderLanes, workInProgress);
break;
}
dependency = dependency.next;
}
}
// 子・兄弟を再帰的に確認...
fiber = fiber.child || fiber.sibling;
}
} ⚡ パフォーマンス問題とContext splitting
1つのContextに多くのデータを詰め込むと、一部のデータしか使わないコンポーネントも 全体の変更のたびに再レンダリングされます。 Context splitting(分割)がその解決策です。
Context splittigの実装パターン
// ❌ 1つのContextに全部詰め込む
const AppContext = React.createContext(null);
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [locale, setLocale] = useState('ja');
return (
// userが変わるとtheme/localeを使うコンポーネントも再レンダリング!
<AppContext.Provider value={{ user, setUser, theme, setTheme, locale, setLocale }}>
<Main />
</AppContext.Provider>
);
}
// ✓ 変化の頻度や用途でContextを分割する
const UserContext = React.createContext(null); // 頻繁に変わる
const ThemeContext = React.createContext('light'); // たまに変わる
const LocaleContext = React.createContext('ja'); // ほとんど変わらない
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [locale, setLocale] = useState('ja');
return (
<LocaleContext.Provider value={locale}>
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={{ user, setUser }}>
<Main />
</UserContext.Provider>
</ThemeContext.Provider>
</LocaleContext.Provider>
);
}
// userが変わっても、ThemeContextやLocaleContextの購読者は再レンダリングされない 読み取りと書き込みを分離するパターン
// より高度なパターン: 読み取りと更新を別のContextに分ける
const UserStateContext = React.createContext(null); // 状態(頻繁に変わる)
const UserDispatchContext = React.createContext(null); // dispatch(変わらない)
function UserProvider({ children }) {
const [user, dispatch] = useReducer(userReducer, null);
return (
<UserDispatchContext.Provider value={dispatch}>
{/* dispatchは不変(useReducerが保証)なので再レンダリングが起きない */}
<UserStateContext.Provider value={user}>
{children}
</UserStateContext.Provider>
</UserDispatchContext.Provider>
);
}
// 状態だけ必要なコンポーネント
function UserProfile() {
const user = useContext(UserStateContext); // userが変わると再レンダリング
return <div>{user?.name}</div>;
}
// dispatchだけ必要なコンポーネント
function LogoutButton() {
const dispatch = useContext(UserDispatchContext); // 再レンダリングされない!
return <button onClick={() => dispatch({ type: 'LOGOUT' })}>ログアウト</button>;
} 🔗 useContextとContext.Consumerの違い
// 古いAPI: Context.Consumer(render props)
function UserMenu() {
return (
<UserContext.Consumer>
{user => ( {/* render prop パターン */}
<div>{user?.name}</div>
)}
</UserContext.Consumer>
);
}
// 現代的なAPI: useContext(Hooks)
function UserMenu() {
const user = useContext(UserContext);
return <div>{user?.name}</div>;
}
// どちらも同じContextを購読する
// useContextの方がシンプルで読みやすい
// 重要な違い:
// Context.Consumerは「このコンポーネントの中の特定部分だけ」を更新できた(レンダープロップ)
// useContextは「コンポーネント全体」を再レンダリングする
// ほとんどの場合はuseContextで十分
// Context.Consumerは後方互換性のためにRemain in React useContextとZustand等との比較
- React組み込み(ライブラリ不要)
- 低頻度の更新(テーマ、ロケール、認証情報)に適している
- 頻繁な更新は注意が必要(全購読者が再レンダリング)
- Context splittingで対応可能だが設計が複雑になる
- セレクター(部分購読)が使える
- 「このフィールドだけ変わったとき」のみ再レンダリング
- 高頻度な更新や複雑な状態管理に向いている
- 追加ライブラリが必要
使い分けの目安
テーマ・ロケール・認証情報のような「アプリ全体で使うが変化頻度が低い」データはContext APIが適しています。 ユーザーの操作に伴って頻繁に変わるデータ(カートの中身・フォームの状態など)は、 ZustandやJotaiなどのセレクターを持つライブラリの方が再レンダリングを効率的に制御できます。
📌 まとめ
- ✓ Contextはprop drillingを解決するための「ツリーをまたいだ値の配布機構」
- ✓ Providerのvalueが変わると、そのContextを購読している全コンポーネントが再レンダリング
- ✓ value比較はObject.is()で行われる——毎回新しいオブジェクトを渡すと無限に再レンダリング
- ✓ React.memoはContextの再レンダリングを防げない(props変更のみ防ぐ)
- ✓ useMemoでContextのvalueをキャッシュすることで不要な再レンダリングを防ぐ
- ✓ Context splittingで変化頻度が異なるデータを分けると再レンダリング範囲を絞れる
- ✓ 読み取り/書き込みContextの分離パターンで、dispatchだけ使うコンポーネントを最適化できる