React Reduxは必要?状態管理の選択肢を初心者向けに解説

React開発における状態管理ライブラリの選択方法を初心者向けに解説。Redux、Zustand、React Queryなど各ライブラリの特徴と使い分けを詳しく説明します。

Learning Next 運営
63 分で読めます

React Reduxは必要?状態管理の選択肢を初心者向けに解説

みなさん、React開発で「状態管理はどうすればいいの?」と悩んだことはありませんか?

「Reduxを使うべきか、それとも他の選択肢があるのか?」と迷ったことはありませんか?

React開発において状態管理は重要なトピックです。 小さなアプリケーションではuseStateで十分ですが、アプリケーションが大きくなると複雑な状態管理が必要になります。

この記事では、React開発における状態管理ライブラリの選択方法を初心者向けに詳しく解説します。 Redux、Zustand、React Queryなど各ライブラリの特徴と使い分けを具体例とともに学んでいきましょう。

状態管理の基本概念

なぜ状態管理が必要なのか

React開発において、状態管理が重要になる理由を理解しましょう。

小さなアプリケーションの場合

// シンプルなアプリケーション
const SimpleApp = () => {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState(null);
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      {user && <p>こんにちは、{user.name}さん</p>}
    </div>
  );
};

このコードは、とてもシンプルなアプリケーションの例です。

useStateを使って、カウント数とユーザー情報を管理しています。 ボタンをクリックするとカウントが増え、ユーザー情報があれば挨拶を表示します。

小さなアプリケーションでは、これくらいの状態管理で十分ですね。

複雑なアプリケーションの問題

// 複雑になったアプリケーション
const ComplexApp = () => {
  const [user, setUser] = useState(null);
  const [products, setProducts] = useState([]);
  const [cart, setCart] = useState([]);
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  return (
    <div>
      <Header user={user} cartCount={cart.length} />
      <ProductList 
        products={products}
        onAddToCart={(product) => {
          setCart([...cart, product]);
        }}
      />
      <Cart 
        items={cart}
        onRemove={(productId) => {
          setCart(cart.filter(item => item.id !== productId));
        }}
        onCheckout={(orderData) => {
          setOrders([...orders, orderData]);
          setCart([]);
        }}
      />
      <UserProfile user={user} orders={orders} />
    </div>
  );
};

アプリケーションが複雑になると、このような問題が発生します。

まず、状態の数が増えています。 ユーザー情報、商品リスト、カート、注文履歴、ローディング状態、エラー状態などです。

次に、状態の操作が複雑になっています。 カートに商品を追加したり、削除したり、チェックアウトで注文に移動したりと、たくさんの処理が必要です。

さらに、どのコンポーネントでも同じ状態を使いたいという問題があります。

Prop Drillingの問題

// 深いネストでのプロパティ渡し
const App = () => {
  const [user, setUser] = useState(null);
  
  return (
    <Layout user={user}>
      <MainContent user={user}>
        <Sidebar user={user}>
          <UserMenu user={user} />
        </Sidebar>
      </MainContent>
    </Layout>
  );
};

// 全てのコンポーネントでuserを受け取る必要がある
const Layout = ({ user, children }) => {
  return (
    <div>
      <Header user={user} />
      {children}
    </div>
  );
};

const MainContent = ({ user, children }) => {
  return (
    <main>
      <WelcomeMessage user={user} />
      {children}
    </main>
  );
};

これは「Prop Drilling」と呼ばれる問題です。

深い階層のコンポーネントで使いたいデータを、全ての中間コンポーネントを通して渡す必要があります。 途中のコンポーネントでは使わないのに、ただ受け渡すためだけにpropsを書く必要があるんです。

こうなると、コードが複雑になって保守しにくくなります。

状態の種類とスコープ

状態管理を考える前に、状態の種類を理解しましょう。

ローカル状態

// コンポーネント内のみで使用する状態
const SearchForm = () => {
  const [query, setQuery] = useState(''); // ローカル状態
  const [isLoading, setIsLoading] = useState(false); // ローカル状態
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    
    try {
      // 検索処理
      await performSearch(query);
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="検索キーワード"
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? '検索中...' : '検索'}
      </button>
    </form>
  );
};

このコードでは、検索フォームの中だけで使う状態を管理しています。

queryは入力されたキーワード、isLoadingは検索中かどうかを表します。 これらの状態はそのコンポーネント内でのみ必要なので、ローカル状態として管理するのが適切です。

フォームが送信されると検索処理が始まり、完了するまでボタンを無効化します。

グローバル状態

// アプリケーション全体で必要な状態
const globalState = {
  user: { name: '田中太郎', email: 'tanaka@example.com' },
  theme: 'light',
  language: 'ja',
  notifications: []
};

// 複数のコンポーネントで使用
const Header = () => {
  const { user, theme, notifications } = useGlobalState();
  
  return (
    <header style={{ backgroundColor: theme === 'light' ? '#fff' : '#333' }}>
      <h1>アプリケーション</h1>
      <div>
        <span>こんにちは、{user.name}さん</span>
        <NotificationBadge count={notifications.length} />
      </div>
    </header>
  );
};

const UserProfile = () => {
  const { user, language } = useGlobalState();
  
  return (
    <div>
      <h2>{language === 'ja' ? 'プロフィール' : 'Profile'}</h2>
      <p>{user.name}</p>
      <p>{user.email}</p>
    </div>
  );
};

グローバル状態は、アプリケーション全体で共有したい情報です。

上の例では、ユーザー情報、テーマ、言語設定、通知などがグローバル状態として管理されています。 ヘッダーコンポーネントでもプロフィールコンポーネントでも、同じユーザー情報を参照できます。

テーマが変わればアプリ全体の見た目が変わり、言語設定を変えれば表示言語が切り替わります。

サーバー状態

// APIから取得するデータの状態
const useUserData = (userId) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, [userId]);
  
  return { user, loading, error };
};

サーバー状態は、サーバーから取得するデータを管理します。

このコードでは、ユーザーIDを指定してサーバーからユーザー情報を取得しています。 取得中はloadingがtrue、エラーが発生したらerrorにメッセージが入ります。

サーバー状態は他の状態と少し違います。 ネットワークの状況やサーバーの応答時間に依存するため、特別な考慮が必要です。

各状態管理ライブラリの特徴

Redux - 予測可能な状態管理

Reduxは、予測可能で集中的な状態管理を提供するライブラリです。

Reduxの基本構造

// Action Types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const SET_USER = 'SET_USER';

// Action Creators
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
const setUser = (user) => ({ type: SET_USER, payload: user });

// Reducer
const initialState = {
  count: 0,
  user: null
};

const rootReducer = (state = initialState, action) => {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + 1 };
    case DECREMENT:
      return { ...state, count: state.count - 1 };
    case SET_USER:
      return { ...state, user: action.payload };
    default:
      return state;
  }
};

// Store
import { createStore } from 'redux';
const store = createStore(rootReducer);

// Component
import { useSelector, useDispatch } from 'react-redux';

const Counter = () => {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
    </div>
  );
};

このコードがReduxの基本的な構造です。 ちょっと複雑に見えますが、順番に理解していきましょう。

Action Typesは、どんな操作をするかを表す定数です。 「カウントを増やす」「カウントを減らす」「ユーザーを設定する」といった具合です。

Action Creatorsは、アクションオブジェクトを作る関数です。 これらの関数を呼ぶことで、実際のアクションが作られます。

Reducerは、現在の状態とアクションを受け取って、新しい状態を返す関数です。 どのアクションが来たかに応じて、適切な状態変更を行います。

Storeが全体の状態を管理し、コンポーネントから状態を読み取ったり更新したりできます。

Reduxは厳格な単方向データフローを強制します。 つまり、状態の変更は必ずAction → Reducer → Storeの順番で行われます。

Redux Toolkitによる改善

// Redux Toolkit を使用した簡潔な書き方
import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment: (state) => {
      state.value += 1; // Immer により直接的な更新が可能
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    }
  }
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

const store = configureStore({
  reducer: {
    counter: counterSlice.reducer
  }
});

// Component
const Counter = () => {
  const count = useSelector(state => state.counter.value);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
};

Redux Toolkitを使うと、コードがずっとシンプルになります。

createSliceを使うことで、Action TypesとAction Creators、Reducerを一度に定義できます。 また、Immerというライブラリが組み込まれているので、state.value += 1のように直接的な書き方ができます。

通常のJavaScriptでは状態を直接変更してはいけませんが、Redux Toolkitが内部で適切に処理してくれるんです。

Reduxの利点と欠点

Reduxの特徴を整理してみましょう。

利点として:

  • 予測可能な状態更新:すべての変更がActionとReducerを通るので、何が起こるか追跡しやすい
  • 強力な開発ツール:Redux DevToolsで状態の変化を詳細に確認できる
  • 時間旅行デバッグ:過去の状態に戻ったり、アクションを再実行したりできる
  • 大規模開発に適している:チーム開発で一貫性を保ちやすい

欠点として:

  • 学習コストが高い:Action、Reducer、Storeなどの概念を理解する必要がある
  • ボイラープレートが多い:シンプルな状態管理でも多くのコードが必要
  • 小さなアプリには過剰:簡単な機能にも複雑な仕組みを使うことになる

小さなアプリケーションではReduxは過剰な場合が多いです。 でも、大きなアプリケーションでは、その恩恵を十分に受けられます。

Zustand - シンプルな状態管理

Zustandは、軽量でシンプルな状態管理ライブラリです。

Zustandの基本的な使用方法

import { create } from 'zustand';

// Store の作成
const useStore = create((set) => ({
  count: 0,
  user: null,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  setUser: (user) => set({ user }),
  reset: () => set({ count: 0, user: null })
}));

// Component での使用
const Counter = () => {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
};

const UserProfile = () => {
  const user = useStore((state) => state.user);
  const setUser = useStore((state) => state.setUser);
  
  const handleLogin = () => {
    setUser({ name: '田中太郎', email: 'tanaka@example.com' });
  };
  
  return (
    <div>
      {user ? (
        <p>こんにちは、{user.name}さん</p>
      ) : (
        <button onClick={handleLogin}>ログイン</button>
      )}
    </div>
  );
};

Zustandはとてもシンプルです!

create関数で店舗(Store)を作り、その中に状態と操作関数を定義します。 set関数を使って状態を更新し、コンポーネントからはuseStoreで必要な部分だけを取得します。

Reduxと比べて、書くコードの量が圧倒的に少ないですね。 Action TypesやReducerといった複雑な概念も不要です。

コンポーネントでの使用も直感的で、必要な状態や関数をそのまま取得できます。

Zustandの高度な機能

// 複数のストアの組み合わせ
const useAuthStore = create((set) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      });
      const user = await response.json();
      set({ user, isAuthenticated: true });
    } catch (error) {
      console.error('ログインエラー:', error);
    }
  },
  logout: () => set({ user: null, isAuthenticated: false })
}));

const useCartStore = create((set, get) => ({
  items: [],
  addItem: (product) => set((state) => ({
    items: [...state.items, { ...product, quantity: 1 }]
  })),
  removeItem: (productId) => set((state) => ({
    items: state.items.filter(item => item.id !== productId)
  })),
  updateQuantity: (productId, quantity) => set((state) => ({
    items: state.items.map(item =>
      item.id === productId ? { ...item, quantity } : item
    )
  })),
  getTotalPrice: () => {
    const { items } = get();
    return items.reduce((total, item) => total + item.price * item.quantity, 0);
  },
  clearCart: () => set({ items: [] })
}));

// 複数のストアを使用するコンポーネント
const ShoppingCart = () => {
  const user = useAuthStore((state) => state.user);
  const items = useCartStore((state) => state.items);
  const getTotalPrice = useCartStore((state) => state.getTotalPrice);
  const clearCart = useCartStore((state) => state.clearCart);
  
  if (!user) {
    return <p>カートを表示するにはログインしてください</p>;
  }
  
  return (
    <div>
      <h2>ショッピングカート</h2>
      {items.length === 0 ? (
        <p>カートは空です</p>
      ) : (
        <div>
          {items.map(item => (
            <div key={item.id}>
              <span>{item.name} × {item.quantity}</span>
              <span>¥{item.price * item.quantity}</span>
            </div>
          ))}
          <p>合計: ¥{getTotalPrice()}</p>
          <button onClick={clearCart}>カートをクリア</button>
        </div>
      )}
    </div>
  );
};

より実践的な例を見てみましょう。

認証用のストアでは、ユーザー情報の管理とログイン・ログアウト機能を実装しています。 非同期処理も簡単に書けて、APIとの連携もスムーズです。

カート用のストアでは、商品の追加・削除・数量変更といった機能を管理しています。 get関数を使って現在の状態を取得し、合計金額を計算する関数も定義できます。

複数のストアを組み合わせて使うこともできます。 認証状態とカート状態を別々に管理して、コンポーネントで両方を使用しています。

Zustandでは、関心事ごとにストアを分けるのが良いパターンです。

Zustandの利点と欠点

Zustandの特徴をまとめてみましょう。

利点として:

  • シンプルなAPI:覚えることが少なく、直感的に使える
  • TypeScriptサポート:型安全性も確保できる
  • 小さなバンドルサイズ:約2KBと軽量
  • 学習コストが低い:すぐに使い始められる
  • 柔軟性が高い:自由度の高い設計が可能

欠点として:

  • 大規模アプリでの構造化が難しい:自由度が高い分、設計指針が必要
  • 開発ツールサポートが限定的:Reduxほど強力なデバッグツールはない
  • チーム開発での一貫性:各開発者の書き方がバラバラになりがち

小〜中規模のアプリケーションに最適です。 シンプルで迅速な開発を重視するプロジェクトにぴったりですね。

React Query - サーバー状態特化

React Query(TanStack Query)は、サーバー状態管理に特化したライブラリです。

React Queryの基本的な使用方法

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// データフェッチング
const useUser = (userId) => {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) {
        throw new Error('User fetch failed');
      }
      return response.json();
    },
    staleTime: 5 * 60 * 1000, // 5分間は新鮮とみなす
    cacheTime: 10 * 60 * 1000, // 10分間キャッシュ
  });
};

// データの更新
const useUpdateUser = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (userData) => {
      const response = await fetch(`/api/users/${userData.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });
      return response.json();
    },
    onSuccess: (data) => {
      // キャッシュを更新
      queryClient.setQueryData(['user', data.id], data);
      queryClient.invalidateQueries(['users']); // 関連クエリを無効化
    },
  });
};

// Component での使用
const UserProfile = ({ userId }) => {
  const { data: user, isLoading, error } = useUser(userId);
  const updateUserMutation = useUpdateUser();
  
  const handleUpdate = (userData) => {
    updateUserMutation.mutate(userData);
  };
  
  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <button 
        onClick={() => handleUpdate({ ...user, name: '更新された名前' })}
        disabled={updateUserMutation.isLoading}
      >
        {updateUserMutation.isLoading ? '更新中...' : '名前を更新'}
      </button>
    </div>
  );
};

React Queryの基本的な使い方を見てみましょう。

データの取得にはuseQueryを使います。 queryKeyでデータを識別し、queryFnで実際の取得処理を定義します。 取得したデータは自動的にキャッシュされ、staleTimecacheTimeで期間を制御できます。

データの更新にはuseMutationを使います。 更新が成功したらonSuccessでキャッシュを更新し、関連するクエリを無効化して再取得を促します。

コンポーネントでの使用はとてもシンプルです。 ローディング状態やエラー状態も自動的に管理され、ボタンの無効化も簡単にできます。

React Queryは、サーバー状態の取得、キャッシュ、更新を自動化してくれます。

React Queryの高度な機能

// 無限スクロール
const useInfiniteUsers = () => {
  return useInfiniteQuery({
    queryKey: ['users', 'infinite'],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await fetch(`/api/users?page=${pageParam}&limit=20`);
      return response.json();
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });
};

const UserList = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteUsers();
  
  return (
    <div>
      {data?.pages.map((page, pageIndex) => (
        <div key={pageIndex}>
          {page.users.map(user => (
            <div key={user.id}>{user.name}</div>
          ))}
        </div>
      ))}
      
      <button
        onClick={fetchNextPage}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? '読み込み中...' : hasNextPage ? 'もっと見る' : '全て表示済み'}
      </button>
    </div>
  );
};

// 楽観的更新
const useOptimisticTodoUpdate = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (todo) => {
      const response = await fetch(`/api/todos/${todo.id}`, {
        method: 'PUT',
        body: JSON.stringify(todo),
      });
      return response.json();
    },
    onMutate: async (newTodo) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries(['todos']);
      
      // 現在のデータを取得
      const previousTodos = queryClient.getQueryData(['todos']);
      
      // 楽観的更新
      queryClient.setQueryData(['todos'], (old) =>
        old.map(todo => todo.id === newTodo.id ? newTodo : todo)
      );
      
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // エラー時に元のデータに戻す
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    onSettled: () => {
      // 成功・失敗に関わらずクエリを無効化
      queryClient.invalidateQueries(['todos']);
    },
  });
};

React Queryの高度な機能も見てみましょう。

無限スクロールuseInfiniteQueryで簡単に実装できます。 ページネーション情報を管理し、「もっと見る」ボタンで次のページを取得します。 各ページのデータは自動的に連結されて表示されます。

楽観的更新は、サーバーの応答を待たずに画面を更新する機能です。 onMutateで先に画面を更新し、エラーが発生したらonErrorで元に戻します。 ユーザーの体感速度が向上し、よりスムーズな操作感を実現できます。

React Queryは、複雑なサーバー状態管理を簡単にしてくれる強力なライブラリです。

Context API - React標準の状態管理

Context APIは、Reactに組み込まれた状態管理機能です。

Context APIの基本実装

import { createContext, useContext, useReducer } from 'react';

// State と Actions の定義
const initialState = {
  user: null,
  theme: 'light',
  notifications: []
};

const appReducer = (state, action) => {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    case 'ADD_NOTIFICATION':
      return {
        ...state,
        notifications: [...state.notifications, action.payload]
      };
    case 'REMOVE_NOTIFICATION':
      return {
        ...state,
        notifications: state.notifications.filter(n => n.id !== action.payload)
      };
    default:
      return state;
  }
};

// Context の作成
const AppContext = createContext();

// Provider の作成
export const AppProvider = ({ children }) => {
  const [state, dispatch] = useReducer(appReducer, initialState);
  
  const actions = {
    setUser: (user) => dispatch({ type: 'SET_USER', payload: user }),
    setTheme: (theme) => dispatch({ type: 'SET_THEME', payload: theme }),
    addNotification: (notification) => dispatch({
      type: 'ADD_NOTIFICATION',
      payload: { ...notification, id: Date.now() }
    }),
    removeNotification: (id) => dispatch({ type: 'REMOVE_NOTIFICATION', payload: id })
  };
  
  return (
    <AppContext.Provider value={{ state, actions }}>
      {children}
    </AppContext.Provider>
  );
};

// カスタムフック
export const useAppContext = () => {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useAppContext must be used within AppProvider');
  }
  return context;
};

// Component での使用
const ThemeToggle = () => {
  const { state, actions } = useAppContext();
  
  return (
    <button onClick={() => actions.setTheme(state.theme === 'light' ? 'dark' : 'light')}>
      {state.theme === 'light' ? 'ダークモード' : 'ライトモード'}
    </button>
  );
};

const UserProfile = () => {
  const { state, actions } = useAppContext();
  
  const handleLogin = () => {
    actions.setUser({ name: '田中太郎', email: 'tanaka@example.com' });
    actions.addNotification({
      type: 'success',
      message: 'ログインしました'
    });
  };
  
  return (
    <div style={{
      backgroundColor: state.theme === 'light' ? '#fff' : '#333',
      color: state.theme === 'light' ? '#333' : '#fff'
    }}>
      {state.user ? (
        <p>こんにちは、{state.user.name}さん</p>
      ) : (
        <button onClick={handleLogin}>ログイン</button>
      )}
    </div>
  );
};

Context APIの実装を詳しく見てみましょう。

状態と操作の定義から始まります。 initialStateで初期状態を定義し、appReducerで状態の更新ロジックを書きます。 Reduxのreducerと同じような仕組みですね。

Contextの作成では、createContextでコンテキストを作り、Providerで子コンポーネントに状態を提供します。 useReducerを使って状態管理し、操作用の関数も一緒に渡します。

カスタムフックを作ることで、コンポーネントから簡単に状態にアクセスできます。 エラーハンドリングも含めて、使いやすいインターフェースを提供しています。

コンポーネントでの使用は、作ったカスタムフックを呼び出すだけです。 状態の読み取りも更新も、直感的に行えます。

Context APIは、追加ライブラリなしで状態管理ができる大きなメリットがあります。

状態管理ライブラリの選び方

プロジェクトサイズによる選択

アプリケーションのサイズに応じた適切な選択を考えましょう。

小規模プロジェクト(〜10コンポーネント)

// useState + useContext で十分
const SmallApp = () => {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <UserContext.Provider value={{ user, setUser }}>
        <Header />
        <MainContent />
        <Footer />
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
};

小規模プロジェクトでは、useState + useContextの組み合わせで十分です。

理由として、シンプルで学習コストが低く、追加ライブラリも不要だからです。 コンポーネント数が少ないので、複雑な状態管理は必要ありません。

Context Providerを重ねることで、必要な状態を子コンポーネントに渡せます。

中規模プロジェクト(10〜50コンポーネント)

// Zustand が適している
const useMediumAppStore = create((set) => ({
  // ユーザー関連
  user: null,
  setUser: (user) => set({ user }),
  
  // UI状態
  theme: 'light',
  setTheme: (theme) => set({ theme }),
  
  // アプリケーション状態
  cart: [],
  addToCart: (item) => set((state) => ({
    cart: [...state.cart, item]
  })),
  
  // 設定
  settings: {
    language: 'ja',
    notifications: true
  },
  updateSettings: (newSettings) => set((state) => ({
    settings: { ...state.settings, ...newSettings }
  }))
}));

中規模プロジェクトでは、Zustandが最適です。

理由として:

  • シンプルで直感的:学習コストが低く、すぐに使い始められる
  • TypeScriptサポートが優秀:型安全性を確保できる
  • 複数のストアを組み合わせやすい:機能ごとに分離できる

状態が複雑になってきても、Zustandなら整理しやすく保守しやすいコードが書けます。

大規模プロジェクト(50コンポーネント以上)

// Redux Toolkit が適している
const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    products: productsSlice.reducer,
    cart: cartSlice.reducer,
    orders: ordersSlice.reducer,
    ui: uiSlice.reducer,
    settings: settingsSlice.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(persistMiddleware),
});

大規模プロジェクトでは、Redux Toolkitが推奨されます。

理由として:

  • 大規模な状態管理が可能:複雑な状態を整理して管理できる
  • 強力な開発ツール:デバッグとトラブルシューティングが容易
  • チーム開発での一貫性:統一されたパターンで開発できる
  • 豊富なミドルウェア:様々な機能を追加できる

多くの開発者が関わる大規模プロジェクトでは、Reduxの構造化と一貫性が重要になります。

状態の種類による選択

状態の種類に応じて、適切なライブラリを選択しましょう。

サーバー状態の管理

// React Query + Zustand の組み合わせ
const useGlobalStore = create((set) => ({
  theme: 'light',
  setTheme: (theme) => set({ theme })
}));

const useUsers = () => {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 5 * 60 * 1000
  });
};

const UserManagement = () => {
  const { data: users, isLoading } = useUsers(); // サーバー状態
  const theme = useGlobalStore(state => state.theme); // クライアント状態
  
  return (
    <div style={{ backgroundColor: theme === 'light' ? '#fff' : '#333' }}>
      {isLoading ? (
        <p>読み込み中...</p>
      ) : (
        users.map(user => <UserCard key={user.id} user={user} />)
      )}
    </div>
  );
};

サーバー状態とクライアント状態を分けて管理するのがベストプラクティスです。

サーバー状態はReact Queryで管理し、クライアント状態はZustandで管理します。 それぞれのライブラリが得意な分野を活かせて、効率的な開発ができます。

サーバーからのデータ取得、キャッシュ、更新はReact Queryにお任せ。 UIの状態やユーザー設定はZustandで管理する、という役割分担がうまくいきます。

チーム規模による選択

開発チームの規模も考慮に入れましょう

少人数チーム(1〜3人)

// 自由度の高い選択が可能
const useFlexibleStore = create((set, get) => ({
  // 開発者の好みに応じて自由に設計
  data: {},
  actions: {
    updateData: (key, value) => set((state) => ({
      data: { ...state.data, [key]: value }
    }))
  }
}));

少人数チームでは、Zustand や Context APIがおすすめです。

理由として:

  • 学習コストが低い:新しいメンバーもすぐに理解できる
  • 迅速な開発が可能:複雑な設定なしに開発を始められる
  • 自由度が高い:チームの好みに応じて柔軟に設計できる

少人数なら、複雑なルールや構造は必要ありません。 シンプルで迅速な開発を重視しましょう。

大人数チーム(5人以上)

// 厳格なルールと構造が必要
const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    loading: false,
    error: null
  },
  reducers: {
    fetchUserStart: (state) => {
      state.loading = true;
      state.error = null;
    },
    fetchUserSuccess: (state, action) => {
      state.loading = false;
      state.data = action.payload;
    },
    fetchUserFailure: (state, action) => {
      state.loading = false;
      state.error = action.payload;
    }
  }
});

大人数チームでは、Redux Toolkitが推奨されます。

理由として:

  • 一貫性のあるコード:全員が同じパターンで開発できる
  • 強力な開発ツール:問題の特定と解決が容易
  • 豊富なドキュメント:学習リソースが充実している
  • 業界標準:多くの開発者が知っているパターン

大人数チームでは、一貫性と保守性が最も重要です。 個人の好みよりも、チーム全体の効率を重視しましょう。

実践的な状態管理パターン

ハイブリッド状態管理

複数のライブラリを組み合わせた実践的なパターンです。

React Query + Zustand パターン

// サーバー状態: React Query
const useProducts = () => {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const response = await fetch('/api/products');
      return response.json();
    }
  });
};

const useProductMutation = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (product) => {
      const response = await fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(product)
      });
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries(['products']);
    }
  });
};

// クライアント状態: Zustand
const useUIStore = create((set) => ({
  // UI状態
  theme: 'light',
  sidebarOpen: false,
  modalOpen: false,
  
  // UI操作
  toggleTheme: () => set((state) => ({
    theme: state.theme === 'light' ? 'dark' : 'light'
  })),
  toggleSidebar: () => set((state) => ({
    sidebarOpen: !state.sidebarOpen
  })),
  openModal: () => set({ modalOpen: true }),
  closeModal: () => set({ modalOpen: false })
}));

const useCartStore = create((set, get) => ({
  // ショッピングカート状態
  items: [],
  
  // カート操作
  addItem: (product) => set((state) => {
    const existingItem = state.items.find(item => item.id === product.id);
    if (existingItem) {
      return {
        items: state.items.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      };
    } else {
      return {
        items: [...state.items, { ...product, quantity: 1 }]
      };
    }
  }),
  
  removeItem: (productId) => set((state) => ({
    items: state.items.filter(item => item.id !== productId)
  })),
  
  clearCart: () => set({ items: [] }),
  
  getTotal: () => {
    const { items } = get();
    return items.reduce((total, item) => total + item.price * item.quantity, 0);
  }
}));

// Component での使用
const ProductList = () => {
  const { data: products, isLoading } = useProducts();
  const addToCart = useCartStore(state => state.addItem);
  const theme = useUIStore(state => state.theme);
  
  if (isLoading) return <div>読み込み中...</div>;
  
  return (
    <div style={{
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#333' : '#fff'
    }}>
      {products?.map(product => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>¥{product.price}</p>
          <button onClick={() => addToCart(product)}>
            カートに追加
          </button>
        </div>
      ))}
    </div>
  );
};

このパターンが実践的で効果的です。

React Queryでサーバー状態を管理し、Zustandでクライアント状態を管理します。 それぞれの得意分野を活かすことで、効率的で保守しやすいコードが書けます。

商品データはサーバーから取得してキャッシュし、UI状態やカート状態はクライアント側で管理します。 テーマ切り替えやモーダル表示などのUI操作も、Zustandで簡単に実装できます。

サーバー状態とクライアント状態を分離することで、各ライブラリの強みを活かせます。

状態管理のベストプラクティス

効果的な状態管理のための実践的なパターンです。

状態の正規化

// 正規化されていない状態(問題のある例)
const badState = {
  posts: [
    {
      id: 1,
      title: '記事1',
      author: { id: 1, name: '田中太郎' },
      comments: [
        { id: 1, text: 'コメント1', author: { id: 1, name: '田中太郎' } },
        { id: 2, text: 'コメント2', author: { id: 2, name: '佐藤花子' } }
      ]
    }
  ]
};

// 正規化された状態(推奨)
const useNormalizedStore = create((set, get) => ({
  // エンティティ別に分離
  users: {
    1: { id: 1, name: '田中太郎' },
    2: { id: 2, name: '佐藤花子' }
  },
  posts: {
    1: { id: 1, title: '記事1', authorId: 1, commentIds: [1, 2] }
  },
  comments: {
    1: { id: 1, text: 'コメント1', authorId: 1, postId: 1 },
    2: { id: 2, text: 'コメント2', authorId: 2, postId: 1 }
  },
  
  // セレクター関数
  getPostWithDetails: (postId) => {
    const state = get();
    const post = state.posts[postId];
    if (!post) return null;
    
    return {
      ...post,
      author: state.users[post.authorId],
      comments: post.commentIds.map(id => ({
        ...state.comments[id],
        author: state.users[state.comments[id].authorId]
      }))
    };
  },
  
  // 更新操作
  updateUser: (userId, updates) => set((state) => ({
    users: {
      ...state.users,
      [userId]: { ...state.users[userId], ...updates }
    }
  }))
}));

状態の正規化により、データの整合性と更新効率が向上します。

正規化されていない状態では、同じユーザー情報が複数の場所に重複して保存されます。 これだと、ユーザー名を変更するときに全ての場所を更新する必要があります。

正規化された状態では、各エンティティが独立して管理されます。 ユーザー情報の更新は1箇所だけで済み、データの整合性が保たれます。

セレクター関数を使うことで、必要なときに関連データを組み合わせて取得できます。

パフォーマンス最適化

// セレクターの最適化
const useOptimizedStore = create((set) => ({
  users: [],
  filter: '',
  sortBy: 'name',
  
  setFilter: (filter) => set({ filter }),
  setSortBy: (sortBy) => set({ sortBy })
}));

// メモ化されたセレクター
const useFilteredUsers = () => {
  const users = useOptimizedStore(state => state.users);
  const filter = useOptimizedStore(state => state.filter);
  const sortBy = useOptimizedStore(state => state.sortBy);
  
  return useMemo(() => {
    let filtered = users.filter(user =>
      user.name.toLowerCase().includes(filter.toLowerCase())
    );
    
    filtered.sort((a, b) => {
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      if (sortBy === 'email') return a.email.localeCompare(b.email);
      return 0;
    });
    
    return filtered;
  }, [users, filter, sortBy]);
};

// コンポーネントでの最適化
const UserList = memo(() => {
  const filteredUsers = useFilteredUsers();
  
  return (
    <div>
      {filteredUsers.map(user => (
        <UserItem key={user.id} user={user} />
      ))}
    </div>
  );
});

const UserItem = memo(({ user }) => {
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
});

適切な最適化により、パフォーマンスが向上します。

useMemoを使って、フィルタリングとソートの結果をメモ化します。 依存関係が変わらない限り、重い計算は再実行されません。

memoを使って、コンポーネントの不要な再レンダリングを防ぎます。 propsが変わらない限り、コンポーネントは再描画されません。

ただし、最適化は必要な場合のみ行いましょう。 パフォーマンスの問題が実際に発生してから対処するのがベストプラクティスです。

まとめ

React開発における状態管理ライブラリの選択は、プロジェクトの規模や要件によって決まります

状態管理ライブラリの特徴

  • Redux: 大規模アプリケーション向け、予測可能な状態管理
  • Zustand: シンプルで直感的、中小規模アプリケーションに最適
  • React Query: サーバー状態管理に特化、キャッシュとデータフェッチングを自動化
  • Context API: React標準、小規模アプリケーションに適している

選択指針

  • 小規模プロジェクト: useState + useContext
  • 中規模プロジェクト: Zustand + React Query
  • 大規模プロジェクト: Redux Toolkit + React Query
  • サーバー状態: React Query一択

実践的なアプローチ

  • ハイブリッド状態管理: 複数ライブラリの組み合わせ
  • 状態の分離: サーバー状態とクライアント状態を分ける
  • 正規化: データ構造の最適化
  • パフォーマンス最適化: 適切なメモ化とセレクター

開発のポイント

  • シンプルから始める: 過度な複雑化を避ける
  • 要件に応じて選択: プロジェクトの特性を考慮
  • チーム状況を考慮: 学習コストと開発効率のバランス
  • 将来の拡張性: スケーラビリティを意識した設計

適切な状態管理ライブラリを選択することで、保守性が高く、パフォーマンスに優れたReactアプリケーションを構築できます。

まずは小さく始めて、必要に応じて段階的に拡張していくのがおすすめです。

状態管理はReact開発の核心的な要素です。 ぜひ、実際のプロジェクトで様々なライブラリを試して、最適な選択を見つけてみてください!

関連記事