JavaScriptの状態管理

2025-07-28

はじめに

現代のフロントエンド開発において、状態管理は最も重要な概念の1つです。この章では、JavaScriptアプリケーションにおける状態管理の基本概念から、具体的な実装方法、主要なライブラリまで、初心者の方にもわかりやすく詳細に解説します。状態管理を適切に行うことで、アプリケーションの保守性や拡張性が大幅に向上します。

状態管理とは?

基本的な定義

状態管理(State Management)とは、アプリケーションが持つデータ(状態)をどのように保持し、更新し、コンポーネント間で共有するかを体系化する方法論です。

状態(State)の具体例

  • ユーザー情報(ログイン状態、プロフィールデータ)
  • アプリケーションの設定(テーマ、言語設定)
  • フォームの入力内容
  • APIから取得したデータ
  • UIの状態(モーダルの開閉、選択中のタブ)

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

単純なアプローチの問題点

小さなアプリケーションでは、コンポーネントのローカル状態(useStateなど)で十分かもしれません。しかし、アプリケーションが成長するにつれて以下の問題が発生します:

  1. プロップドリリング: 深い階層のコンポーネントに状態を渡すため、中間コンポーネントが不必要なpropsを受け取る
  2. 状態の同期問題: 同じ状態を複数のコンポーネントで共有・更新する場合の整合性維持
  3. 再利用性の低下: 状態とロジックがコンポーネントに密結合している
  4. テストの難しさ: 状態が分散しているとテストが書きにくい

適切な状態管理のメリット

  1. コードの見通しが良くなる: 状態の流れが明確
  2. デバッグが容易: 状態の変化を追跡しやすい
  3. コンポーネントの再利用性向上: 状態とUIの分離
  4. パフォーマンス最適化: 不必要な再レンダリングの防止
  5. チーム開発の効率化: 一貫したアーキテクチャ

状態管理の基本パターン

1. ローカルステート(コンポーネント内状態)

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0); // ローカルステート

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

2. リフトアップ(状態の共通の親コンポーネントへの移動)

function ParentComponent() {
  const [sharedState, setSharedState] = useState('');

  return (
    <>
      <ChildA state={sharedState} setState={setSharedState} />
      <ChildB state={sharedState} setState={setSharedState} />
    </>
  );
}

3. コンテキストAPI(Reactの場合)

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

const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

function useAppContext() {
  return useContext(AppContext);
}

// 使用例
function UserProfile() {
  const { user } = useAppContext();
  // ...
}

主要な状態管理ライブラリ

1. Redux

特徴

  • 単一の信頼できる情報源(Single Source of Truth)
  • 状態は読み取り専用(Immutable)
  • 変更は純粋関数(Reducer)を通じてのみ
  • ミドルウェアで機能拡張可能

基本概念

  • Store: アプリケーションの全状態を保持
  • Action: 状態変更を指示するオブジェクト
  • Reducer: 現在の状態とActionから新しい状態を返す純粋関数
  • Dispatch: ActionをStoreに送信する方法

サンプルコード

import { createStore } from 'redux';

// アクションタイプ
const ADD_TODO = 'ADD_TODO';

// アクションクリエーター
function addTodo(text) {
  return { type: ADD_TODO, text };
}

// 初期状態
const initialState = {
  todos: []
};

// リデューサー
function todoApp(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, { text: action.text, completed: false }]
      };
    default:
      return state;
  }
}

// ストア作成
const store = createStore(todoApp);

// 使用例
store.dispatch(addTodo('Reduxを学ぶ'));
console.log(store.getState());

2. Zustand

特徴

  • シンプルで軽量
  • Reduxのようなボイラープレートが不要
  • Reactとの統合が容易
  • ミドルウェアサポート

サンプルコード

import create from 'zustand';

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

// 使用例
function BearCounter() {
  const bears = useStore((state) => state.bears);
  const increase = useStore((state) => state.increasePopulation);
  return ;
}

3. Recoil (Facebook製)

特徴

  • Reactのための状態管理ライブラリ
  • アトミックな状態管理
  • 非同期データの扱いが容易
  • 学習曲線が緩やか

サンプルコード

import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

const textState = atom({
  key: 'textState', // 一意のID
  default: '', // 初期値
});

const charCountState = selector({
  key: 'charCountState',
  get: ({get}) => {
    const text = get(textState);
    return text.length;
  },
});

function TextInput() {
  const [text, setText] = useRecoilState(textState);
  const count = useRecoilValue(charCountState);

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <p>文字数: {count}</p>
    </div>
  );
}

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

1. 状態の設計原則

  1. 単一責任の原則: 1つの状態は1つのことを管理
  2. 最小限の状態: 派生可能な状態は保存しない
  3. 適切なスコープ: 状態の共有範囲を適切に決定
  4. 正規化: 関連データはIDで参照

2. パフォーマンス最適化

  1. メモ化: React.memo, useMemo, useCallback
  2. 選択的サブスクリプション: 必要な状態のみ購読
  3. バッチ更新: 複数の状態更新をまとめる
  4. 遅延ロード: 初期化コストの高い状態は必要時まで読み込まない

3. デバッグと開発者体験

  1. 状態のシリアライズ: JSON化可能な状態設計
  2. タイムトラベルデバッグ: Redux DevToolsなどの利用
  3. ロギング: 状態変更の記録
  4. 型安全性: TypeScriptの導入

状態管理の選択基準

プロジェクトに適した状態管理ソリューションを選ぶ際の考慮点:

  1. アプリケーションの規模: 小規模ならContext API、大規模ならRedux
  2. チームの習熟度: 学習コストと生産性のバランス
  3. 非同期処理の量: Reduxはミドルウェアで強力
  4. Reactのバージョン: 新しい機能(Server Componentsなど)との互換性
  5. パフォーマンス要件: 最適化の必要性

状態管理の未来

  1. React Server Components: サーバーサイドでの状態管理
  2. 原子状態管理: RecoilやJotaiのようなアプローチ
  3. コンパイラ支援状態管理: よりスマートな最適化
  4. Isomorphic状態管理: サーバー/クライアント間のシームレスな共有

まとめ

状態管理の重要なポイント:

  • 状態管理はアプリケーションの信頼性と保守性に直結
  • 適切な状態設計が開発体験を大きく改善
  • プロジェクト規模に応じたソリューション選択が重要
  • Redux、Context API、Zustand、Recoilなど様々な選択肢
  • 状態の正規化と最小化がパフォーマンス向上の鍵
  • デバッグツールの活用で開発効率向上

状態管理をマスターすることで、複雑なアプリケーションも自信を持って構築できるようになります。

練習問題

問題1

以下のコードには状態管理上の問題点があります。それを指摘し、改善案を提案してください。

function UserProfile() {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    fetchUser().then(data => {
      setUser(data);
      setIsLoading(false);
    });
  }, []);

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <div>
          <h1>{user.name}</h1>
          <UserDetails user={user} />
          <UserPreferences user={user} />
        </div>
      )}
    </div>
  );
}

function UserDetails({ user }) {
  const [details, setDetails] = useState(null);

  useEffect(() => {
    fetchUserDetails(user.id).then(setDetails);
  }, [user.id]);

  return <div>{/* ユーザー詳細表示 */}</div>;
}

問題2

次の状態管理に関する記述について、正しいものには○、間違っているものには×をつけてください。

  1. すべての状態はグローバルに管理すべきである ( )
  2. Reduxでは状態を直接変更してはいけない ( )
  3. Context APIは頻繁に変更される状態には向いていない ( )
  4. Zustandはクラスコンポーネント専用の状態管理ライブラリである ( )

問題3

以下の要件を満たすシンプルなカウンターアプリを、Zustandを使用して実装してください。

  • カウント値を保持
  • インクリメント(+1)機能
  • デクリメント(-1)機能
  • リセット機能
  • 現在のカウント値を2倍にした値を取得する派生状態

解答例

問題1の解答

問題点:

  1. 同じユーザーデータを複数のコンポーネントで個別に取得(UserProfileUserDetails
  2. ユーザー状態がコンポーネントに閉じているため共有できない
  3. ローディング状態が分散している

改善案:

  1. ユーザー状態をContextや状態管理ライブラリで共有
  2. ユーザー詳細もメインのfetchで取得し、propsで渡す
  3. データ取得ロジックをカスタムフックに分離
// 改善例(Context API使用)
const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    Promise.all([
      fetchUser(),
      fetchUserDetails()
    ]).then(([userData, details]) => {
      setUser({ ...userData, details });
      setIsLoading(false);
    });
  }, []);

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

function useUser() {
  return useContext(UserContext);
}

function UserProfile() {
  const { user, isLoading } = useUser();

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <UserDetails details={user.details} />
      <UserPreferences user={user} />
    </div>
  );
}

問題2の解答

  1. × (必要な範囲でのみ状態を共有)
  2. ○ (パフォーマンスの問題が発生する可能性)
  3. × (関数コンポーネントでも使用可能)

問題3の解答

import create from 'zustand';

const useCounterStore = create((set, get) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  getDoubleCount: () => get().count * 2
}));

function Counter() {
  const { count, increment, decrement, reset, getDoubleCount } = useCounterStore();

  return (
    <div>
      <h1>Count: {count}</h1>
      <p>Double: {getDoubleCount()}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}