状態管理の必要性
フロントエンドアプリケーションが複雑化するにつれ、コンポーネント間での状態共有が課題となります。適切な状態管理パターンの選択は、アプリケーションの保守性とスケーラビリティに直結します。
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 Toolkit | Zustand | Jotai | Recoil | Valtio |
|---|---|---|---|---|---|
| バンドルサイズ | 大 | 小 | 小 | 中 | 小 |
| 学習コスト | 高 | 低 | 低 | 中 | 低 |
| ボイラープレート | 多 | 少 | 少 | 中 | 少 |
| DevTools | 充実 | あり | あり | あり | あり |
| TypeScript | 良好 | 優秀 | 優秀 | 良好 | 優秀 |
| SSR対応 | 良好 | 良好 | 優秀 | 複雑 | 良好 |
| React外利用 | 可能 | 可能 | 不可 | 不可 | 可能 |
選択基準
状態管理ライブラリ選択ガイド
プロジェクト規模・チーム:
| 規模 | 推奨 | 理由 |
|---|---|---|
| 大規模・多人数 | Redux Toolkit | 明確な規約、豊富なエコシステム |
| 中規模・少人数 | Zustand | シンプル、学習コスト低い |
| 小規模・プロトタイプ | Jotai/Valtio | 最小限のセットアップ |
アーキテクチャ要件:
| 要件 | 推奨 |
|---|---|
| 予測可能性重視 | Redux/Zustand |
| 細粒度の更新 | Jotai/Recoil |
| ミュータブルな操作 | Valtio |
| Server Components | Jotai (最適) |
ベストプラクティス
// 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: 機能ごとに分割されたストア