0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React HooksをHackしよう! 【Part19: 状態管理...何を使えばいいの?】

Posted at

React で状態管理を行う際、「useState で十分?」「useReducer に切り替えるべき?」「外部ライブラリは必要?」と悩んだことはありませんか?
本記事では、useState を基準として、useReducer、useContext、useSyncExternalStore、そして Redux、Zustand、Jotai などの外部ライブラリを比較し、どのような場面でどの選択肢を使うべきかを一緒に考えていきましょう!

検証環境: この記事は facebook/react リポジトリ(2025年12月時点の main ブランチ)の実際のソースコードを参照して書かれています。

React の内部実装は頻繁に変更されます。この記事の内容は執筆時点での実装に基づいており、将来のバージョンでは変更される可能性があります。


0. State of React 2024 から見る状態管理ライブラリの現状

State of Reactは開発者コミュニティ・プロジェクトの Devographics が、React公式(Meta社)とは独立した立場で、Reactエコシステムの最新トレンドや技術の認知度・満足度を把握するために実施しているアンケートです。仕事や趣味で Reactを利用する世界中の開発者(2024年は約7,900人)を対象に、使用している機能やライブラリ、学習リソース、そして現在の開発環境に対する満足度や不満点を聞いています。
まず、State of React 2024 の調査結果を確認しましょう。使用率(Usage: 使用経験のある回答者の割合)は以下の通りです:

ライブラリ 使用率 (2024) 備考
useState 99% React 内蔵、事実上の標準
Redux 77% 長年のデファクトスタンダード
Redux Toolkit 54% Redux の公式推奨ツールキット
Zustand 41% 急成長中の軽量ライブラリ
MobX 16% リアクティブプログラミング
Jotai 15% アトミックな状態管理
XState 9% ステートマシン
Effector 1% データフローグラフ
  1. useState が圧倒的: 99% の開発者が使用。状態管理の基本
  2. Redux はまだ主流: 77% の使用率。大規模プロジェクトでの信頼性
  3. Zustand の急成長: 41% まで成長。シンプルさが支持される
  4. Jotai の台頭: 15% まで成長。アトミックモデルへの関心

本記事では、6つ(公式フック(useState, useReducer, useContext)、Redux、Redux Toolkit、Zustand、MobX、Jotai)に焦点を当てて解説します。


1. 状態管理の全体像

React の状態管理は、大きく分けて以下の4つのレイヤーに分類できます:

本記事ではローカル状態とグローバル状態の2レイヤーで考えます

1.1 なぜ選択が重要なのか?

過剰な選択はコードの複雑化を招き、不足した選択はスケーラビリティの問題を引き起こします。

問題 原因 結果
Props drilling グローバル状態が必要な場面で useState を使用 保守性の低下
過剰な再レンダー Context の不適切な使用 パフォーマンス低下
状態の分散 関連する状態を別々の useState で管理 バグの発生
学習コストの増大 必要以上に複雑なライブラリを導入 開発速度の低下

2. 各ツールの概要と特徴

2.1 useState — 状態管理の基本

役割: コンポーネントごとの単純な状態管理

const [count, setCount] = useState(0);

// 直接値を設定
setCount(5);

// 更新関数で前の値を参照
setCount(prev => prev + 1);

特徴

項目 内容
スコープ コンポーネントローカル
複雑さ ⭐ 最もシンプル
適したデータ 単一の値、独立した状態
更新トリガー setState 呼び出し

内部実装のポイント

facebook/react リポジトリを見ると、useState は内部的に basicStateReducer を使用していることがわかります:

// packages/react-reconciler/src/ReactFiberHooks.js
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

つまり、useState は useReducer の特殊ケースとして実装されています!


2.2 useReducer — 複雑な状態遷移の管理

役割: 複数の関連する状態、複雑な更新ロジックの管理

useStateで多くの状態を管理していると、状態の更新ロジックが散らばり、保守が難しくなります。useReducerは、状態遷移を一元化し、明確にするのに役立ちます。
また複数の更新が同時に発生する場合、useStateを使用する場合画面が何度も再レンダーされる可能性がありますが、useReducerを使用すると一度のdispatchでまとめて状態を更新でき、パフォーマンスが向上します。

type State = { count: number; step: number };
type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_STEP'; step: number };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step };
    case 'DECREMENT':
      return { ...state, count: state.count - state.step };
    case 'SET_STEP':
      return { ...state, step: action.step };
    default:
      return state;
  }
};

const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
dispatch({ type: 'INCREMENT' });

特徴

項目 内容
スコープ コンポーネントローカル
複雑さ ⭐⭐ useState より構造的
適したデータ 複数の関連する状態、複雑なロジック
更新トリガー dispatch(action) 呼び出し

useState vs useReducer の内部実装

// useState の内部
function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

// useReducer の内部
function updateReducer(reducer, initialArg) {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, currentHook, reducer);
}

両者は同じ updateReducerImpl を使用しており、唯一の違いは reducer 関数の複雑さです。


2.3 useContext — コンポーネントツリー全体での共有

役割: Props drilling の解消、グローバルなデータ共有

// Context の作成
const ThemeContext = createContext<'light' | 'dark'>('light');

// Provider で値を提供
function App() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  return (
    <ThemeContext value={theme}>
      <Page />
    </ThemeContext>
  );
}

// Consumer で値を使用
function Button() {
  const theme = useContext(ThemeContext);
  return <button className={`btn-${theme}`}>Click</button>;
}

特徴

項目 内容
スコープ サブツリー全体
複雑さ ⭐⭐ セットアップが必要
適したデータ テーマ、認証、ロケール
更新トリガー Provider の value 変更

内部実装のポイント

// packages/react-reconciler/src/ReactFiberNewContext.js
function readContextForConsumer<T>(consumer: Fiber | null, context: ReactContext<T>): T {
  // Context の現在値を取得
  const value = isPrimaryRenderer
    ? context._currentValue
    : context._currentValue2;

  // 依存関係を Fiber に登録
  const contextItem = {
    context: context,
    memoizedValue: value,
    next: null,
  };
  // ...
  return value;
}

useContext は他の Hooks と異なり、Hook リストにノードを追加しません。単純に現在の Context 値を読み取り、依存関係を登録するだけです。


2.4 Zustand — 軽量な外部状態管理

役割: シンプルな API でグローバル状態を管理

import { create } from 'zustand';

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

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

// コンポーネントでの使用
function Counter() {
  const { count, increment } = useCounterStore();
  return <button onClick={increment}>{count}</button>;
}

// セレクターで必要な部分だけ購読
function DisplayCount() {
  const count = useCounterStore((state) => state.count);
  return <span>{count}</span>;
}

特徴

項目 内容
スコープ アプリ全体(ストア単位)
複雑さ ⭐⭐ シンプルな API
適したデータ 複数コンポーネント間で共有する状態
更新トリガー set 関数による状態更新
  1. Provider 不要: Context のように Provider で包む必要がない
  2. セレクターで最適化: 必要な部分だけ購読して再レンダーを抑制
  3. シンプルな API: Redux のような boilerplate が不要
  4. React 外でも使用可能: getState() でどこからでもアクセス可能

2.5 Jotai — アトミックな状態管理

役割: ボトムアップのアトミックな状態管理

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';

// アトムの定義
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// コンポーネントでの使用
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// 読み取り専用
function DoubleDisplay() {
  const doubleCount = useAtomValue(doubleCountAtom);
  return <span>Double: {doubleCount}</span>;
}

// 書き込み専用
function IncrementButton() {
  const setCount = useSetAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}

特徴

項目 内容
スコープ atom単位(柔軟)
複雑さ ⭐⭐ useState に近い感覚
適したデータ 細粒度の状態、派生状態
更新トリガー atomの値変更

Jotai vs Zustand の違い

観点 Jotai Zustand
設計思想 ボトムアップ(atom → 組み合わせ) トップダウン(store → select)
状態の定義 小さなatomを組み合わせる 1つのstoreに集約
Provider 必要(オプショナル) 不要
派生状態 ファーストクラスサポート セレクターで実現
最適化 アトム単位で自動最適化 セレクターで明示的に最適化

2.6 Redux / Redux Toolkit — エンタープライズ標準

役割: 予測可能なグローバル状態管理

Redux は長年 React のデファクトスタンダードとして使われてきました。Redux Toolkit (RTK) は Redux の公式推奨ツールキットで、boilerplate を大幅に削減します。

Redux Toolkit の基本

import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// Slice の作成(reducer + actions を一度に定義)
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1; // Immer により直接変更可能
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

// Store の作成
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
});

// 型定義
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

// コンポーネントでの使用
function Counter() {
  const count = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch<AppDispatch>();
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(counterSlice.actions.increment())}>+1</button>
      <button onClick={() => dispatch(counterSlice.actions.incrementByAmount(5))}>+5</button>
    </div>
  );
}

// App でラップ
function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

特徴

項目 内容
スコープ アプリ全体(単一ストア)
複雑さ ⭐⭐⭐ 構造化されているが学習コストあり
適したデータ 大規模アプリ、複雑なビジネスロジック
更新トリガー dispatch(action)

Redux Toolkit のメリット

  1. Immer 内蔵: ミュータブルな書き方でイミュータブル更新
  2. DevTools: 時間旅行デバッグ、状態の可視化
  3. RTK Query: データフェッチ + キャッシュを統合
  4. エコシステム: 豊富なミドルウェア、ツール

Redux vs Zustand

観点 Redux Toolkit Zustand
boilerplate 少なめ(従来の Redux より大幅削減) 最小限
DevTools 公式サポート(時間旅行) プラグインで対応
ミドルウェア 豊富(Saga, Thunk など) シンプル
学習コスト 中〜高
Provider 必須 不要
大規模対応 実績多数 十分対応可能

2.8 MobX — リアクティブプログラミング

役割: 観測可能なオブジェクトによる自動追跡

MobX は「観察可能な状態」を作成し、その状態が変更されると依存するコンポーネントが自動的に再レンダーされる仕組みを提供します。

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

// Store クラス
class CounterStore {
  count = 0;
  
  constructor() {
    makeAutoObservable(this); // 自動的に observable, action を設定
  }
  
  increment() {
    this.count += 1;
  }
  
  decrement() {
    this.count -= 1;
  }
  
  get doubleCount() {
    return this.count * 2; // computed value
  }
}

const counterStore = new CounterStore();

// observer で自動購読
const Counter = observer(() => {
  return (
    <div>
      <span>{counterStore.count}</span>
      <span>Double: {counterStore.doubleCount}</span>
      <button onClick={() => counterStore.increment()}>+1</button>
    </div>
  );
});

特徴

項目 内容
スコープ Store 単位(複数可)
複雑さ ⭐⭐⭐ OOP スタイル
適したデータ 複雑なビジネスロジック、クラスベース設計
更新トリガー observable の変更を自動検知
  1. 自動追跡: 使用している状態のみを自動で購読
  2. ミュータブル更新: 直接変更 OK(内部で追跡)
  3. computed: 派生状態が自動キャッシュ
  4. OOP フレンドリー: クラスベースで記述可能

MobX vs Redux

観点 MobX Redux
パラダイム リアクティブ Flux(単方向データフロー)
更新方法 ミュータブル イミュータブル(Immer)
boilerplate 少ない 中程度(RTK で削減)
デバッグ 自動追跡が便利だが追いにくい場合も DevTools で明示的
学習曲線 低〜中 中〜高

3. ユースケース別比較

3.1 カウンター(最もシンプルな例)

useState で十分

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

選択理由: 単一の独立した値、シンプルな更新ロジック


3.2 フォーム管理(複数の関連する状態)

useState(小規模フォーム)

function SimpleForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
    </form>
  );
}

useReducer(大規模フォーム)

type FormState = {
  name: string;
  email: string;
  isSubmitting: boolean;
  error: string | null;
};

type FormAction =
  | { type: 'SET_FIELD'; field: keyof FormState; value: string }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; error: string };

const formReducer = (state: FormState, action: FormAction): FormState => {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, error: null };
    case 'SUBMIT_SUCCESS':
      return { name: '', email: '', isSubmitting: false, error: null };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false, error: action.error };
    default:
      return state;
  }
};

function ComplexForm() {
  const [state, dispatch] = useReducer(formReducer, {
    name: '', email: '', isSubmitting: false, error: null
  });
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });
    try {
      await submitForm(state);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ type: 'SUBMIT_ERROR', error: 'エラーが発生しました' });
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.name}
        onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'name', value: e.target.value })}
        disabled={state.isSubmitting}
      />
      {/* ... */}
    </form>
  );
}

選択理由: 状態遷移が明確(入力 → 送信中 → 成功/失敗)、テストしやすい


3.3 テーマ/認証の共有(グローバル状態)

useContext(変更頻度が低い)

const ThemeContext = createContext<{
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}>({ theme: 'light', toggleTheme: () => {} });

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  
  const toggleTheme = useCallback(() => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  }, []);
  
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
  
  return <ThemeContext value={value}>{children}</ThemeContext>;
}

選択理由: 変更頻度が低い、Provider パターンが適している

Zustand(複数の場所で読み書き)

const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  isAuthenticated: false,
  login: async (credentials) => {
    const user = await api.login(credentials);
    set({ user, isAuthenticated: true });
  },
  logout: () => set({ user: null, isAuthenticated: false }),
}));

// どこからでも使える
function Header() {
  const user = useAuthStore((state) => state.user);
  const logout = useAuthStore((state) => state.logout);
  
  return user ? (
    <div>
      <span>{user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  ) : null;
}

選択理由: Provider 不要、セレクターで最適化、複数コンポーネントでの使用


3.4 ブラウザ API との連携

useSyncExternalStore

function useMediaQuery(query: string): boolean {
  const subscribe = useCallback(
    (callback: () => void) => {
      const mediaQuery = window.matchMedia(query);
      mediaQuery.addEventListener('change', callback);
      return () => mediaQuery.removeEventListener('change', callback);
    },
    [query]
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => false // SSR 時のデフォルト値
  );
}

function ResponsiveComponent() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}

選択理由: 外部 API との同期、ティアリング防止


3.5 派生状態が多い場合

Jotai

// 基本アトム
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'completed' | 'active'>('all');

// 派生アトム
const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);
  
  switch (filter) {
    case 'completed':
      return todos.filter(t => t.completed);
    case 'active':
      return todos.filter(t => !t.completed);
    default:
      return todos;
  }
});

const statsAtom = atom((get) => {
  const todos = get(todosAtom);
  return {
    total: todos.length,
    completed: todos.filter(t => t.completed).length,
    active: todos.filter(t => !t.completed).length,
  };
});

// 使用
function TodoStats() {
  const stats = useAtomValue(statsAtom);
  return (
    <div>
      Total: {stats.total} | Active: {stats.active} | Completed: {stats.completed}
    </div>
  );
}

選択理由: 派生状態のファーストクラスサポート、アトム単位の最適化


4. パフォーマンス比較

4.1 再レンダーの範囲

ツール 再レンダー範囲
useState 呼び出したコンポーネントとその子
useReducer 呼び出したコンポーネントとその子
useContext Provider 配下の全消費者
Redux セレクターが変更された購読者のみ
Zustand セレクターが変更された購読者のみ
MobX observer で使用した値のみ
Jotai atomをsubscribeしているコンポーネントのみ

7. まとめ

7.1 各ツールのポジショニング

シンプル ←―――――――――――――――――――――――――――――――――――――――――→ 複雑
ローカル ←―――――――――――――――――――――――――――――――――――――――――→ グローバル

  useState   useReducer   useContext   Zustand/Jotai   Redux/MobX
     ↓          ↓            ↓              ↓              ↓
 [単純な値] [複雑な状態]  [ツリー共有]   [軽量グローバル] [大規模アプリ]
シナリオ 推奨ツール
ボタンのトグル状態 useState
フォームの入力値(2-3フィールド) useState
フォームの入力値(4フィールド以上 + 送信状態) useReducer
テーマ切り替え useContext
認証状態(複数コンポーネントで使用) Zustand または useContext + useReducer
ショッピングカート Zustand
複雑な派生状態(フィルタリング等) Jotai
ブラウザの online/offline 状態 useSyncExternalStore
window サイズの監視 useSyncExternalStore
大規模アプリ、DevTools 必須 Redux Toolkit
既存 Redux プロジェクトの改善 Redux Toolkit
OOP スタイルの状態管理 MobX
自動追跡による最適化 MobX
軽量な Redux 代替 Zustand

判断基準

  1. まず useState を試す
  • 最もシンプル、ほとんどのケースで十分
  1. 状態が複雑になったら useReducer
  • 複数の関連する状態
  • 状態遷移のパターンが明確
  • テストしやすさが必要
  1. Props drilling が問題なら useContext
  • 変更頻度が低いデータ(テーマ、認証、ロケール)
  • useMemo / useCallback で最適化を忘れずに
  1. パフォーマンスが重要なら Zustand / Jotai
  • セレクター / アトム単位で再レンダーを最適化
  • Provider 不要で使いやすい
  1. 大規模アプリや厳密な状態管理なら Redux Toolkit
  • DevTools、時間旅行デバッグ
  • RTK Query でデータフェッチも統合
  1. OOP スタイルや自動追跡なら MobX
  • クラスベースの設計に馴染む
  • 細かい購読設定不要で自動最適化
  1. 外部ストアとの連携は useSyncExternalStore
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?