JavaScriptの状態管理

2025-07-28

はじめに

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

状態管理の基本

基本的な定義

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

状態(State)とは、アプリケーション内で変化するデータのことを指します。具体的には、ユーザーのログイン状態やプロフィール情報などのユーザー情報、テーマや言語設定といったアプリケーションの設定、入力フォームに入力された内容、APIから取得したデータ、さらにモーダルの開閉や選択中のタブといったUIの状態などが挙げられます。これらの状態を適切に管理することで、アプリの動作や表示をユーザー操作に応じて動的に変化させることができます。

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

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

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

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

適切な状態管理を行うことで、アプリケーションの動作が安定し、データの整合性が保たれます。また、変更が起きた部分だけを効率的に更新できるためパフォーマンスが向上し、コードの見通しも良くなります。その結果、保守性が高くバグの少ないアプリを構築しやすくなります。

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

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

コンポーネント内部でのみ管理される状態で、useStateなどを使って定義します。小規模な状態管理に適しており、外部から直接参照できないためカプセル化が保たれます。

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>
  );
}

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

複数の子コンポーネントで同じ状態を共有したい場合、その状態を共通の親コンポーネントに移動し、propsを通じて子に渡す設計手法です。Reactの基本的な状態共有の方法です。

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

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

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

グローバルに状態を共有する仕組みで、Contextを作成してProviderを通じて下層のコンポーネントにデータを渡せます。propsのバケツリレーを回避でき、小中規模のアプリに向いています。

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();
  // ...
}

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

Redux

状態を一元的に管理する仕組みで、アプリ全体の状態を「ストア」に集約します。状態の変更は必ずアクションとリデューサーを通じて行われ、予測可能でデバッグしやすい構造を実現します。

特徴

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

基本概念

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

サンプルコード

まず、ADD_TODOというアクションタイプを定義し、addTodo関数で新しいToDoを追加するアクションを生成します。initialStateで初期状態(空のToDoリスト)を定義し、todoAppリデューサーではアクションの種類に応じて状態を更新します。

createStoreでストアを作成し、store.dispatch(addTodo('Reduxを学ぶ'))でToDoを追加すると、ストアの状態が更新されます。最後にstore.getState()で現在の状態を取得して確認できます。Reduxの基本構造(アクション → リデューサー → ストア)を理解するのに適したシンプルな例です。

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());

Zustand

軽量な状態管理ライブラリで、シンプルなAPIとHooksベースの設計が特徴です。
ボイラープレートが少なく、Reduxよりも直感的に使えるため、中小規模のプロジェクトに人気があります。

特徴

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

サンプルコード

useStoreは、create関数を使って状態と更新関数を定義しています。bears(クマの数)を状態として持ち、increasePopulationで数を1増やし、removeAllBearsでリセットします。コンポーネントBearCounterでは、useStoreを使って状態bearsと関数increasePopulationを取得し、ボタンをクリックするたびにクマの数が増える仕組みになっています。

Reduxよりもシンプルに記述でき、ReactのuseStateのような直感的な書き方でグローバル状態を管理できるのがZustandの特徴です。

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専用の状態管理ライブラリで、状態を「Atom」として定義し、必要なコンポーネントから直接利用できます。依存関係や派生状態も簡単に扱え、大規模アプリでも柔軟に対応できます。

特徴

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

サンプルコード

atomは状態の基本単位で、ここではtextStateとして文字列入力の値を保持しています。selectorは派生状態を定義するもので、charCountStatetextStateを参照して文字数を計算し返します。

TextInputコンポーネントでは、useRecoilStatetextStateを読み書きし、useRecoilValuecharCountStateの値(文字数)を取得しています。入力欄に文字を入力すると即座に文字数が更新される仕組みで、Recoilの依存関係の自動更新と宣言的な状態管理の特徴をよく示しています。

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>
  );
}

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

状態管理のベストプラクティスは、状態を最小限に保ち、必要な場所だけで管理することです。
重複した状態を持たず、共通で使うデータはグローバルに、コンポーネント固有のものはローカルに管理します。また、副作用(API通信など)と状態を分離し、データの流れを一方向に保つことで、バグを防ぎ保守性を高められます。

状態の設計原則

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

パフォーマンス最適化

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

デバッグと開発者体験

  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>
  );
}