Props Drilling・Context・Zustand ── 「なんとなく使ってる」を卒業するための選び方
「Zustandがあれば Context はもう要らない」──コードレビューでも X でもよく見かける意見です。結論から言うと、半分正解で半分ミスリードだと思っています。
この記事では Props Drilling → Context → Zustand の順に、仕組み・使いどころ・落とし穴を整理し、「どれを選ぶか」の判断軸を示します。
目次
- 前提:なぜ状態管理が必要か
- Props Drilling ── まずは基本を理解する
- React Context ── 標準の解決策
- Zustand ── 軽量グローバルストア
- 3つを横断比較
- どれを選ぶべきか?判断フローチャート
- まとめ
1. 前提:なぜ状態管理が必要か
React のデータフローは 単方向(親 → 子) です。
親が持つ状態を孫コンポーネントに届けるには、何らかの手段が必要です。
App
└── Layout
└── Sidebar
└── UserAvatar ← ここで user が欲しい
この距離が問題の本質です。
2. Props Drilling ── まずは基本を理解する
仕組み
props を「バケツリレー」のように中間コンポーネント経由で渡す方法です。
// App.tsx
function App() {
const [user, setUser] = useState<User>({ name: "Taro", role: "admin" });
return <Layout user={user} />;
}
// Layout.tsx
function Layout({ user }: { user: User }) {
return <Sidebar user={user} />;
}
// Sidebar.tsx
function Sidebar({ user }: { user: User }) {
return <UserAvatar user={user} />;
}
// UserAvatar.tsx
function UserAvatar({ user }: { user: User }) {
return <img src={`/avatars/${user.name}.png`} alt={user.name} />;
}
Layout と Sidebar は user を使っていないのに受け取っています。
Props Drilling が向いている場面
- コンポーネントの階層が 2〜3層以内
- 状態の所有者と消費者が近い
- ライブラリ依存をゼロにしたい小規模コンポーネント
問題点
| 問題 | 説明 |
|---|---|
| 中間コンポーネントの汚染 | 使わない props を受け取り・渡す必要がある |
| リファクタリングコスト | props の名前や型を変えると全階層を修正 |
| 可読性の低下 | コンポーネントの「本来の責務」がぼやける |
Props Drilling は悪手ではありません。「辛くなってきたら移行する」 ──これが健全なアプローチです。
3. React Context ── 標準の解決策
仕組み
Context は Provider(提供)→ Consumer(消費) のパターンで、中間コンポーネントをスキップして値を届けます。
// UserContext.tsx
import { createContext, useContext, useState, ReactNode } from "react";
type User = { name: string; role: string };
type UserContextType = { user: User; setUser: (u: User) => void };
const UserContext = createContext<UserContextType | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>({ name: "Taro", role: "admin" });
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const ctx = useContext(UserContext);
if (!ctx) throw new Error("useUser must be used within UserProvider");
return ctx;
}
// UserAvatar.tsx ── 中間コンポーネントへの props は不要
function UserAvatar() {
const { user } = useUser();
return <img src={`/avatars/${user.name}.png`} alt={user.name} />;
}
Context が向いている場面
- テーマ(ダーク/ライト)、言語(i18n)、認証情報 など変化が少ない値
- コンポーネントライブラリ内部の設定値の受け渡し
- 追加ライブラリを使いたくないプロジェクト
Context の落とし穴:再レンダリング問題
Context の値が変わると、その Provider 配下の全 Consumer が再レンダリングされます。
// 頻繁に変わる値を一つの Context に詰め込む(よくある失敗)
const AppContext = createContext({
user, // 滅多に変わらない
cartItems, // 毎回変わる ← これが変わるたびに user を使うコンポーネントも再描画
theme,
});
解決策:Context を分割する
Provider を分けることで、cartItems が変化しても UserProvider 配下のコンポーネントは再レンダリングされなくなります。
// 更新頻度ごとに Context を分ける
<UserProvider> {/* 低頻度 */}
<ThemeProvider> {/* 低頻度 */}
<CartProvider> {/* 高頻度 */}
<App />
</CartProvider>
</ThemeProvider>
</UserProvider>
Context は「グローバル変数の React 版」と思うと理解しやすいです。ただし、頻繁に変わるデータ(カートの中身、フォーム入力値など)には向いていません。
useMemo / useCallback でコンテキスト値を安定させるか、use-context-selector を使ってセレクタベースの購読に切り替えると再レンダリングを最小化できます。
4. Zustand ── 軽量グローバルストア
仕組み
Zustand は React の外側にストアを持ち、コンポーネントはセレクタで必要な値だけ購読します。
npm install zustand
// store/userStore.ts
import { create } from "zustand";
type User = { name: string; role: string };
interface UserStore {
user: User;
setUser: (user: User) => void;
}
export const useUserStore = create<UserStore>((set) => ({
user: { name: "Taro", role: "admin" },
setUser: (user) => set({ user }),
}));
// UserAvatar.tsx
function UserAvatar() {
// name だけ購読 → role が変わっても再レンダリングしない
const name = useUserStore((state) => state.user.name);
return <img src={`/avatars/${name}.png`} alt={name} />;
}
Zustand が向いている場面
- 頻繁に更新される状態(カート、フィルター、通知)
- 複数の場所から読み書きされる 状態
- Redux は重すぎるが Context の再レンダリングが気になる場面
- React コンポーネント外(イベントハンドラ、WebSocket コールバック)から状態を更新したい
Zustand の強み
useUserStore.getState() を使えば、React の外からでもストアにアクセスできます。これは Context では実現できない Zustand ならではの強みです。
import { useUserStore } from "./store/userStore";
// WebSocket などの非 React コードから直接呼べる
function onAuthSuccess(user: User) {
useUserStore.getState().setUser(user);
}
devtools と persist ミドルウェアの組み合わせも簡単に書けます。
import { devtools, persist } from "zustand/middleware";
const useUserStore = create<UserStore>()(
devtools(
persist(
(set) => ({
user: { name: "", role: "" },
setUser: (user) => set({ user }),
}),
{ name: "user-storage" } // localStorage に永続化
)
)
);
Zustand の注意点
-
小規模なコンポーネントローカルな状態には過剰(普通に
useStateで十分) - ストアが増えすぎると管理が散漫になる → ドメインごとにファイルを分ける習慣が重要
5. 3つを横断比較
| 観点 | Props Drilling | Context | Zustand |
|---|---|---|---|
| 導入コスト | ゼロ | 低(React 標準) | 低(軽量ライブラリ) |
| ボイラープレート | 少 | 中 | 少〜中 |
| 再レンダリング制御 | 手動で制御しやすい | 全 Consumer が再描画 | セレクタで最小化 |
| React 外からの更新 | 不可 | 不可 | 可 |
| DevTools | なし | React DevTools のみ | Redux DevTools 互換 |
| 永続化(localStorage) | なし | 手動実装 | ミドルウェアで簡単 |
| テストしやすさ | しやすい | しやすい | しやすい(ストアを直接操作可) |
| 学習コスト | 最低 | 低 | 低〜中 |
| 向いているデータ | ローカル・浅い階層 | テーマ・認証・低頻度 | 高頻度・広範囲 |
6. どれを選ぶべきか?判断フローチャート
状態が必要になった
│
▼
この状態は「このコンポーネントだけ」で使う?
│
Yes → ロジックが複雑?
│
Yes → useReducer
│
No → useState
│
No
│
▼
渡す階層は 2〜3 層以内?
│
Yes → Props Drilling でも十分
│
No
│
▼
頻繁に更新される?(カート・通知・フォーム)
または React 外から更新が必要?
│
Yes → Zustand
│
No(テーマ・言語・認証など低頻度)
│
▼
Context
実際のプロジェクトでの使い分け例
// 認証情報 → Context(アプリ全体で使うが変化は少ない)
<AuthProvider>
{/* テーマ → Context */}
<ThemeProvider>
{/* カート・商品フィルター → Zustand(頻繁に変わる) */}
<App />
</ThemeProvider>
</AuthProvider>
Context と Zustand は競合ではなく補完の関係です。同じプロジェクトで両方使うのは普通のことです。
7. まとめ
| ツール | 一言で言うと |
|---|---|
| Props Drilling | 「近いなら props で十分」シンプルを大切に |
| Context | 「広く使う・変化が少ない」値の配送係 |
| Zustand | 「頻繁に変わる・広範囲」な状態のための軽量ストア |
React Context は不要か?
答えは No です。
テーマ・認証・ローカライズのように「広く使われるが変化が少ない値」には Context が最適です。一方で「頻繁に変わる状態」や「React 外からの更新」が必要な場面では Zustand が輝きます。
道具を正しく使い分けることが、スケールするフロントエンドへの近道です。