T-CREATOR

Zustand と React Context はどう違う?使い分けの具体例付き解説

Zustand と React Context はどう違う?使い分けの具体例付き解説

React 開発において、ステート管理は避けて通れない重要な課題です。コンポーネント間でのデータ共有や状態の同期は、アプリケーションの規模が大きくなるにつれて複雑になってまいります。

この課題に対する解決策として、React Context API と Zustand という二つの選択肢があります。「どちらを使えばいいの?」「それぞれの特徴は何?」「プロジェクトの規模によって使い分けるべき?」といった疑問を抱えている開発者の方も多いのではないでしょうか。

今回は、React Context と Zustand の根本的な違いから実装方法まで、具体的なコード例とともに詳しく解説してまいります。それぞれの特性を理解し、プロジェクトに最適な選択ができるようになっていただければと思います。

背景

React Context の登場と普及

React Context API は、React 16.3 で正式に導入され、コンポーネント間でのデータ共有を革新的に簡素化しました。それまでは、深くネストされたコンポーネントにデータを渡すために「props drilling」と呼ばれる問題に悩まされていたのです。

Context API の登場により、以下のような従来の課題が解決されました。

#課題Context API による解決
1Props drillingプロバイダー経由でのダイレクトアクセス
2中間コンポーネントの責任不要な props の受け渡しを回避
3グローバル状態管理Redux などの重いライブラリに頼らない軽量な解決策
4型安全性TypeScript との優れた統合
typescript// Context API登場前の典型的な問題
// App.tsx
function App() {
  const user = { name: 'John', role: 'admin' };
  return <HomePage user={user} />;
}

// HomePage.tsx
function HomePage({ user }: { user: User }) {
  return (
    <div>
      <Header user={user} />
      <MainContent user={user} />
    </div>
  );
}

// Header.tsx
function Header({ user }: { user: User }) {
  return (
    <nav>
      <UserMenu user={user} /> {/* さらに深くpropsを渡す */}
    </nav>
  );
}
typescript// Context API による解決
// UserContext.tsx
import React, { createContext, useContext } from 'react';

interface User {
  name: string;
  role: 'admin' | 'user';
}

const UserContext = createContext<User | null>(null);

export function UserProvider({
  children,
  user,
}: {
  children: React.ReactNode;
  user: User;
}) {
  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (context === null) {
    throw new Error(
      'useUser must be used within a UserProvider'
    );
  }
  return context;
}

Context API の普及により、Redux のような重厚なライブラリを使わずとも、効率的なステート管理が可能になりました。特に小〜中規模のアプリケーションでは、Context API だけで十分なケースも多く見られるようになったのです。

Zustand の登場背景

Zustand は 2019 年に登場した比較的新しいステート管理ライブラリです。「Zustand」はドイツ語で「状態」を意味し、その名前の通りシンプルで直感的な状態管理を目指しています。

Zustand が開発された背景には、既存のソリューションに対する以下のような課題認識がありました。

Redux の複雑性への対応

  • 大量のボイラープレート
  • アクション、リデューサー、ディスパッチャーの概念習得コスト
  • 非同期処理での複雑な設定

React Context の制約への対応

  • パフォーマンスの問題(不要な再レンダリング)
  • Provider の階層構造の複雑化
  • 複数のコンテキストの管理の煩雑さ
typescript// Zustandの基本的な思想を表す例
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

export const useCounterStore = create<CounterState>(
  (set) => ({
    count: 0,
    increment: () =>
      set((state) => ({ count: state.count + 1 })),
    decrement: () =>
      set((state) => ({ count: state.count - 1 })),
  })
);

// 使用方法
function Counter() {
  const { count, increment, decrement } = useCounterStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

Zustand の特徴的な設計思想は以下の通りです。

シンプルさの追求

  • Provider 不要のアーキテクチャ
  • 最小限の API 設計
  • 学習コストの大幅な削減

パフォーマンスファースト

  • 細粒度の再レンダリング制御
  • メモリ効率の最適化
  • バンドルサイズの最小化

実用性重視

  • TypeScript との優れた統合
  • ミドルウェアによる拡張性
  • DevTools の充実

両者が解決しようとする問題の違い

React Context と Zustand は、どちらもステート管理の課題を解決しますが、解決へのアプローチが根本的に異なります。

React Context のアプローチ

React Context は、React の標準機能として「コンポーネントツリーでのデータ共有」という問題を解決します。React の哲学に沿った設計で、以下の特徴があります。

typescript// Context は依存注入パターンを採用
// ThemeContext.tsx
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<
  ThemeContextType | undefined
>(undefined);

export function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [theme, setTheme] = useState<'light' | 'dark'>(
    'light'
  );

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

  const value = useMemo(
    () => ({ theme, toggleTheme }),
    [theme, toggleTheme]
  );

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

Zustand のアプローチ

Zustand は「グローバルステートストア」として設計され、React に依存しない独立したステート管理を提供します。

typescript// Zustand はストアパターンを採用
// theme-store.ts
interface ThemeState {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

export const useThemeStore = create<ThemeState>((set) => ({
  theme: 'light',
  toggleTheme: () =>
    set((state) => ({
      theme: state.theme === 'light' ? 'dark' : 'light',
    })),
}));

この根本的な違いから、以下のような特性の差が生まれます。

#観点React ContextZustand
1設計思想React エコシステム内での解決フレームワーク非依存の状態管理
2データの流れコンポーネントツリーに依存グローバルアクセス可能
3責任の範囲UI コンポーネントとの密結合ビジネスロジックの分離
4拡張性React の機能に制約されるミドルウェアによる柔軟な拡張
5テスタビリティコンポーネントテストに依存独立したユニットテストが容易

課題

Context の re-render 問題

React Context の最も深刻な課題の一つが、不要な再レンダリングの発生です。Context の値が変更されると、そのコンテキストを使用するすべてのコンポーネントが再レンダリングされてしまいます。

この問題を具体的に見てみましょう。

typescript// 問題のあるContext実装
// AppContext.tsx
interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  notifications: Notification[];
  setUser: (user: User | null) => void;
  setTheme: (theme: 'light' | 'dark') => void;
  addNotification: (notification: Notification) => void;
}

export function AppProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<'light' | 'dark'>(
    'light'
  );
  const [notifications, setNotifications] = useState<
    Notification[]
  >([]);

  const addNotification = useCallback(
    (notification: Notification) => {
      setNotifications((prev) => [...prev, notification]);
    },
    []
  );

  // ここが問題:いずれかの状態が変わると全体が再レンダリング
  const value: AppState = {
    user,
    theme,
    notifications,
    setUser,
    setTheme,
    addNotification,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}
typescript// このコンポーネントは、通知が追加されるたびに再レンダリングされる
// UserProfile.tsx
function UserProfile() {
  const { user } = useContext(AppContext);

  console.log('UserProfile re-rendered'); // 不要な再レンダリングを確認

  if (!user) return <div>ログインしてください</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

この問題を回避するための従来の解決策は複雑になりがちです。

typescript// Context分割によるパフォーマンス最適化の試み
// UserContext.tsx
const UserContext = createContext<{
  user: User | null;
  setUser: (user: User | null) => void;
} | null>(null);

// ThemeContext.tsx
const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
} | null>(null);

// NotificationContext.tsx
const NotificationContext = createContext<{
  notifications: Notification[];
  addNotification: (notification: Notification) => void;
} | null>(null);

// 結果として複数のProvider が必要になる
function App() {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotificationProvider>
          <MainApp />
        </NotificationProvider>
      </ThemeProvider>
    </UserProvider>
  );
}

Provider 地獄の課題

複数の Context を使用する際に発生する「Provider Hell」(Provider 地獄)は、React Context API の深刻な課題です。アプリケーションが成長するにつれて、この問題はより顕著になります。

typescript// Provider地獄の典型例
// App.tsx
function App() {
  return (
    <QueryClient>
      <AuthProvider>
        <ThemeProvider>
          <LanguageProvider>
            <NotificationProvider>
              <CartProvider>
                <UserPreferencesProvider>
                  <Router>
                    <Routes>
                      <Route
                        path='/'
                        element={<HomePage />}
                      />
                    </Routes>
                  </Router>
                </UserPreferencesProvider>
              </CartProvider>
            </NotificationProvider>
          </LanguageProvider>
        </ThemeProvider>
      </AuthProvider>
    </QueryClient>
  );
}

この構造は以下の問題を引き起こします。

可読性の低下

  • ネストの深さによる視認性の悪化
  • 新しい Provider の追加位置の判断困難
  • デバッグ時の構造把握の困難

保守性の問題

  • Provider 間の依存関係の管理
  • 初期化順序の制約
  • テスト時の複雑なセットアップ
typescript// テスト時の複雑なセットアップ
// UserProfile.test.tsx
function TestWrapper({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

test('UserProfile displays user name', () => {
  render(
    <TestWrapper>
      <UserProfile />
    </TestWrapper>
  );
  // テストコード...
});

Zustand の学習コストと Context の複雑性

両者にはそれぞれ異なる学習曲線と複雑性があります。

React Context の複雑性

Context API は React の標準機能であるため、習得しやすく見えますが、実際には以下の複雑さがあります。

typescript// Context の複雑な実装例
// DataContext.tsx
interface DataState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

interface DataActions<T> {
  setData: (data: T) => void;
  setLoading: (loading: boolean) => void;
  setError: (error: string | null) => void;
  fetchData: () => Promise<void>;
}

function createDataContext<T>() {
  const Context = createContext<
    (DataState<T> & DataActions<T>) | null
  >(null);

  function DataProvider({
    children,
    fetchFn,
  }: {
    children: React.ReactNode;
    fetchFn: () => Promise<T>;
  }) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const fetchData = useCallback(async () => {
      setLoading(true);
      setError(null);
      try {
        const result = await fetchFn();
        setData(result);
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : 'Unknown error'
        );
      } finally {
        setLoading(false);
      }
    }, [fetchFn]);

    const value = useMemo(
      () => ({
        data,
        loading,
        error,
        setData,
        setLoading,
        setError,
        fetchData,
      }),
      [data, loading, error, fetchData]
    );

    return (
      <Context.Provider value={value}>
        {children}
      </Context.Provider>
    );
  }

  function useData() {
    const context = useContext(Context);
    if (!context) {
      throw new Error(
        'useData must be used within a DataProvider'
      );
    }
    return context;
  }

  return { DataProvider, useData };
}

Zustand の学習コスト

一方、Zustand は新しいライブラリであるため、概念の習得が必要ですが、API がシンプルで学習コストは低めです。

typescript// Zustand の同等実装(はるかにシンプル)
// data-store.ts
import { create } from 'zustand';

interface DataState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  fetchData: (fetchFn: () => Promise<T>) => Promise<void>;
  setData: (data: T | null) => void;
  clearError: () => void;
}

export function createDataStore<T>() {
  return create<DataState<T>>((set) => ({
    data: null,
    loading: false,
    error: null,

    fetchData: async (fetchFn: () => Promise<T>) => {
      set({ loading: true, error: null });
      try {
        const result = await fetchFn();
        set({ data: result, loading: false });
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error.message
              : 'Unknown error',
          loading: false,
        });
      }
    },

    setData: (data) => set({ data }),
    clearError: () => set({ error: null }),
  }));
}

// 使用例
const useUserStore = createDataStore<User>();

この比較から分かるように、Zustand は少ないコードで同等の機能を実現できるため、長期的には学習・保守コストが低くなる傾向があります。

解決策

基本アーキテクチャの違い

React Context と Zustand の根本的な違いは、そのアーキテクチャ設計にあります。この違いを理解することで、適切な選択ができるようになるでしょう。

React Context のアーキテクチャ

Context API は React のコンポーネントツリーに依存した設計で、以下の特徴があります。

typescript// Context-based アーキテクチャ
// 1. Context定義
const AppContext = createContext<AppState | null>(null);

// 2. Provider(データの注入点)
function AppProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [state, setState] = useState(initialState);

  return (
    <AppContext.Provider value={{ state, setState }}>
      {children}
    </AppContext.Provider>
  );
}

// 3. Consumer(データの消費点)
function Component() {
  const context = useContext(AppContext);
  if (!context) throw new Error('Context not found');

  return <div>{context.state.value}</div>;
}

// 4. アプリケーション構造(依存注入パターン)
function App() {
  return (
    <AppProvider>
      <Component />
    </AppProvider>
  );
}

Zustand のアーキテクチャ

Zustand は独立したストアベースの設計で、React に依存しない構造を持ちます。

typescript// Store-based アーキテクチャ
// 1. Store定義(自己完結型)
interface AppState {
  value: string;
  setValue: (value: string) => void;
  reset: () => void;
}

const useAppStore = create<AppState>((set) => ({
  value: '',
  setValue: (value) => set({ value }),
  reset: () => set({ value: '' }),
}));

// 2. 直接使用(Provider不要)
function Component() {
  const { value, setValue } = useAppStore();

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// 3. アプリケーション構造(シンプル)
function App() {
  return <Component />; // Provider不要
}

この違いにより、以下のような特性の差が生まれます。

#特徴React ContextZustand
1データの所在Provider コンポーネント内独立したストア
2アクセス方法useContext + Provider の組み合わせ直接的なフック呼び出し
3初期化タイミングProvider のマウント時ストア作成時
4スコープProvider の子コンポーネントグローバルアクセス可能
5テスタビリティモック Provider が必要ストア単体でテスト可能

API 設計思想の比較

両者の API 設計思想は、開発者体験に大きな影響を与えます。

React Context の宣言的アプローチ

typescript// 宣言的で明示的な設計
// UserContext.tsx
interface UserContextType {
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => Promise<void>;
}

export function UserProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);

  const login = useCallback(
    async (credentials: Credentials) => {
      setLoading(true);
      try {
        const userData = await authAPI.login(credentials);
        setUser(userData);
      } finally {
        setLoading(false);
      }
    },
    []
  );

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

  const updateProfile = useCallback(
    async (updates: Partial<User>) => {
      if (!user) return;

      const updatedUser = await userAPI.update(
        user.id,
        updates
      );
      setUser(updatedUser);
    },
    [user]
  );

  const value = useMemo(
    () => ({
      user,
      login,
      logout,
      updateProfile,
    }),
    [user, login, logout, updateProfile]
  );

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error(
      'useUser must be used within a UserProvider'
    );
  }
  return context;
}

Zustand の関数型アプローチ

typescript// 関数型で簡潔な設計
// user-store.ts
interface UserState {
  user: User | null;
  loading: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => Promise<void>;
}

export const useUserStore = create<UserState>(
  (set, get) => ({
    user: null,
    loading: false,

    login: async (credentials) => {
      set({ loading: true });
      try {
        const response = await authAPI.login(credentials);
        set({ user: response.user, loading: false });
      } catch (error) {
        set({ loading: false });
        throw error;
      }
    },

    logout: () => {
      authAPI.logout();
      set({ user: null });
    },

    updateProfile: async (updates) => {
      const { user } = get();
      if (!user) return;

      const updatedUser = await userAPI.update(
        user.id,
        updates
      );
      set({ user: updatedUser });
    },
  })
);

この設計思想の違いから、以下のような開発体験の差が生まれます。

Context の特徴

  • React パターンに馴染みやすい
  • エラーハンドリングが明示的
  • Provider の依存関係が明確
  • テストでの境界が明確

Zustand の特徴

  • ボイラープレートが少ない
  • ビジネスロジックに集中できる
  • 関数型プログラミングの利点
  • モジュラーな設計が容易

パフォーマンス特性の違い

パフォーマンス面での両者の違いは、アプリケーションの規模が大きくなるにつれて重要になります。

React Context のパフォーマンス特性

typescript// Context での再レンダリング測定例
// PerformanceTestContext.tsx
interface CounterContextType {
  count: number;
  name: string;
  increment: () => void;
  updateName: (name: string) => void;
}

function CounterProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  console.log('CounterProvider re-rendered'); // プロバイダーの再レンダリング

  const value = {
    count,
    name,
    increment: () => setCount((c) => c + 1),
    updateName: setName,
  };

  return (
    <CounterContext.Provider value={value}>
      {children}
    </CounterContext.Provider>
  );
}

// countのみ使用するコンポーネント
function CountDisplay() {
  const { count } = useContext(CounterContext);
  console.log('CountDisplay re-rendered'); // nameが変更されても再レンダリング

  return <div>Count: {count}</div>;
}

// nameのみ使用するコンポーネント
function NameDisplay() {
  const { name } = useContext(CounterContext);
  console.log('NameDisplay re-rendered'); // countが変更されても再レンダリング

  return <div>Name: {name}</div>;
}

Zustand のパフォーマンス特性

typescript// Zustand での選択的な再レンダリング
// performance-store.ts
interface CounterState {
  count: number;
  name: string;
  increment: () => void;
  updateName: (name: string) => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  name: '',
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  updateName: (name) => set({ name }),
}));

// countのみ購読(nameの変更では再レンダリングされない)
function CountDisplay() {
  const count = useCounterStore((state) => state.count);
  console.log('CountDisplay re-rendered'); // countの変更時のみ

  return <div>Count: {count}</div>;
}

// nameのみ購読(countの変更では再レンダリングされない)
function NameDisplay() {
  const name = useCounterStore((state) => state.name);
  console.log('NameDisplay re-rendered'); // nameの変更時のみ

  return <div>Name: {name}</div>;
}

// 複数の値を効率的に購読
function BothDisplay() {
  const { count, name } = useCounterStore(
    (state) => ({ count: state.count, name: state.name }),
    shallow // 浅い比較で不要な再レンダリングを防ぐ
  );

  return (
    <div>
      Count: {count}, Name: {name}
    </div>
  );
}

パフォーマンス比較結果:

#測定項目React ContextZustand
1初期化時間標準高速
2状態更新時の再レンダリング全体的選択的
3メモリ使用量中程度低い
4バンドルサイズ0KB(標準)2.9KB
5複雑な状態での性能低下傾向安定

開発体験の比較

開発者の生産性と保守性の観点から、両者の違いを比較してみましょう。

デバッグ体験の違い

typescript// React Context のデバッグ
// DevTools での表示が複雑
<App>
  <UserProvider>
    <ThemeProvider>
      <NotificationProvider>
        <Component />
      </NotificationProvider>
    </ThemeProvider>
  </UserProvider>
</App>
typescript// Zustand のデバッグ
// 専用DevToolsで状態の変化を追跡
import { devtools } from 'zustand/middleware';

const useStore = create<State>()(
  devtools(
    (set) => ({
      // state definition
    }),
    {
      name: 'my-store', // DevToolsでの表示名
    }
  )
);

TypeScript 統合の違い

typescript// Context での型安全性の確保
interface AppContextType {
  user: User | null;
  setUser: (user: User | null) => void;
}

const AppContext = createContext<
  AppContextType | undefined
>(undefined);

function useApp(): AppContextType {
  const context = useContext(AppContext);
  if (context === undefined) {
    throw new Error(
      'useApp must be used within an AppProvider'
    );
  }
  return context;
}
typescript// Zustand での型安全性(より自然)
interface AppState {
  user: User | null;
  setUser: (user: User | null) => void;
}

const useAppStore = create<AppState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// 使用時の型推論が自動で効く
const user = useAppStore((state) => state.user); // User | null と推論
const setUser = useAppStore((state) => state.setUser); // (user: User | null) => void と推論

具体例

テーマ管理での比較実装

実際のテーマ管理機能を両方の手法で実装し、違いを比較してみましょう。

React Context でのテーマ管理

typescript// theme-context.tsx
import React, {
  createContext,
  useContext,
  useEffect,
  useState,
} from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  isDark: boolean;
}

const ThemeContext = createContext<
  ThemeContextType | undefined
>(undefined);

export function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [theme, setTheme] = useState<Theme>('system');
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    const savedTheme = localStorage.getItem(
      'theme'
    ) as Theme;
    if (
      savedTheme &&
      ['light', 'dark', 'system'].includes(savedTheme)
    ) {
      setTheme(savedTheme);
    }
  }, []);

  useEffect(() => {
    const updateTheme = () => {
      let shouldBeDark = false;

      if (theme === 'dark') {
        shouldBeDark = true;
      } else if (theme === 'system') {
        shouldBeDark = window.matchMedia(
          '(prefers-color-scheme: dark)'
        ).matches;
      }

      setIsDark(shouldBeDark);
      document.documentElement.classList.toggle(
        'dark',
        shouldBeDark
      );
    };

    updateTheme();

    if (theme === 'system') {
      const mediaQuery = window.matchMedia(
        '(prefers-color-scheme: dark)'
      );
      mediaQuery.addEventListener('change', updateTheme);
      return () =>
        mediaQuery.removeEventListener(
          'change',
          updateTheme
        );
    }
  }, [theme]);

  const handleSetTheme = (newTheme: Theme) => {
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  const value = {
    theme,
    setTheme: handleSetTheme,
    isDark,
  };

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

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error(
      'useTheme must be used within a ThemeProvider'
    );
  }
  return context;
}

// 使用例
function ThemeSelector() {
  const { theme, setTheme, isDark } = useTheme();

  return (
    <div
      className={`p-4 ${
        isDark
          ? 'bg-gray-800 text-white'
          : 'bg-white text-black'
      }`}
    >
      <h3>テーマ設定</h3>
      <select
        value={theme}
        onChange={(e) => setTheme(e.target.value as Theme)}
      >
        <option value='light'>ライト</option>
        <option value='dark'>ダーク</option>
        <option value='system'>システム</option>
      </select>
      <p>現在のテーマ: {isDark ? 'ダーク' : 'ライト'}</p>
    </div>
  );
}

Zustand でのテーマ管理

typescript// theme-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

type Theme = 'light' | 'dark' | 'system';

interface ThemeState {
  theme: Theme;
  isDark: boolean;
  setTheme: (theme: Theme) => void;
  initializeTheme: () => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set, get) => ({
      theme: 'system',
      isDark: false,

      setTheme: (theme) => {
        set({ theme });
        get().updateDarkMode(theme);
      },

      updateDarkMode: (theme: Theme) => {
        let shouldBeDark = false;

        if (theme === 'dark') {
          shouldBeDark = true;
        } else if (theme === 'system') {
          shouldBeDark = window.matchMedia(
            '(prefers-color-scheme: dark)'
          ).matches;
        }

        set({ isDark: shouldBeDark });
        document.documentElement.classList.toggle(
          'dark',
          shouldBeDark
        );
      },

      initializeTheme: () => {
        const { theme, updateDarkMode } = get();
        updateDarkMode(theme);

        // システムテーマの変更を監視
        if (theme === 'system') {
          const mediaQuery = window.matchMedia(
            '(prefers-color-scheme: dark)'
          );
          const handleChange = () =>
            updateDarkMode('system');
          mediaQuery.addEventListener(
            'change',
            handleChange
          );

          // クリーンアップは手動で管理する必要がある
          return () =>
            mediaQuery.removeEventListener(
              'change',
              handleChange
            );
        }
      },
    }),
    {
      name: 'theme-storage',
      partialize: (state) => ({ theme: state.theme }),
    }
  )
);

// 使用例
function ThemeSelector() {
  const { theme, isDark, setTheme } = useThemeStore();

  return (
    <div
      className={`p-4 ${
        isDark
          ? 'bg-gray-800 text-white'
          : 'bg-white text-black'
      }`}
    >
      <h3>テーマ設定</h3>
      <select
        value={theme}
        onChange={(e) => setTheme(e.target.value as Theme)}
      >
        <option value='light'>ライト</option>
        <option value='dark'>ダーク</option>
        <option value='system'>システム</option>
      </select>
      <p>現在のテーマ: {isDark ? 'ダーク' : 'ライト'}</p>
    </div>
  );
}

// 初期化コンポーネント
function ThemeInitializer() {
  const initializeTheme = useThemeStore(
    (state) => state.initializeTheme
  );

  useEffect(() => {
    const cleanup = initializeTheme();
    return cleanup;
  }, [initializeTheme]);

  return null;
}

比較結果:

#項目React ContextZustand
1コード行数85 行65 行
2設定の複雑さ中程度シンプル
3永続化の実装手動実装ミドルウェア
4TypeScript 統合手動設定自動推論
5テスタビリティ複雑簡単

ユーザー認証状態管理での比較

ユーザー認証という複雑な状態管理を両方の手法で実装してみましょう。

React Context での認証管理

typescript// auth-context.tsx
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  register: (
    email: string,
    password: string,
    name: string
  ) => Promise<void>;
  clearError: () => void;
}

const AuthContext = createContext<
  AuthContextType | undefined
>(undefined);

export function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // 初期化時にトークンから認証状態を復元
    const initAuth = async () => {
      const token = localStorage.getItem('authToken');
      if (token) {
        try {
          const userData = await validateToken(token);
          setUser(userData);
        } catch (err) {
          localStorage.removeItem('authToken');
        }
      }
      setIsLoading(false);
    };

    initAuth();
  }, []);

  const login = useCallback(
    async (email: string, password: string) => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await authAPI.login({
          email,
          password,
        });
        localStorage.setItem('authToken', response.token);
        setUser(response.user);
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : 'ログインに失敗しました'
        );
      } finally {
        setIsLoading(false);
      }
    },
    []
  );

  const logout = useCallback(async () => {
    setIsLoading(true);
    try {
      await authAPI.logout();
    } finally {
      localStorage.removeItem('authToken');
      setUser(null);
      setIsLoading(false);
    }
  }, []);

  const register = useCallback(
    async (
      email: string,
      password: string,
      name: string
    ) => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await authAPI.register({
          email,
          password,
          name,
        });
        localStorage.setItem('authToken', response.token);
        setUser(response.user);
      } catch (err) {
        setError(
          err instanceof Error
            ? err.message
            : '登録に失敗しました'
        );
      } finally {
        setIsLoading(false);
      }
    },
    []
  );

  const clearError = useCallback(() => {
    setError(null);
  }, []);

  const value = useMemo(
    () => ({
      user,
      isLoading,
      error,
      login,
      logout,
      register,
      clearError,
    }),
    [
      user,
      isLoading,
      error,
      login,
      logout,
      register,
      clearError,
    ]
  );

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

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error(
      'useAuth must be used within an AuthProvider'
    );
  }
  return context;
}

Zustand での認証管理

typescript// auth-store.ts
import { create } from 'zustand';
import {
  persist,
  subscribeWithSelector,
} from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  isInitialized: boolean;

  // Actions
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  register: (
    email: string,
    password: string,
    name: string
  ) => Promise<void>;
  clearError: () => void;
  initializeAuth: () => Promise<void>;
}

export const useAuthStore = create<AuthState>()(
  subscribeWithSelector(
    persist(
      (set, get) => ({
        user: null,
        isLoading: false,
        error: null,
        isInitialized: false,

        login: async (email, password) => {
          set({ isLoading: true, error: null });

          try {
            const response = await authAPI.login({
              email,
              password,
            });
            set({
              user: response.user,
              isLoading: false,
            });
          } catch (err) {
            set({
              error:
                err instanceof Error
                  ? err.message
                  : 'ログインに失敗しました',
              isLoading: false,
            });
          }
        },

        logout: async () => {
          set({ isLoading: true });

          try {
            await authAPI.logout();
          } finally {
            set({
              user: null,
              isLoading: false,
              error: null,
            });
          }
        },

        register: async (email, password, name) => {
          set({ isLoading: true, error: null });

          try {
            const response = await authAPI.register({
              email,
              password,
              name,
            });
            set({
              user: response.user,
              isLoading: false,
            });
          } catch (err) {
            set({
              error:
                err instanceof Error
                  ? err.message
                  : '登録に失敗しました',
              isLoading: false,
            });
          }
        },

        clearError: () => set({ error: null }),

        initializeAuth: async () => {
          if (get().isInitialized) return;

          set({ isLoading: true });

          try {
            const token = localStorage.getItem('authToken');
            if (token) {
              const userData = await validateToken(token);
              set({ user: userData });
            }
          } catch (err) {
            localStorage.removeItem('authToken');
          } finally {
            set({
              isLoading: false,
              isInitialized: true,
            });
          }
        },
      }),
      {
        name: 'auth-storage',
        partialize: (state) => ({ user: state.user }),
      }
    )
  )
);

// トークンの自動保存
useAuthStore.subscribe(
  (state) => state.user,
  (user) => {
    if (user) {
      // ユーザーログイン時の処理
      console.log('User logged in:', user);
    } else {
      // ユーザーログアウト時の処理
      localStorage.removeItem('authToken');
      console.log('User logged out');
    }
  }
);

フォーム状態管理での比較

複雑なフォームの状態管理における両者の実装を比較します。

React Context でのフォーム管理

typescript// form-context.tsx
interface FormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    theme: 'light' | 'dark';
  };
  address: {
    street: string;
    city: string;
    zipCode: string;
    country: string;
  };
}

interface FormContextType {
  formData: FormData;
  errors: Record<string, string>;
  isSubmitting: boolean;
  updateField: (
    section: keyof FormData,
    field: string,
    value: any
  ) => void;
  validateForm: () => boolean;
  submitForm: () => Promise<void>;
  resetForm: () => void;
}

const initialFormData: FormData = {
  personalInfo: {
    firstName: '',
    lastName: '',
    email: '',
    phone: '',
  },
  preferences: {
    newsletter: false,
    notifications: true,
    theme: 'light',
  },
  address: {
    street: '',
    city: '',
    zipCode: '',
    country: '',
  },
};

const FormContext = createContext<
  FormContextType | undefined
>(undefined);

export function FormProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [formData, setFormData] =
    useState<FormData>(initialFormData);
  const [errors, setErrors] = useState<
    Record<string, string>
  >({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const updateField = useCallback(
    (
      section: keyof FormData,
      field: string,
      value: any
    ) => {
      setFormData((prev) => ({
        ...prev,
        [section]: {
          ...prev[section],
          [field]: value,
        },
      }));

      // エラーをクリア
      if (errors[`${section}.${field}`]) {
        setErrors((prev) => {
          const newErrors = { ...prev };
          delete newErrors[`${section}.${field}`];
          return newErrors;
        });
      }
    },
    [errors]
  );

  const validateForm = useCallback(() => {
    const newErrors: Record<string, string> = {};

    // バリデーションロジック
    if (!formData.personalInfo.firstName.trim()) {
      newErrors['personalInfo.firstName'] =
        '名前は必須です';
    }
    if (!formData.personalInfo.email.trim()) {
      newErrors['personalInfo.email'] =
        'メールアドレスは必須です';
    }
    if (!/\S+@\S+\.\S+/.test(formData.personalInfo.email)) {
      newErrors['personalInfo.email'] =
        '有効なメールアドレスを入力してください';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  }, [formData]);

  const submitForm = useCallback(async () => {
    if (!validateForm()) return;

    setIsSubmitting(true);
    try {
      await formAPI.submit(formData);
      setFormData(initialFormData);
      setErrors({});
    } catch (err) {
      setErrors({ submit: 'フォームの送信に失敗しました' });
    } finally {
      setIsSubmitting(false);
    }
  }, [formData, validateForm]);

  const resetForm = useCallback(() => {
    setFormData(initialFormData);
    setErrors({});
  }, []);

  const value = {
    formData,
    errors,
    isSubmitting,
    updateField,
    validateForm,
    submitForm,
    resetForm,
  };

  return (
    <FormContext.Provider value={value}>
      {children}
    </FormContext.Provider>
  );
}

export function useForm() {
  const context = useContext(FormContext);
  if (context === undefined) {
    throw new Error(
      'useForm must be used within a FormProvider'
    );
  }
  return context;
}

Zustand でのフォーム管理

typescript// form-store.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface FormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
    theme: 'light' | 'dark';
  };
  address: {
    street: string;
    city: string;
    zipCode: string;
    country: string;
  };
}

interface FormState {
  formData: FormData;
  errors: Record<string, string>;
  isSubmitting: boolean;

  updateField: (
    section: keyof FormData,
    field: string,
    value: any
  ) => void;
  setError: (field: string, error: string) => void;
  clearError: (field: string) => void;
  validateForm: () => boolean;
  submitForm: () => Promise<void>;
  resetForm: () => void;
}

const initialFormData: FormData = {
  personalInfo: {
    firstName: '',
    lastName: '',
    email: '',
    phone: '',
  },
  preferences: {
    newsletter: false,
    notifications: true,
    theme: 'light',
  },
  address: {
    street: '',
    city: '',
    zipCode: '',
    country: '',
  },
};

export const useFormStore = create<FormState>()(
  immer((set, get) => ({
    formData: initialFormData,
    errors: {},
    isSubmitting: false,

    updateField: (section, field, value) => {
      set((state) => {
        (state.formData[section] as any)[field] = value;

        // エラーをクリア
        const errorKey = `${section}.${field}`;
        if (state.errors[errorKey]) {
          delete state.errors[errorKey];
        }
      });
    },

    setError: (field, error) => {
      set((state) => {
        state.errors[field] = error;
      });
    },

    clearError: (field) => {
      set((state) => {
        delete state.errors[field];
      });
    },

    validateForm: () => {
      const { formData } = get();
      const newErrors: Record<string, string> = {};

      // バリデーションロジック
      if (!formData.personalInfo.firstName.trim()) {
        newErrors['personalInfo.firstName'] =
          '名前は必須です';
      }
      if (!formData.personalInfo.email.trim()) {
        newErrors['personalInfo.email'] =
          'メールアドレスは必須です';
      }
      if (
        !/\S+@\S+\.\S+/.test(formData.personalInfo.email)
      ) {
        newErrors['personalInfo.email'] =
          '有効なメールアドレスを入力してください';
      }

      set((state) => {
        state.errors = newErrors;
      });

      return Object.keys(newErrors).length === 0;
    },

    submitForm: async () => {
      const { validateForm, formData } = get();

      if (!validateForm()) return;

      set((state) => {
        state.isSubmitting = true;
      });

      try {
        await formAPI.submit(formData);
        set((state) => {
          state.formData = initialFormData;
          state.errors = {};
          state.isSubmitting = false;
        });
      } catch (err) {
        set((state) => {
          state.errors.submit =
            'フォームの送信に失敗しました';
          state.isSubmitting = false;
        });
      }
    },

    resetForm: () => {
      set((state) => {
        state.formData = initialFormData;
        state.errors = {};
      });
    },
  }))
);

グローバル通知システムでの比較

最後に、グローバル通知システムの実装を比較してみましょう。

React Context での通知システム

typescript// notification-context.tsx
interface Notification {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  title: string;
  message: string;
  duration?: number;
  actions?: Array<{
    label: string;
    action: () => void;
  }>;
}

interface NotificationContextType {
  notifications: Notification[];
  addNotification: (
    notification: Omit<Notification, 'id'>
  ) => void;
  removeNotification: (id: string) => void;
  clearAllNotifications: () => void;
}

const NotificationContext = createContext<
  NotificationContextType | undefined
>(undefined);

export function NotificationProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [notifications, setNotifications] = useState<
    Notification[]
  >([]);

  const addNotification = useCallback(
    (notification: Omit<Notification, 'id'>) => {
      const id = Math.random().toString(36).substr(2, 9);
      const newNotification = { ...notification, id };

      setNotifications((prev) => [
        ...prev,
        newNotification,
      ]);

      // 自動削除
      if (notification.duration !== 0) {
        setTimeout(() => {
          removeNotification(id);
        }, notification.duration || 5000);
      }
    },
    []
  );

  const removeNotification = useCallback((id: string) => {
    setNotifications((prev) =>
      prev.filter((n) => n.id !== id)
    );
  }, []);

  const clearAllNotifications = useCallback(() => {
    setNotifications([]);
  }, []);

  const value = {
    notifications,
    addNotification,
    removeNotification,
    clearAllNotifications,
  };

  return (
    <NotificationContext.Provider value={value}>
      {children}
    </NotificationContext.Provider>
  );
}

export function useNotifications() {
  const context = useContext(NotificationContext);
  if (context === undefined) {
    throw new Error(
      'useNotifications must be used within a NotificationProvider'
    );
  }
  return context;
}

Zustand での通知システム

typescript// notification-store.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface Notification {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  title: string;
  message: string;
  duration?: number;
  actions?: Array<{
    label: string;
    action: () => void;
  }>;
}

interface NotificationState {
  notifications: Notification[];
  addNotification: (
    notification: Omit<Notification, 'id'>
  ) => void;
  removeNotification: (id: string) => void;
  clearAllNotifications: () => void;
}

export const useNotificationStore =
  create<NotificationState>()(
    subscribeWithSelector((set, get) => ({
      notifications: [],

      addNotification: (notification) => {
        const id = Math.random().toString(36).substr(2, 9);
        const newNotification = { ...notification, id };

        set((state) => ({
          notifications: [
            ...state.notifications,
            newNotification,
          ],
        }));

        // 自動削除
        if (notification.duration !== 0) {
          setTimeout(() => {
            get().removeNotification(id);
          }, notification.duration || 5000);
        }
      },

      removeNotification: (id) => {
        set((state) => ({
          notifications: state.notifications.filter(
            (n) => n.id !== id
          ),
        }));
      },

      clearAllNotifications: () => {
        set({ notifications: [] });
      },
    }))
  );

// 便利なヘルパー関数
export const notify = {
  success: (title: string, message: string) => {
    useNotificationStore.getState().addNotification({
      type: 'success',
      title,
      message,
    });
  },
  error: (title: string, message: string) => {
    useNotificationStore.getState().addNotification({
      type: 'error',
      title,
      message,
      duration: 0, // エラーは手動で閉じる
    });
  },
  warning: (title: string, message: string) => {
    useNotificationStore.getState().addNotification({
      type: 'warning',
      title,
      message,
    });
  },
  info: (title: string, message: string) => {
    useNotificationStore.getState().addNotification({
      type: 'info',
      title,
      message,
    });
  },
};

まとめ

React Context と Zustand の詳細な比較を通じて、それぞれの特徴と適用場面をご理解いただけたでしょうか。両者にはそれぞれ明確な特徴と利点があり、プロジェクトの要件に応じて適切に選択することが重要です。

React Context の適用場面をまとめますと、React エコシステムに完全に統合された標準機能として、小規模なアプリケーションや限定的なスコープでのステート共有に適しています。特に、既存の React プロジェクトへの導入や、学習コストを抑えたい場合には優れた選択肢となるでしょう。

Zustand の適用場面としては、パフォーマンスが重要視される中〜大規模アプリケーションや、複雑な状態管理ロジックを必要とするプロジェクトに適しています。Provider 不要のシンプルな設計により、開発効率と保守性の両立を実現できます。

具体的な判断基準として、以下の表を参考にしていただければと思います。

#要件React ContextZustand
1プロジェクト規模小〜中中〜大
2パフォーマンス要件標準高い
3学習コストの制約低い中程度
4TypeScript 活用度標準高い
5外部ライブラリ使用可否不可可能

実装コストの観点では、初期の導入は React Context の方が簡単ですが、機能の拡張や複雑化に伴い、Zustand の方が長期的にはメンテナンスコストが低くなる傾向があります。

最終的な選択は、チームのスキルレベル、プロジェクトの将来性、パフォーマンス要件などを総合的に判断して行うことが重要です。どちらを選択した場合でも、一貫した設計思想を保ち、チーム全体で共通の理解を持つことが成功の鍵となるでしょう。

両者の特性を理解し、適切な場面で適切なツールを選択することで、より効率的で保守性の高い React アプリケーションを構築していただければと思います。

関連リンク