はじめに

前回の記事では、React Routerを使ったSPAのルーティングについて解説しました。本記事では、アプリケーション全体で状態を共有するための「グローバル状態管理」について詳しく解説します。

これまでの学習で、useStateによるローカル状態管理や「状態のリフトアップ」によるコンポーネント間のデータ共有を学びました。しかし、アプリケーションが大きくなると、深くネストしたコンポーネントへpropsを渡し続ける「prop drilling」という問題が発生します。この問題を解決するのがグローバル状態管理です。

本記事では、Reactに組み込まれているContext APIと、軽量で人気の高い状態管理ライブラリZustandを取り上げ、それぞれの特徴と使い分けを解説します。

本記事を読むことで、以下のことができるようになります。

  • prop drillingの問題を理解し、解決策を選択できる
  • Context APIによるグローバル状態管理の実装
  • Zustandの導入とストア設計
  • プロジェクト規模に応じた状態管理ライブラリの選択
  • 実践的なテーマ切り替え・認証状態管理の実装

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • 関数コンポーネントの基本
  • useState・useEffectの使い方
  • Propsによるデータの受け渡し
  • 状態のリフトアップの概念

prop drillingの問題

prop drillingとは

「prop drilling」とは、深くネストしたコンポーネントにデータを渡すために、中間のコンポーネントがそのデータを使用しないにもかかわらずpropsとして受け渡し続ける状況を指します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// prop drillingの例 - データを使わない中間コンポーネントもpropsを受け渡す
function App() {
  const [user, setUser] = useState({ name: "田中太郎", role: "admin" });
  
  return <Layout user={user} />;
}

function Layout({ user }) {
  return (
    <div>
      <Header user={user} />
      <Main user={user} />
    </div>
  );
}

function Header({ user }) {
  return (
    <header>
      <Navigation user={user} />
    </header>
  );
}

function Navigation({ user }) {
  return (
    <nav>
      <UserMenu user={user} />
    </nav>
  );
}

function UserMenu({ user }) {
  // 実際にuserデータを使用するのはここだけ
  return <span>ようこそ、{user.name}さん</span>;
}

prop drillingの問題点

上記の例では、userデータを必要としているのは最下層のUserMenuコンポーネントだけですが、LayoutHeaderNavigationという3つの中間コンポーネントがpropsを受け渡しています。

この構造には以下の問題があります。

  1. 保守性の低下: 中間コンポーネントがデータを使わないのにpropsを定義する必要がある
  2. リファクタリングの困難さ: propsの型や名前を変更する際、すべての中間コンポーネントを修正する必要がある
  3. 可読性の低下: どのコンポーネントが実際にデータを使用しているか把握しにくい
  4. 再利用性の低下: 中間コンポーネントが特定のpropsに依存し、他の場所で使いにくくなる

Context API - Reactの組み込みソリューション

Context APIとは

Context APIは、React 16.3で導入されたグローバル状態管理のための組み込み機能です。コンポーネントツリーのどこからでも、propsを経由せずに直接データにアクセスできます。

Context APIは以下の3つのステップで使用します。

  1. Contextの作成: createContextでContextオブジェクトを作成
  2. Providerで値を提供: コンポーネントツリーをProviderでラップし、値を設定
  3. useContextで値を取得: 任意のコンポーネントから値にアクセス

基本的な使い方

まず、テーマ切り替え機能を例にContext APIの基本を学びましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// src/contexts/ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

// 1. 型定義
type Theme = 'light' | 'dark';

type ThemeContextType = {
  theme: Theme;
  toggleTheme: () => void;
};

// 2. Contextの作成(デフォルト値はnull)
const ThemeContext = createContext<ThemeContextType | null>(null);

// 3. カスタムフック - Contextを安全に使用するため
export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

// 4. Providerコンポーネント
type ThemeProviderProps = {
  children: ReactNode;
};

export function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext>
  );
}

Providerでアプリをラップ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// src/App.tsx
import { ThemeProvider } from './contexts/ThemeContext';
import { Header } from './components/Header';
import { Main } from './components/Main';

function App() {
  return (
    <ThemeProvider>
      <div className="app">
        <Header />
        <Main />
      </div>
    </ThemeProvider>
  );
}

export default App;

任意のコンポーネントからContextを使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/components/Header.tsx
import { useTheme } from '../contexts/ThemeContext';

export function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header className={`header ${theme}`}>
      <h1>My App</h1>
      <button onClick={toggleTheme}>
        {theme === 'light' ? 'ダークモードへ' : 'ライトモードへ'}
      </button>
    </header>
  );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/components/Main.tsx
import { useTheme } from '../contexts/ThemeContext';

export function Main() {
  const { theme } = useTheme();

  return (
    <main className={`main ${theme}`}>
      <p>現在のテーマ: {theme}</p>
      <DeepNestedComponent />
    </main>
  );
}

// 深くネストしたコンポーネントからも直接アクセス可能
function DeepNestedComponent() {
  const { theme } = useTheme();
  
  return (
    <div className={`nested ${theme}`}>
      <p>このコンポーネントもテーマにアクセスできます</p>
    </div>
  );
}

実践例: 認証状態の管理

より実践的な例として、ユーザー認証状態を管理するContextを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// src/contexts/AuthContext.tsx
import { createContext, useContext, useState, ReactNode, useCallback } from 'react';

// 型定義
type User = {
  id: string;
  name: string;
  email: string;
  role: 'user' | 'admin';
};

type AuthContextType = {
  user: User | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
};

// Contextの作成
const AuthContext = createContext<AuthContextType | null>(null);

// カスタムフック
export function useAuth() {
  const context = useContext(AuthContext);
  if (context === null) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

// Providerコンポーネント
type AuthProviderProps = {
  children: ReactNode;
};

export function AuthProvider({ children }: AuthProviderProps) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const login = useCallback(async (email: string, password: string) => {
    setIsLoading(true);
    try {
      // 実際のアプリケーションではAPIを呼び出す
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      
      if (!response.ok) {
        throw new Error('ログインに失敗しました');
      }
      
      const userData = await response.json();
      setUser(userData);
    } finally {
      setIsLoading(false);
    }
  }, []);

  const logout = useCallback(() => {
    setUser(null);
  }, []);

  const value = {
    user,
    isAuthenticated: user !== null,
    login,
    logout,
    isLoading,
  };

  return (
    <AuthContext value={value}>
      {children}
    </AuthContext>
  );
}

複数のProviderを組み合わせる

複数のContextを使用する場合は、Providerをネストします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/App.tsx
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { AppContent } from './components/AppContent';

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <AppContent />
      </ThemeProvider>
    </AuthProvider>
  );
}

export default App;

Context APIのパフォーマンス最適化

Context APIには、値が変更されるとそのContextを使用しているすべてのコンポーネントが再レンダリングされるという特性があります。これを最適化するにはいくつかの方法があります。

Contextを分割する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 良くない例 - すべての値が1つのContextに
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  settings: {},
});

// 良い例 - 関連する値ごとにContextを分割
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);
const SettingsContext = createContext({});

useMemoで値をメモ化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>('light');

  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  }, []);

  // 値をメモ化して不要な再レンダリングを防ぐ
  const value = useMemo(() => ({
    theme,
    toggleTheme,
  }), [theme, toggleTheme]);

  return (
    <ThemeContext value={value}>
      {children}
    </ThemeContext>
  );
}

Context APIの適切なユースケース

Context APIは以下のような場面で効果的です。

  • テーマ切り替え: ライト/ダークモードの切り替え
  • 認証状態: ログイン中のユーザー情報
  • 言語設定: 国際化対応(i18n)
  • アプリ設定: ユーザーの設定項目
  • 更新頻度の低いデータ: 頻繁に変更されないグローバルな値

Zustand - シンプルで高速な状態管理

Zustandとは

Zustand(ドイツ語で「状態」)は、軽量でシンプルな状態管理ライブラリです。2025年12月現在、GitHubで56,000以上のスターを獲得し、多くのReactプロジェクトで採用されています。

Zustandの主な特徴は以下の通りです。

  • シンプルなAPI: ボイラープレートが少なく、学習コストが低い
  • Providerが不要: Context APIのようなProviderでラップする必要がない
  • 高いパフォーマンス: セレクターによる細かな再レンダリング制御
  • ミドルウェア対応: devtools、persist、immerなど豊富な拡張機能
  • TypeScript対応: 型安全な状態管理

Zustandのインストール

1
npm install zustand

基本的な使い方

Zustandでは、create関数を使ってストア(状態を管理するフック)を作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/stores/counterStore.ts
import { create } from 'zustand';

// 型定義
type CounterState = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

// ストアの作成
export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

コンポーネントからストアを使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/components/Counter.tsx
import { useCounterStore } from '../stores/counterStore';

export function Counter() {
  // 必要な状態だけを選択(セレクター)
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const reset = useCounterStore((state) => state.reset);

  return (
    <div>
      <h2>カウンター: {count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

セレクターによるパフォーマンス最適化

Zustandの大きな利点は、セレクターを使って必要な状態だけを購読できることです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 良い例 - 必要な値だけを購読
function DisplayCount() {
  // countが変更されたときだけ再レンダリング
  const count = useCounterStore((state) => state.count);
  return <p>Count: {count}</p>;
}

function IncrementButton() {
  // incrementは変更されないので再レンダリングされない
  const increment = useCounterStore((state) => state.increment);
  return <button onClick={increment}>+1</button>;
}

// 注意が必要な例 - すべての状態を購読
function Counter() {
  // ストア内のどの値が変更されても再レンダリング
  const state = useCounterStore();
  return <p>Count: {state.count}</p>;
}

実践例: Todoリストの状態管理

より実践的な例として、Todoリストを管理するストアを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// src/stores/todoStore.ts
import { create } from 'zustand';

// 型定義
type Todo = {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
};

type FilterType = 'all' | 'active' | 'completed';

type TodoState = {
  todos: Todo[];
  filter: FilterType;
  // Actions
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  deleteTodo: (id: string) => void;
  setFilter: (filter: FilterType) => void;
  clearCompleted: () => void;
};

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  filter: 'all',

  addTodo: (text) => set((state) => ({
    todos: [
      ...state.todos,
      {
        id: crypto.randomUUID(),
        text,
        completed: false,
        createdAt: new Date(),
      },
    ],
  })),

  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ),
  })),

  deleteTodo: (id) => set((state) => ({
    todos: state.todos.filter((todo) => todo.id !== id),
  })),

  setFilter: (filter) => set({ filter }),

  clearCompleted: () => set((state) => ({
    todos: state.todos.filter((todo) => !todo.completed),
  })),
}));

// 派生状態を計算するセレクター
export const selectFilteredTodos = (state: TodoState) => {
  switch (state.filter) {
    case 'active':
      return state.todos.filter((todo) => !todo.completed);
    case 'completed':
      return state.todos.filter((todo) => todo.completed);
    default:
      return state.todos;
  }
};

export const selectTodoStats = (state: TodoState) => ({
  total: state.todos.length,
  active: state.todos.filter((t) => !t.completed).length,
  completed: state.todos.filter((t) => t.completed).length,
});

Todoリストコンポーネントの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
// src/components/TodoApp.tsx
import { useState } from 'react';
import { useTodoStore, selectFilteredTodos, selectTodoStats } from '../stores/todoStore';

export function TodoApp() {
  return (
    <div className="todo-app">
      <h1>Todoリスト</h1>
      <TodoInput />
      <TodoFilter />
      <TodoList />
      <TodoStats />
    </div>
  );
}

function TodoInput() {
  const [text, setText] = useState('');
  const addTodo = useTodoStore((state) => state.addTodo);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (text.trim()) {
      addTodo(text.trim());
      setText('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="新しいTodoを入力..."
      />
      <button type="submit">追加</button>
    </form>
  );
}

function TodoFilter() {
  const filter = useTodoStore((state) => state.filter);
  const setFilter = useTodoStore((state) => state.setFilter);
  const clearCompleted = useTodoStore((state) => state.clearCompleted);

  return (
    <div className="todo-filter">
      <button 
        className={filter === 'all' ? 'active' : ''}
        onClick={() => setFilter('all')}
      >
        すべて
      </button>
      <button 
        className={filter === 'active' ? 'active' : ''}
        onClick={() => setFilter('active')}
      >
        未完了
      </button>
      <button 
        className={filter === 'completed' ? 'active' : ''}
        onClick={() => setFilter('completed')}
      >
        完了済み
      </button>
      <button onClick={clearCompleted}>完了を削除</button>
    </div>
  );
}

function TodoList() {
  const todos = useTodoStore(selectFilteredTodos);
  const toggleTodo = useTodoStore((state) => state.toggleTodo);
  const deleteTodo = useTodoStore((state) => state.deleteTodo);

  if (todos.length === 0) {
    return <p>Todoがありません</p>;
  }

  return (
    <ul className="todo-list">
      {todos.map((todo) => (
        <li key={todo.id} className={todo.completed ? 'completed' : ''}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          <span>{todo.text}</span>
          <button onClick={() => deleteTodo(todo.id)}>削除</button>
        </li>
      ))}
    </ul>
  );
}

function TodoStats() {
  const stats = useTodoStore(selectTodoStats);

  return (
    <div className="todo-stats">
      <span>合計: {stats.total}</span>
      <span>未完了: {stats.active}</span>
      <span>完了: {stats.completed}</span>
    </div>
  );
}

非同期処理の実装

Zustandでは、アクション内で非同期処理を自然に記述できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// src/stores/userStore.ts
import { create } from 'zustand';

type User = {
  id: string;
  name: string;
  email: string;
};

type UserState = {
  users: User[];
  isLoading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
  addUser: (user: Omit<User, 'id'>) => Promise<void>;
};

export const useUserStore = create<UserState>((set, get) => ({
  users: [],
  isLoading: false,
  error: null,

  fetchUsers: async () => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error('ユーザーの取得に失敗しました');
      const users = await response.json();
      set({ users, isLoading: false });
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : '不明なエラー',
        isLoading: false 
      });
    }
  },

  addUser: async (userData) => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });
      if (!response.ok) throw new Error('ユーザーの追加に失敗しました');
      const newUser = await response.json();
      // 現在の状態を取得して更新
      set((state) => ({ 
        users: [...state.users, newUser],
        isLoading: false 
      }));
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : '不明なエラー',
        isLoading: false 
      });
    }
  },
}));

ミドルウェアの活用

Zustandは豊富なミドルウェアを提供しています。

devtools - Redux DevToolsとの連携

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        undefined,
        'counter/increment' // アクション名
      ),
      decrement: () => set(
        (state) => ({ count: state.count - 1 }),
        undefined,
        'counter/decrement'
      ),
    }),
    { name: 'CounterStore' }
  )
);

persist - ローカルストレージへの永続化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

type SettingsState = {
  theme: 'light' | 'dark';
  language: string;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: string) => void;
};

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'ja',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'app-settings', // ストレージのキー名
      storage: createJSONStorage(() => localStorage),
    }
  )
);

immer - 不変性を意識しない状態更新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

type TodoState = {
  todos: { id: string; text: string; completed: boolean }[];
  toggleTodo: (id: string) => void;
};

export const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    toggleTodo: (id) => set((state) => {
      // immerにより直接的な変更が可能
      const todo = state.todos.find((t) => t.id === id);
      if (todo) {
        todo.completed = !todo.completed;
      }
    }),
  }))
);

複数のミドルウェアを組み合わせる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export const useTodoStore = create<TodoState>()(
  devtools(
    persist(
      immer((set) => ({
        // ... 状態とアクション
      })),
      {
        name: 'todo-storage',
        storage: createJSONStorage(() => localStorage),
      }
    ),
    { name: 'TodoStore' }
  )
);

Context API vs Zustand - 使い分けの指針

比較表

特徴 Context API Zustand
追加パッケージ 不要(React組み込み) 必要(約1KB gzip)
Providerの必要性 必要 不要
学習コスト 低い 低い
ボイラープレート やや多い 少ない
再レンダリング最適化 手動で対応が必要 セレクターで自動最適化
ミドルウェア なし 豊富(devtools, persist等)
テスト容易性 Providerのモックが必要 直接ストアをテスト可能
React外からのアクセス 不可 可能

Context APIを選ぶべきケース

以下のような状況では、Context APIが適しています。

  • 依存関係を増やしたくない場合: 追加パッケージなしで利用可能
  • 更新頻度が低いデータ: テーマ、言語設定、認証状態など
  • シンプルな状態管理: 小規模なアプリケーションや一部の機能
  • Reactエコシステムに閉じた開発: React外からの状態アクセスが不要
1
2
3
4
5
6
7
8
9
// Context APIが適している例
// テーマ設定(更新頻度: 低)
const ThemeContext = createContext<ThemeContextType | null>(null);

// 認証状態(更新頻度: 低〜中)
const AuthContext = createContext<AuthContextType | null>(null);

// アプリ設定(更新頻度: 低)
const SettingsContext = createContext<SettingsContextType | null>(null);

Zustandを選ぶべきケース

以下のような状況では、Zustandが適しています。

  • 頻繁に更新される状態: フォーム入力、リアルタイムデータなど
  • 複雑な状態ロジック: 多くのアクションや派生状態がある場合
  • パフォーマンスが重要: 大量のデータや頻繁な更新がある場合
  • デバッグ体験の向上: Redux DevToolsでの状態追跡が必要
  • 永続化が必要: ローカルストレージへの自動保存
  • React外からのアクセス: イベントハンドラやユーティリティ関数からの状態操作
1
2
3
4
5
6
7
8
9
// Zustandが適している例
// Todoリスト(更新頻度: 高、CRUD操作が多い)
export const useTodoStore = create<TodoState>((set) => ({...}));

// ショッピングカート(更新頻度: 中〜高、永続化が必要)
export const useCartStore = create(persist(...));

// フォーム状態(更新頻度: 高)
export const useFormStore = create<FormState>((set) => ({...}));

両方を組み合わせる

実際のアプリケーションでは、Context APIとZustandを適材適所で組み合わせることも有効です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// App.tsx
import { AuthProvider } from './contexts/AuthContext'; // Context API
import { ThemeProvider } from './contexts/ThemeContext'; // Context API
import { TodoApp } from './components/TodoApp'; // Zustandを使用

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <TodoApp />
      </ThemeProvider>
    </AuthProvider>
  );
}

他の状態管理ライブラリとの比較

Redux Toolkit

Reduxは歴史のある状態管理ライブラリで、Redux Toolkitによりボイラープレートが削減されています。

  • メリット: 大規模なエコシステム、豊富なミドルウェア、Redux DevTools
  • デメリット: Zustandより設定が複雑、学習コストがやや高い
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Redux Toolkit
import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
  },
});

// Zustand(より簡潔)
import { create } from 'zustand';

const useCounterStore = create((set) => ({
  value: 0,
  increment: () => set((s) => ({ value: s.value + 1 })),
  decrement: () => set((s) => ({ value: s.value - 1 })),
}));

Jotai

Jotaiは「アトム」という単位で状態を管理する、Zustandと同じpmndrsチームによるライブラリです。

  • メリット: 細かな粒度での状態管理、React Suspense対応
  • デメリット: アトム間の依存関係が複雑になりやすい
1
2
3
4
5
6
7
8
9
// Jotai
import { atom, useAtom } from 'jotai';

const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

選択の指針

ライブラリ 推奨ケース
Context API 小規模アプリ、更新頻度の低いデータ
Zustand 中〜大規模アプリ、シンプルさとパフォーマンスのバランス
Redux Toolkit 大規模エンタープライズアプリ、既存のRedux資産がある場合
Jotai 細かな状態分割が必要な場合、Suspense活用

まとめ

本記事では、Reactにおけるグローバル状態管理について、Context APIとZustandを中心に解説しました。

  • prop drillingはコンポーネント間のデータ受け渡しを複雑にする問題
  • Context APIはReact組み込みのソリューションで、更新頻度の低いデータに適している
  • Zustandは軽量でシンプルなAPI、高いパフォーマンス、豊富なミドルウェアが特徴
  • プロジェクトの規模や要件に応じて適切なツールを選択することが重要
  • 両者を組み合わせて使うことも有効なアプローチ

グローバル状態管理をマスターすることで、スケーラブルで保守しやすいReactアプリケーションを構築できるようになります。次の記事では、TypeScriptとReactを組み合わせた型安全なコンポーネント開発について解説します。

次に読む記事

参考リンク