architecture

フロントエンド状態管理パターン - Redux・Zustand・Jotai・Recoilの設計思想

2025.12.02

状態管理の必要性

フロントエンドアプリケーションが複雑化するにつれ、コンポーネント間での状態共有が課題となります。適切な状態管理パターンの選択は、アプリケーションの保守性とスケーラビリティに直結します。

flowchart TB
    subgraph PropDrilling["Prop Drilling (プロップの受け渡し地獄)"]
        App["App<br/>state: { user, theme, cart }"]
        Layout["Layout<br/>← props: user, theme"]
        Header["Header<br/>← props: user, theme"]
        Avatar["Avatar<br/>← props: user (やっと使う)"]

        App --> Layout --> Header --> Avatar
    end

    Note["問題: 中間コンポーネントが不要なpropsを受け渡すだけ"]

状態管理アーキテクチャの分類

flowchart TB
    subgraph Patterns["状態管理パターンの分類"]
        subgraph Flux["1. Flux/Redux パターン"]
            F1["単一Store + Action + Reducer<br/>Redux, Zustand"]
        end

        subgraph Atomic["2. Atomic パターン"]
            A1["分散した小さな状態単位(Atom)<br/>Jotai, Recoil"]
        end

        subgraph Proxy["3. Proxy-based パターン"]
            P1["Proxyによる自動追跡<br/>Valtio, MobX"]
        end

        subgraph Signal["4. Signal パターン"]
            S1["細粒度リアクティビティ<br/>Solid.js Signals, Preact Signals"]
        end
    end

Flux/Reduxパターン

基本概念

flowchart LR
    View["View"] -->|Action| Dispatcher["Dispatcher"]
    Dispatcher -->|dispatch| Store["Store<br/>(Reducer)"]
    Store -->|notify| State["State<br/>(読み取り専用)"]
    State --> View

原則:

  • 単方向データフロー
  • 状態は読み取り専用(Immutable)
  • 変更はActionを通じてのみ
  • Reducerは純粋関数

Redux実装

// store/userSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserState {
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
}

const initialState: UserState = {
  currentUser: null,
  isLoading: false,
  error: null,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    loginStart: (state) => {
      state.isLoading = true;
      state.error = null;
    },
    loginSuccess: (state, action: PayloadAction<User>) => {
      state.currentUser = action.payload;
      state.isLoading = false;
    },
    loginFailure: (state, action: PayloadAction<string>) => {
      state.error = action.payload;
      state.isLoading = false;
    },
    logout: (state) => {
      state.currentUser = null;
    },
  },
});

export const { loginStart, loginSuccess, loginFailure, logout } = userSlice.actions;
export default userSlice.reducer;

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// hooks/useAppDispatch.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from '../store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

// components/UserProfile.tsx
function UserProfile() {
  const { currentUser, isLoading, error } = useAppSelector((state) => state.user);
  const dispatch = useAppDispatch();

  const handleLogout = () => {
    dispatch(logout());
  };

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!currentUser) return <div>Please login</div>;

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
}

Zustand実装

// store/useStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserStore {
  // State
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;

  // Actions
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => void;
}

export const useUserStore = create<UserStore>()(
  devtools(
    persist(
      immer((set, get) => ({
        currentUser: null,
        isLoading: false,
        error: null,

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

          try {
            const response = await fetch('/api/auth/login', {
              method: 'POST',
              body: JSON.stringify({ email, password }),
            });
            const user = await response.json();

            set((state) => {
              state.currentUser = user;
              state.isLoading = false;
            });
          } catch (error) {
            set((state) => {
              state.error = (error as Error).message;
              state.isLoading = false;
            });
          }
        },

        logout: () => {
          set((state) => {
            state.currentUser = null;
          });
        },

        updateProfile: (updates) => {
          set((state) => {
            if (state.currentUser) {
              Object.assign(state.currentUser, updates);
            }
          });
        },
      })),
      { name: 'user-storage' }
    ),
    { name: 'UserStore' }
  )
);

// Selector(パフォーマンス最適化)
export const useCurrentUser = () => useUserStore((state) => state.currentUser);
export const useIsLoading = () => useUserStore((state) => state.isLoading);

// components/UserProfile.tsx
function UserProfile() {
  const currentUser = useCurrentUser();
  const logout = useUserStore((state) => state.logout);

  if (!currentUser) return null;

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Atomicパターン

Jotai実装

// atoms/userAtoms.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

interface User {
  id: string;
  name: string;
  email: string;
}

// 基本のAtom
export const currentUserAtom = atom<User | null>(null);

// 永続化されたAtom
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');

// 派生Atom(読み取り専用)
export const isLoggedInAtom = atom((get) => get(currentUserAtom) !== null);

// 派生Atom(読み書き可能)
export const userNameAtom = atom(
  (get) => get(currentUserAtom)?.name ?? 'Guest',
  (get, set, newName: string) => {
    const user = get(currentUserAtom);
    if (user) {
      set(currentUserAtom, { ...user, name: newName });
    }
  }
);

// 非同期Atom
export const userProfileAtom = atom(async (get) => {
  const user = get(currentUserAtom);
  if (!user) return null;

  const response = await fetch(`/api/users/${user.id}/profile`);
  return response.json();
});

// アクションAtom
export const loginAtom = atom(
  null,
  async (get, set, { email, password }: { email: string; password: string }) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const user = await response.json();
    set(currentUserAtom, user);
  }
);

export const logoutAtom = atom(null, (get, set) => {
  set(currentUserAtom, null);
});

// components/UserProfile.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';

function UserProfile() {
  const currentUser = useAtomValue(currentUserAtom);
  const [userName, setUserName] = useAtom(userNameAtom);
  const logout = useSetAtom(logoutAtom);

  if (!currentUser) return null;

  return (
    <div>
      <input
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />
      <button onClick={logout}>Logout</button>
    </div>
  );
}

Recoil実装

// atoms/userState.ts
import { atom, selector, selectorFamily } from 'recoil';

interface User {
  id: string;
  name: string;
  email: string;
}

// Atom
export const currentUserState = atom<User | null>({
  key: 'currentUser',
  default: null,
});

// Selector(派生状態)
export const isLoggedInState = selector({
  key: 'isLoggedIn',
  get: ({ get }) => get(currentUserState) !== null,
});

// 非同期Selector
export const userProfileState = selector({
  key: 'userProfile',
  get: async ({ get }) => {
    const user = get(currentUserState);
    if (!user) return null;

    const response = await fetch(`/api/users/${user.id}/profile`);
    return response.json();
  },
});

// パラメータ付きSelector(SelectorFamily)
export const userPostsState = selectorFamily({
  key: 'userPosts',
  get: (userId: string) => async () => {
    const response = await fetch(`/api/users/${userId}/posts`);
    return response.json();
  },
});

// components/UserProfile.tsx
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';

function UserProfile() {
  const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
  const isLoggedIn = useRecoilValue(isLoggedInState);
  const profile = useRecoilValue(userProfileState);

  const logout = () => setCurrentUser(null);

  if (!isLoggedIn) return <div>Please login</div>;

  return (
    <div>
      <h1>{currentUser?.name}</h1>
      <p>{profile?.bio}</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}
flowchart TB
    subgraph FluxPattern["Flux (Redux/Zustand)"]
        Store["Single Store<br/>user | cart | ui | ..."]
        Store --> CA["Component A"]
        Store --> CB["Component B"]
        Store --> CC["Component C"]
    end

    subgraph AtomicPattern["Atomic (Jotai/Recoil)"]
        A1["Atom"] --> CompA["Component A"]
        A2["Atom"] --> Derived["Derived Atom"]
        A3["Atom"] --> Derived
        Derived --> CompB["Component B"]
        A4["Atom"] --> CompC["Component C"]
    end

特徴:

  • Flux: 中央集権的、予測可能、デバッグしやすい
  • Atomic: 分散的、細粒度、コード分割に適する

Proxy-basedパターン

Valtio実装

// store/userStore.ts
import { proxy, useSnapshot } from 'valtio';
import { devtools } from 'valtio/utils';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserStore {
  currentUser: User | null;
  isLoading: boolean;
  error: string | null;
}

export const userStore = proxy<UserStore>({
  currentUser: null,
  isLoading: false,
  error: null,
});

// DevTools有効化
devtools(userStore, { name: 'userStore' });

// アクション(直接変更可能)
export const userActions = {
  async login(email: string, password: string) {
    userStore.isLoading = true;
    userStore.error = null;

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      userStore.currentUser = await response.json();
    } catch (error) {
      userStore.error = error.message;
    } finally {
      userStore.isLoading = false;
    }
  },

  logout() {
    userStore.currentUser = null;
  },

  updateName(name: string) {
    if (userStore.currentUser) {
      userStore.currentUser.name = name;  // 直接変更可能
    }
  },
};

// components/UserProfile.tsx
function UserProfile() {
  // useSnapshotで読み取り専用のスナップショットを取得
  const snap = useSnapshot(userStore);

  if (snap.isLoading) return <div>Loading...</div>;
  if (!snap.currentUser) return null;

  return (
    <div>
      <input
        value={snap.currentUser.name}
        onChange={(e) => userActions.updateName(e.target.value)}
      />
      <button onClick={userActions.logout}>Logout</button>
    </div>
  );
}

状態管理ライブラリ比較

特徴Redux ToolkitZustandJotaiRecoilValtio
バンドルサイズ
学習コスト
ボイラープレート
DevTools充実ありありありあり
TypeScript良好優秀優秀良好優秀
SSR対応良好良好優秀複雑良好
React外利用可能可能不可不可可能

選択基準

状態管理ライブラリ選択ガイド

プロジェクト規模・チーム:

規模推奨理由
大規模・多人数Redux Toolkit明確な規約、豊富なエコシステム
中規模・少人数Zustandシンプル、学習コスト低い
小規模・プロトタイプJotai/Valtio最小限のセットアップ

アーキテクチャ要件:

要件推奨
予測可能性重視Redux/Zustand
細粒度の更新Jotai/Recoil
ミュータブルな操作Valtio
Server ComponentsJotai (最適)

ベストプラクティス

// 1. 状態の正規化
interface NormalizedState {
  users: {
    byId: Record<string, User>;
    allIds: string[];
  };
  posts: {
    byId: Record<string, Post>;
    allIds: string[];
  };
}

// 2. 派生状態の活用
const selectUserPosts = (state: RootState, userId: string) =>
  state.posts.allIds
    .map(id => state.posts.byId[id])
    .filter(post => post.authorId === userId);

// 3. 非同期処理の分離
// サーバー状態は TanStack Query / SWR
// クライアント状態は Zustand / Jotai

// 4. 適切な粒度での分割
// NG: 巨大な単一ストア
// OK: 機能ごとに分割されたストア

参考リンク

← 一覧に戻る