「useContextって結局どう使うのが正解?」「Reduxを入れるほどじゃないけど、propsをバケツリレーするのも限界…」――Reactで中規模アプリを書き始めた瞬間、誰もが直面する悩みです。本記事はuseContext 使い方とReact Context 状態管理を軸に、createContext・Context.Provider・useContextの基本から、Provider分割・Selectorパターン・useSyncExternalStoreによる再レンダリング抑制、さらにRedux Toolkit・Zustand・Jotai・Recoil・Valtio・TanStack Queryへの移行判断までを、サンプルコード20本超・比較表3つ・FAQ7問で徹底解説します。読み終えるころには、「Contextだけで戦える領域」と「ライブラリを入れるべき領域」の境界が腹落ちし、現場でレビュー時に堂々と判断を下せるようになっているはずです。
useContextはなぜ必要なのか
Reactのコンポーネントツリーは、propsを上から下へ受け渡すことで状態を共有します。しかしツリーが深くなるほど「中継するだけのコンポーネント」が増え、いわゆるprops drilling(バケツリレー)が発生します。useContextは、この問題を解決するためにReactが標準で提供しているツリー横断の値配信機構です。Reduxのような外部ライブラリではなく、Reactそのものの一機能である点が重要です。
props drillingの典型的な構造
たとえば、最上位のAppで取得したログインユーザー情報を、5階層下のUserAvatarに渡したい場合、本来関係のない中間コンポーネントすべてにuser propsを書く羽目になります。Contextを使えば、Providerで一度配信した値を、必要なコンポーネントがuseContextで直接受け取れます。
// drillingあり: 中間コンポーネントが全てuserを中継
<App user={user}>
<Layout user={user}>
<Header user={user}>
<Nav user={user}>
<UserAvatar user={user} />
</Nav>
</Header>
</Layout>
</App>
useContextが向いている用途・向いていない用途
Contextは万能ではありません。「めったに変わらない値・ツリー全体で共有する値」に向いており、「秒間に何度も変わる値・大量のコンポーネントが個別に購読する値」には向きません。後者はContext単体ではパフォーマンス問題を引き起こすため、後述のSelectorパターンや外部ストアと組み合わせます。
Reactの公式ドキュメントで明示されている位置づけ
React公式はuseContextを「状態管理ライブラリではなく、値を配信する仕組み」と明確に位置づけています。つまりContextそれ自体は状態を持たず、Providerに渡された値をそのまま下流に流すパイプにすぎません。状態はuseStateやuseReducerで管理し、その結果をContextで配信する、というのが正しい設計です。
createContextとuseContextの基本構文
まずは最小構成でContextを動かしてみましょう。createContextでコンテキストオブジェクトを作り、Context.Providerで値を配信し、useContextで受け取る、という3点セットが基本です。
最小サンプル: テーマカラーの配信
import { createContext, useContext, useState } from "react";
// 1. Contextオブジェクトの生成(デフォルト値を指定)
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("dark");
// 2. Providerで値を配信
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
}
function Page() {
// 3. useContextで受け取る
const theme = useContext(ThemeContext);
return <div className={theme}>現在のテーマ: {theme}</div>;
}
createContextの引数とデフォルト値の役割
createContext(defaultValue)のdefaultValueは、ProviderでラップされていないコンポーネントがuseContextを呼んだときに返される値です。テストや単体描画で便利ですが、本番ではProviderで必ずラップする運用が安全です。
Providerのvalueに渡せるもの
Providerのvalueには、プリミティブ・オブジェクト・関数・配列など任意のJavaScript値を渡せます。ただし毎レンダリングで新しいオブジェクトを生成すると、すべての購読コンポーネントが再レンダリングされるため、後述のuseMemoでの参照固定が重要になります。
カスタムフックでラップする定石
useContextをコンポーネント内で直接呼ぶのではなく、useThemeのようなカスタムフックでラップするのが定番です。Provider未配置時のエラーチェックも入れられ、利用側のコードも読みやすくなります。
function useTheme() {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error("useTheme must be used within ThemeProvider");
}
return ctx;
}
useReducerと組み合わせた本格的な状態管理
Contextは値を配信するだけなので、状態のロジックは別途useStateやuseReducerで持たせる必要があります。特にアクション種類が多い場合はuseReducer + Contextの組み合わせが「ミニReduxパターン」として定着しており、外部ライブラリなしで複雑な状態を扱えます。
カート状態をuseReducer + Contextで管理する
import { createContext, useContext, useReducer } from "react";
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "add":
return { ...state, items: [...state.items, action.item] };
case "remove":
return {
...state,
items: state.items.filter((i) => i.id !== action.id),
};
case "clear":
return { ...state, items: [] };
default:
return state;
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext.Provider value={state}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
export const useCartState = () => useContext(CartStateContext);
export const useCartDispatch = () => useContext(CartDispatchContext);
stateとdispatchを別Contextに分けるメリット
上記のようにstateとdispatchを別々のContextで配信すると、dispatchしか使わないコンポーネント(例: 「追加」ボタン)はuseCartDispatchのみ購読し、state変更による再レンダリングの影響を受けません。これは後述する「Context再レンダリング問題」への第一の対策です。
初期値を遅延評価する
useReducerの第3引数に初期化関数を渡せば、初期値の計算を1回だけに抑えられます。LocalStorageからの復元など、コストの高い初期化に有効です。
const [state, dispatch] = useReducer(
cartReducer,
null,
() => {
const saved = localStorage.getItem("cart");
return saved ? JSON.parse(saved) : { items: [] };
}
);
Contextの再レンダリング問題と解決策
Contextを使い始めると、ほぼ確実にぶつかるのが「Providerの値が変わると、useContextしている全てのコンポーネントが再レンダリングされる」問題です。これはReactの仕様であり、React.memoでは止められません。原因と対策を順に押さえます。
なぜReact.memoでは止まらないのか
React.memoはpropsの変化を比較して再レンダリングを抑制しますが、Contextの値変化は「内部の購読」によって伝わるため、propsが変わっていなくても再描画が走ります。「memoで囲めば安全」は誤りです。
解決策1: Provider valueをuseMemoで固定する
Providerのvalueにオブジェクトリテラルを直接渡すと、親の再レンダリングのたびに新しい参照が生まれ、配信される全コンポーネントが再描画されます。useMemoで参照を固定するのが第一歩です。
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
// valueの参照をuserとsetUserが変わったときだけ更新
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
解決策2: Providerを目的別に分割する
1つの巨大Contextに全状態を詰めるのではなく、関心ごとに小さなProviderへ分割します。テーマ・認証・カート・通知などを別Contextにすれば、テーマ切替時に認証情報の購読者が巻き込まれることはありません。
// 悪い例: 全てを1つにまとめる
<AppContext.Provider value={{ theme, user, cart, notifications }}>...</AppContext.Provider>
// 良い例: 関心ごとに分割
<ThemeProvider>
<AuthProvider>
<CartProvider>
<NotificationProvider>{children}</NotificationProvider>
</CartProvider>
</AuthProvider>
</ThemeProvider>
解決策3: Selectorパターン(use-context-selector)
Context全体ではなく、その中の特定フィールドだけを購読したい場合、use-context-selectorライブラリが定番です。Reactには公式のContext selectorは未実装で、コミュニティ実装で補う形になります。
import { createContext, useContextSelector } from "use-context-selector";
const StoreContext = createContext({ count: 0, name: "" });
function CountDisplay() {
// countが変わったときだけ再レンダリング
const count = useContextSelector(StoreContext, (s) => s.count);
return <p>{count}</p>;
}
解決策4: useSyncExternalStoreで外部ストア化する
React 18で追加されたuseSyncExternalStoreを使えば、Context経由でなく外部ストアを購読でき、フィールド単位の選択購読が標準APIで可能になります。ZustandやValtioが内部で使っているのもこのAPIです。
import { useSyncExternalStore } from "react";
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState: (next) => {
state = { ...state, ...next };
listeners.forEach((l) => l());
},
subscribe: (l) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
const store = createStore({ count: 0 });
function Counter() {
const count = useSyncExternalStore(
store.subscribe,
() => store.getState().count
);
return <button onClick={() => store.setState({ count: count + 1 })}>{count}</button>;
}
useContext vs Redux/Zustand/Jotai 比較
「Contextでいいのか、ライブラリを入れるべきか」は永遠のテーマです。代表的な選択肢を実務観点で比較します。
主要状態管理ライブラリの比較表
| 観点 | useContext | Redux Toolkit | Zustand | Jotai |
|---|---|---|---|---|
| 学習コスト | 低 | 中〜高 | 低 | 低 |
| バンドル増 | 0KB | 約13KB | 約1KB | 約3KB |
| Provider必要 | 必須 | 必須 | 不要 | 推奨 |
| Selector | 標準なし | あり(reselect) | あり | atom単位で自動 |
| DevTools | なし | 公式強力 | あり | あり |
| 非同期 | 手書き | createAsyncThunk | async関数で素直に | atomWithSuspense等 |
| 想定規模 | 小〜中 | 中〜大 | 小〜大 | 中 |
Reduxを今選ぶべきケース
Redux Toolkitは依然として大規模アプリ・厳密なアクション履歴・タイムトラベルデバッグが必要な現場で第一候補です。createSlice・createAsyncThunk・RTK Queryが揃い、サーバー状態とクライアント状態を同じ思想で扱えます。
Zustandがハマるケース
Zustandは「Provider不要・hooksベース・selector標準装備」という三拍子で、中小規模〜中規模に最適です。useContextの薄い代替として導入でき、グローバルカウンタやモーダル制御程度なら数行で完結します。
import { create } from "zustand";
const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
function CartCount() {
// selectorで購読フィールドを絞る
const count = useCart((s) => s.items.length);
return <span>{count}件</span>;
}
Jotai・Recoil・Valtioの位置づけ
Jotaiは「atom」という極小単位で状態を持つ思想で、細粒度の購読を最も自然に書けます。Recoilは元Facebook発の同じ系統で、Jotaiの方が活発に保守されている印象です。ValtioはProxyベースで「ミューテーション風に書けるイミュータブル更新」が特徴で、フォーム状態と相性がよいです。
サーバー状態はTanStack Queryに任せる
覚えておきたいのは、APIから取ってくるデータは状態管理ライブラリで持たないのが2026年の主流という点です。TanStack Query(旧React Query)やSWRがキャッシュ・再検証・楽観的更新を一手に引き受けるため、Context/Zustand/Reduxには「クライアント固有のUI状態」だけを残すと設計がきれいに整います。
Provider分割と設計パターン
useContextで中規模アプリを構築する際、Provider設計の質がコードベースの寿命を左右します。ここでは実務で使えるパターンをまとめます。
パターン1: State/Dispatch分離Provider
前述のCartStateContext・CartDispatchContextのように、読み取りと更新を別Contextにする方式です。読み取り側は値変化で再レンダリングされますが、更新側(dispatch)は参照が安定するため不要な再描画を回避できます。
パターン2: Composition Provider
複数のProviderを1つのAppProviderに合成して、ルートをすっきり保つパターンです。テストやStorybookで一括ラップしやすくなります。
function AppProvider({ children }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<CartProvider>{children}</CartProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
パターン3: スコープ付きProvider
同じContextを異なる値でツリーの一部だけ上書きするパターンです。ダッシュボード全体は「dark」、設定モーダル内だけ「light」など、ローカルなテーマ切替に有効です。
<ThemeContext.Provider value="dark">
<Dashboard />
<ThemeContext.Provider value="light">
<SettingsModal />
</ThemeContext.Provider>
</ThemeContext.Provider>
パターン4: グローバルvsローカルの判断軸
「Contextに載せるか、コンポーネント内のuseStateに留めるか」の判断はシンプルです。2階層以上下に渡したい、または横断的に複数ブランチで読みたい場合のみContextを検討します。それ以外はpropsで十分です。早すぎるContext化は、テスト容易性とコンポーネントの独立性を損ねます。
Provider設計のチェックリスト
- Providerの
valueはuseMemoで参照固定しているか - StateとDispatchは分離されているか
- 1Providerに無関係な状態を詰め込んでいないか
- カスタムフックでuseContextをラップし、Provider未配置時にエラーを投げているか
- テスト用のラッパーProviderを用意しているか
パフォーマンス最適化の実践
Context関連のパフォーマンス問題は、計測なしの「念のためのmemo」では悪化することもあります。ここでは計測ベースで最適化する手順を示します。
React DevTools Profilerで何を見るか
Chrome拡張のReact Developer ToolsのProfilerタブで操作を録画すると、コミットごとに「どのコンポーネントが・なぜ再レンダリングされたか」が表示されます。原因列に「Context changed」と出ているコンポーネントが、Contextによる再描画の対象です。
React.memoとuseMemoの併用パターン
Context値を直接購読する末端コンポーネントは、それ自体は再描画されてOKです。しかしその下流の重いコンポーネントにはReact.memoをかけ、propsをuseMemoで安定化させることで、再描画の伝搬を止められます。詳細はuseMemo vs useCallback完全比較でも解説しています。
Contextの分割でN+1問題を避ける
1つのContextに大きなオブジェクトを載せると、どれか1フィールドの変更で全購読者が再描画されます。変更頻度の異なるフィールドを別Providerに分けるのが最も効果的な対策です。「秒間更新されるマウス座標」と「滅多に変わらないユーザー情報」を同じContextに入れてはいけません。
計測サンプル: Profilerで効果を確認
import { Profiler } from "react";
function onRender(id, phase, actualDuration) {
console.log(`[${id}] ${phase} ${actualDuration.toFixed(2)}ms`);
}
<Profiler id="CartList" onRender={onRender}>
<CartList />
</Profiler>
2026年: React Compilerとの関係
React 19系で正式リリースされたReact Compilerは、useMemo・useCallbackの手書きを大幅に削減してくれますが、Contextの再レンダリング問題そのものは解決しません。Provider分割やSelectorの設計判断は、依然として開発者の責任領域です。React Compilerの詳細はReact Hooks 完全実践ガイドも参照してください。
useContextからライブラリへの移行判断
「いつContextを卒業して状態管理ライブラリを入れるか」は、アプリの寿命に直結します。判断軸を整理します。
移行を検討すべきサイン
- Providerの数が10個を超え、ネストの管理が辛い
- 1つのContextに5つ以上のフィールドが乗り、再レンダリング制御が破綻している
- 非同期処理(API取得・楽観的更新)のコードがContext内に肥大化している
- 異なるブランチ間で状態を双方向に同期したいケースが頻出する
- テスト時のProviderラップが煩雑で、テストコードが読みづらい
段階的移行のすすめ
いきなり全Contextを置き換える必要はありません。サーバー状態をTanStack Queryへ、頻繁に変わるUI状態をZustandへと、影響範囲の小さい領域から切り出すのが安全です。「めったに変わらない設定値・ユーザー情報」はuseContextのまま残してOKです。
移行先選定フロー
| 状況 | 推奨される選択 |
|---|---|
| サーバーAPIのデータが中心 | TanStack Query / SWR |
| 小〜中規模・hooks重視 | Zustand |
| 細粒度の購読が必要 | Jotai |
| 大規模・履歴管理が重要 | Redux Toolkit |
| フォーム状態・ミューテーション中心 | Valtio / React Hook Form |
| めったに変わらない設定 | useContext維持 |
「Contextでよかった」典型例
- テーマ(ダーク/ライト)切替
- i18n(現在のロケール)
- 認証ユーザー情報(ログイン直後に1回セットして、その後ほぼ不変)
- 機能フラグ・実験グループ
- ルーター(React Routerは内部的にContextを多用)
useContextでハマりやすい落とし穴
最後に、レビューでよく指摘される失敗パターンをまとめます。
落とし穴1: Provider valueにオブジェクトリテラルを直書き
value={{ user, login }}のように毎レンダリングで新オブジェクトを作ると、全購読者が再レンダリングされます。useMemoでの参照固定が必須です。
落とし穴2: Context値をimportして直接使う
Contextオブジェクト自体を共有モジュールからimportせず、Providerコンポーネントだけをexportする設計が安全です。誤って別の場所でuseContext(別のContext)されると、デフォルト値が返ってバグになります。
落とし穴3: Provider未配置時のサイレント失敗
カスタムフック化でエラーを投げる設計にしておかないと、Providerをラップし忘れたときにdefaultValueが静かに返り、原因不明のバグになります。
function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
落とし穴4: SSR/Server Componentsでの誤用
Next.js App Routerなどでは、ContextはClient Componentでしか使えません。Server ComponentでuseContextを呼ぶとエラーになります。Provider側のファイルには必ず"use client"を記述します。
"use client";
import { createContext, useState } from "react";
const ThemeContext = createContext("light");
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
落とし穴5: depsを忘れたuseEffectとContextの組み合わせ
useContextで取得した値をuseEffectのdepsから漏らすと、古い値を参照し続けるstale closure問題が起きます。depsには必ずContextから取り出した値を含めます。
useContextに関するよくある質問(FAQ)
Q1: useContextとReduxはどちらを覚えるべき?
まずuseContextを確実に理解してください。Reduxはその上位概念であり、Contextの限界を体感した後に学ぶと「なぜReduxが必要なのか」が腹落ちします。逆順で学ぶと、Reduxの設計思想が過剰に見えてしまいます。
Q2: Context Providerは何個までネストしてOK?
明確な上限はありませんが、7〜8個を超えると可読性が落ちるのが体感値です。Composition Providerでまとめるか、TanStack Query・Zustandなどに一部を移譲して数を減らしましょう。
Q3: useContextは遅い?
useContext自体は非常に軽量です。遅さの正体は「value変化で全購読者が再描画される」仕様であり、useContextというAPIそのものではありません。Provider分割とuseMemoで多くの問題は解決します。
Q4: Server Componentsの時代にContextは不要になる?
いいえ。Server Componentsで扱えるのは静的・サーバー側のデータで、クライアントのインタラクション状態(モーダル・テーマ・カート)はClient ComponentsとuseContextの領域として残り続けます。役割分担が明確になっただけです。
Q5: useContext + useReducerはReduxの代わりになる?
小〜中規模ならYesです。ただしミドルウェア・タイムトラベルデバッグ・selector最適化・公式DevToolsが必要になった瞬間にRedux Toolkitに軍配が上がります。境界は「複数開発者で長期保守する大規模アプリかどうか」です。
Q6: Context.Consumer はもう使わない?
関数コンポーネントが標準の現在、useContextで十分です。Context.Consumerはクラスコンポーネント時代の名残で、新規コードでの使用は推奨されません。
Q7: Contextは何個まで作ってOK?
用途別に小さく分けるのが原則なので、10〜20個程度なら全く問題ありません。むしろ「1つの神Context」より、目的別に分割された多数の小Contextの方が再レンダリング制御もテストも容易です。
まとめ: useContextを正しく使い、必要なら卒業する
useContextは「Reactが標準で持つツリー横断の値配信機構」であり、状態管理ライブラリそのものではありません。useStateやuseReducerと組み合わせ、Provider分割・useMemoでの参照固定・カスタムフックでのラップという3点セットで、中小規模アプリなら十分に戦えます。一方で、頻繁な更新・細粒度の購読・サーバー状態の管理が必要になった時点で、TanStack QueryやZustand、必要ならRedux Toolkitに段階的に委譲しましょう。「全部Context」も「最初からRedux」もアンチパターンです。アプリの規模と更新頻度を見極めて、適切なツールを適切な範囲で使い分けるのが2026年のReact状態管理の正解です。本記事と合わせて、React Hooks 完全実践ガイドとuseMemo vs useCallback完全比較も読むと、Hooks全体の地図が完成します。

コメント