Day 10: 実践パターン
今日学ぶこと
- 認証パターン(ログインフロー、トークン管理、保護されたルート)
- 機能ベースのプロジェクト構造
- Redux DevToolsの活用
- React ContextからReduxへの移行ガイド
- Reduxを使うべきでないケース
- コード分割と
combineSlices - エラーバウンダリとの統合
- 10日間の総まとめ
認証パターン
ほぼすべてのWebアプリケーションに必要な認証フローをReduxで実装するパターンです。
authSliceの実装
// features/auth/authSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const login = createAsyncThunk(
'auth/login',
async ({ email, password }, { rejectWithValue }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
return rejectWithValue(error.message);
}
const data = await response.json();
// Store token in localStorage
localStorage.setItem('token', data.token);
return data;
} catch (error) {
return rejectWithValue('Network error');
}
}
);
export const logout = createAsyncThunk(
'auth/logout',
async () => {
localStorage.removeItem('token');
return null;
}
);
export const checkAuth = createAsyncThunk(
'auth/checkAuth',
async (_, { rejectWithValue }) => {
const token = localStorage.getItem('token');
if (!token) {
return rejectWithValue('No token');
}
try {
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
localStorage.removeItem('token');
return rejectWithValue('Invalid token');
}
return await response.json();
} catch (error) {
return rejectWithValue('Network error');
}
}
);
const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
token: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
reducers: {
clearError(state) {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Login
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
})
// Logout
.addCase(logout.fulfilled, (state) => {
state.user = null;
state.token = null;
state.status = 'idle';
})
// Check Auth
.addCase(checkAuth.fulfilled, (state, action) => {
state.user = action.payload;
state.status = 'succeeded';
})
.addCase(checkAuth.rejected, (state) => {
state.user = null;
state.token = null;
state.status = 'idle';
});
},
});
export const { clearError } = authSlice.actions;
export default authSlice.reducer;
TypeScript版
// features/auth/authSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
email: string;
name: string;
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
interface LoginCredentials {
email: string;
password: string;
}
interface LoginResponse {
user: User;
token: string;
}
export const login = createAsyncThunk<
LoginResponse,
LoginCredentials,
{ rejectValue: string }
>(
'auth/login',
async ({ email, password }, { rejectWithValue }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
return rejectWithValue(error.message);
}
const data: LoginResponse = await response.json();
localStorage.setItem('token', data.token);
return data;
} catch {
return rejectWithValue('Network error');
}
}
);
const initialState: AuthState = {
user: null,
token: null,
status: 'idle',
error: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
clearError(state) {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? 'Unknown error';
});
},
});
export const { clearError } = authSlice.actions;
export default authSlice.reducer;
保護されたルート
// components/ProtectedRoute.jsx
import React from 'react';
import { useSelector } from 'react-redux';
import { Navigate, useLocation } from 'react-router-dom';
function ProtectedRoute({ children }) {
const { user, status } = useSelector((state) => state.auth);
const location = useLocation();
if (status === 'loading') {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default ProtectedRoute;
// App.jsx - ルーティング設定
import { Routes, Route } from 'react-router-dom';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import Dashboard from './pages/Dashboard';
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
);
}
機能ベースのプロジェクト構造
大規模なReduxアプリケーションでは、機能(feature)ごとにファイルを整理することで、保守性と可読性が大幅に向上します。
graph TB
subgraph Project["推奨プロジェクト構造"]
SRC["src/"]
APP["app/<br/>store.js, hooks.js"]
FEAT["features/"]
AUTH["auth/<br/>authSlice.js<br/>authApi.js<br/>LoginForm.jsx<br/>AuthGuard.jsx"]
POSTS["posts/<br/>postsSlice.js<br/>postsApi.js<br/>PostList.jsx<br/>PostDetail.jsx"]
USERS["users/<br/>usersSlice.js<br/>usersApi.js<br/>UserProfile.jsx"]
COMP["components/<br/>Header.jsx<br/>Layout.jsx<br/>Button.jsx"]
end
SRC --> APP
SRC --> FEAT
SRC --> COMP
FEAT --> AUTH
FEAT --> POSTS
FEAT --> USERS
style Project fill:transparent,stroke:#666
style APP fill:#3b82f6,color:#fff
style AUTH fill:#8b5cf6,color:#fff
style POSTS fill:#8b5cf6,color:#fff
style USERS fill:#8b5cf6,color:#fff
style COMP fill:#22c55e,color:#fff
ディレクトリ構造の詳細
src/
├── app/
│ ├── store.js # Store configuration
│ ├── hooks.js # Typed hooks (useAppDispatch, useAppSelector)
│ └── rootReducer.js # Combine all reducers
├── features/
│ ├── auth/
│ │ ├── authSlice.js
│ │ ├── authApi.js # RTK Query or async thunks
│ │ ├── LoginForm.jsx
│ │ ├── AuthGuard.jsx
│ │ └── auth.test.js
│ ├── posts/
│ │ ├── postsSlice.js
│ │ ├── postsApi.js
│ │ ├── PostList.jsx
│ │ ├── PostDetail.jsx
│ │ └── posts.test.js
│ └── users/
│ ├── usersSlice.js
│ ├── usersApi.js
│ ├── UserProfile.jsx
│ └── users.test.js
├── components/ # Shared/presentational components
│ ├── Header.jsx
│ ├── Layout.jsx
│ └── Button.jsx
├── pages/ # Route-level components
│ ├── HomePage.jsx
│ ├── LoginPage.jsx
│ └── DashboardPage.jsx
└── utils/ # Shared utilities
├── test-utils.js
└── api.js
この構造のメリット:
- 関連するコードが同じディレクトリにまとまる
- 新しい機能の追加が容易(新しいディレクトリを作るだけ)
- テストファイルが対象コードの近くにある
- 削除も簡単(ディレクトリごと削除)
Redux DevTools
Redux DevToolsは、Redux開発において最も強力なデバッグツールです。
主要機能
1. Action Log(アクションログ)
dispatchされたすべてのactionを時系列で確認できます。各actionのtype、payload、そして結果のstateを確認できます。
2. State Diff(状態差分)
各actionがstateのどの部分を変更したかをdiffとして表示します。意図しない変更を素早く発見できます。
3. Time Travel(タイムトラベル)
過去の任意の時点にstateを巻き戻したり、特定のactionをスキップしたりできます。バグの原因を特定するのに非常に有効です。
4. State Import/Export
現在のstateをJSONとしてエクスポートし、後でインポートできます。バグレポートに状態を添付する場合に便利です。
DevToolsの設定
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {
// reducers...
},
// DevTools is enabled by default in development
devTools: process.env.NODE_ENV !== 'production',
});
Note:
configureStoreを使っていれば、DevToolsは開発環境で自動的に有効になります。追加の設定は不要です。
DevToolsのカスタマイズ
const store = configureStore({
reducer: rootReducer,
devTools: {
name: 'My App',
trace: true, // Action dispatch stack trace
traceLimit: 25, // Stack trace depth limit
maxAge: 50, // Max stored actions
},
});
React ContextからReduxへの移行
既存のContext APIを使ったアプリケーションをReduxに移行するステップバイステップガイドです。
ステップ1: 現状を把握する
// Before: Context-based state management
const AuthContext = React.createContext();
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const login = async (email, password) => {
setLoading(true);
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
setUser(data.user);
} finally {
setLoading(false);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('token');
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
ステップ2: Sliceを作成する
// After: Redux slice (same logic, different structure)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const loginUser = createAsyncThunk(
'auth/login',
async ({ email, password }) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return await res.json();
}
);
const authSlice = createSlice({
name: 'auth',
initialState: { user: null, loading: false },
reducers: {
logoutUser(state) {
state.user = null;
localStorage.removeItem('token');
},
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.loading = true;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload.user;
})
.addCase(loginUser.rejected, (state) => {
state.loading = false;
});
},
});
ステップ3: コンポーネントを段階的に移行
// Transitional: Support both Context and Redux
function useAuth() {
// Phase 1: Still using Context
// return useContext(AuthContext);
// Phase 2: Using Redux
const dispatch = useDispatch();
const { user, loading } = useSelector((state) => state.auth);
return {
user,
loading,
login: (email, password) => dispatch(loginUser({ email, password })),
logout: () => dispatch(logoutUser()),
};
}
移行のポイント:
- カスタムフックで抽象化しておくと、内部実装の切り替えが容易
- 一度にすべて移行する必要はない — 機能ごとに段階的に移行
- テストを先に書いて、移行後も同じ振る舞いを保証する
Reduxを使うべきでないケース
Reduxはすべてのstateに適しているわけではありません。以下の判断基準を参考にしてください。
flowchart TD
START["State管理が必要"] --> Q1{"複数のコンポーネントから<br/>アクセスが必要?"}
Q1 -- "No" --> LOCAL["ローカルState<br/>useState / useReducer"]
Q1 -- "Yes" --> Q2{"サーバーデータ?"}
Q2 -- "Yes" --> Q3{"キャッシュ・再検証<br/>が重要?"}
Q3 -- "Yes" --> SERVER["サーバーState<br/>RTK Query / React Query<br/>/ SWR"]
Q3 -- "No" --> REDUX["Redux"]
Q2 -- "No" --> Q4{"URLに保存すべき?"}
Q4 -- "Yes" --> URL["URL State<br/>React Router<br/>/ searchParams"]
Q4 -- "No" --> Q5{"複雑なロジック<br/>or アクション履歴?"}
Q5 -- "Yes" --> REDUX
Q5 -- "No" --> CONTEXT["React Context"]
style LOCAL fill:#22c55e,color:#fff
style SERVER fill:#3b82f6,color:#fff
style URL fill:#f59e0b,color:#fff
style REDUX fill:#8b5cf6,color:#fff
style CONTEXT fill:#ef4444,color:#fff
Stateの種類と推奨ツール
| State種類 | 例 | 推奨ツール |
|---|---|---|
| ローカルUI | モーダル開閉、入力フォーム | useState |
| フォーム | バリデーション、入力値 | React Hook Form, Formik |
| サーバーデータ | API応答、キャッシュ | RTK Query, React Query |
| URL | ページ、フィルタ、検索 | React Router |
| グローバルUI | テーマ、言語 | Context API |
| ビジネスロジック | 認証、カート、複雑なフロー | Redux |
コード分割とcombineSlices
大規模アプリケーションでは、すべてのReducerを初期ロードに含めると、バンドルサイズが大きくなります。RTK 2.0のcombineSlicesを使えば、遅延ロードが可能です。
combineSlicesの基本
// app/rootReducer.js
import { combineSlices } from '@reduxjs/toolkit';
import { authSlice } from '../features/auth/authSlice';
// Start with core slices that are always needed
const rootReducer = combineSlices(authSlice);
export default rootReducer;
遅延ロードされるスライス
// features/admin/adminSlice.js
import { createSlice } from '@reduxjs/toolkit';
import rootReducer from '../../app/rootReducer';
const adminSlice = createSlice({
name: 'admin',
initialState: { users: [], stats: null },
reducers: {
setUsers(state, action) {
state.users = action.payload;
},
},
});
// Inject this slice when the admin feature is loaded
const injectedReducer = rootReducer.inject(adminSlice);
export const { setUsers } = adminSlice.actions;
export default adminSlice.reducer;
React.lazyとの組み合わせ
// App.jsx
import React, { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Admin page is lazy-loaded; its slice gets injected when loaded
const AdminPage = lazy(() => import('./pages/AdminPage'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
TypeScript版
// app/rootReducer.ts
import { combineSlices } from '@reduxjs/toolkit';
import { authSlice } from '../features/auth/authSlice';
const rootReducer = combineSlices(authSlice);
// Declare lazy-loaded slices for type safety
declare module './rootReducer' {
export interface LazyLoadedSlices {
admin: AdminState;
}
}
export type RootState = ReturnType<typeof rootReducer>;
export default rootReducer;
エラーバウンダリとの統合
Reduxのエラーステートをエラーバウンダリと統合するパターンです。
// components/ReduxErrorBoundary.jsx
import React, { Component } from 'react';
import { connect } from 'react-redux';
class ReduxErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasRenderError: false };
}
static getDerivedStateFromError(error) {
return { hasRenderError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Render error:', error, errorInfo);
}
render() {
// Catch render errors
if (this.state.hasRenderError) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasRenderError: false })}>
Try Again
</button>
</div>
);
}
// Catch Redux state errors
if (this.props.globalError) {
return (
<div role="alert">
<h2>Application Error</h2>
<p>{this.props.globalError}</p>
<button onClick={this.props.clearError}>
Dismiss
</button>
</div>
);
}
return this.props.children;
}
}
const mapStateToProps = (state) => ({
globalError: state.app?.globalError,
});
const mapDispatchToProps = (dispatch) => ({
clearError: () => dispatch({ type: 'app/clearGlobalError' }),
});
export default connect(mapStateToProps, mapDispatchToProps)(ReduxErrorBoundary);
グローバルエラーハンドリングミドルウェア
// app/errorMiddleware.js
const errorMiddleware = (store) => (next) => (action) => {
// Catch all rejected async thunks
if (action.type.endsWith('/rejected')) {
const status = action.payload?.status;
if (status === 401) {
// Auto logout on unauthorized
store.dispatch({ type: 'auth/logout' });
}
if (status === 500) {
store.dispatch({
type: 'app/setGlobalError',
payload: 'A server error occurred. Please try again later.',
});
}
}
return next(action);
};
export default errorMiddleware;
10日間の学習まとめ
この10日間で学んだことを振り返りましょう。
| Day | テーマ | 主なトピック |
|---|---|---|
| 1 | State管理の課題 | Props drilling、なぜReduxが必要か |
| 2 | Reduxの3原則 | Single source of truth、Immutability、Pure functions |
| 3 | Redux Toolkit入門 | configureStore、createSlice、Immer |
| 4 | React-Reduxの連携 | useSelector、useDispatch、Provider |
| 5 | 非同期処理 | createAsyncThunk、loading/error state |
| 6 | RTK Query | createApi、キャッシュ、自動再取得 |
| 7 | セレクタとパフォーマンス | createSelector、メモ化、re-render最適化 |
| 8 | ミドルウェア | Listener Middleware、カスタムミドルウェア |
| 9 | テスト | 統合テスト、MSW、renderWithProviders |
| 10 | 実践パターン | 認証、プロジェクト構造、移行、判断基準 |
次のステップ
この書籍で基礎を学んだ後、さらに深く学ぶためのリソースを紹介します。
公式ドキュメント
- Redux公式ドキュメント — 最も信頼できるリソース
- Redux Toolkit公式ドキュメント — API リファレンスと使い方ガイド
- RTK Query概要 — データフェッチングの詳細
発展的なトピック
- Redux Saga — より複雑な非同期フローの管理(Listener Middlewareでは不十分な場合)
- Normalized State —
createEntityAdapterを使ったエンティティ管理 - Server-Side Rendering — Next.jsやRemixでのRedux統合
- State Machines — XStateとReduxの組み合わせ
- Offline First — Redux Persistを使ったオフライン対応
実践プロジェクトのアイデア
| プロジェクト | 学べるスキル |
|---|---|
| Todoアプリ(フル機能版) | CRUD、フィルタ、永続化 |
| ブログプラットフォーム | RTK Query、認証、ページネーション |
| Eコマースサイト | カート管理、チェックアウトフロー |
| チャットアプリ | WebSocket統合、リアルタイム更新 |
| ダッシュボード | データ可視化、複数のAPIソース |
まとめ
| パターン | 使用場面 | 主なツール |
|---|---|---|
| 認証フロー | ログイン/ログアウト | createAsyncThunk + ProtectedRoute |
| 機能ベース構造 | 中〜大規模アプリ | features/ディレクトリ構造 |
| コード分割 | 大規模アプリ | combineSlices + React.lazy |
| エラーハンドリング | 全アプリ | Error Boundary + middleware |
| DevTools | 開発時 | Redux DevTools Extension |
最終的な心構え:
- Reduxは道具であり目的ではない — 問題に対して適切なツールを選ぶ
- 小さく始める — 必要になるまでReduxを導入しない
- RTKを使う — 素のReduxを書く理由はほぼない
- テストを書く — 統合テストが最もコストパフォーマンスが良い
- 公式ドキュメントを読む — 最も正確で最新の情報源
練習問題
問題1: 認証フローの実装
以下の機能を持つ認証システムを実装してください:
- メールアドレスとパスワードでのログイン
- ログイン状態の永続化(ページリロード後も維持)
- 未認証ユーザーのリダイレクト
- ログアウト機能
問題2: プロジェクト構造の設計
以下の機能を持つEコマースアプリのディレクトリ構造を設計してください:
- ユーザー認証
- 商品一覧・検索
- ショッピングカート
- 注文管理
- ユーザープロフィール
問題3: State管理の判断
以下の各stateについて、最適な管理方法(useState、Context、Redux、React Query等)を選び、理由を説明してください:
- サイドバーの開閉状態
- 現在のユーザー情報
- 商品一覧データ
- ショッピングカートの中身
- フォームの入力値
- ダークモード設定
問題4: 移行計画の作成
React ContextとuseReducerで構築された既存のTodoアプリを、Redux Toolkitに移行する計画を作成してください。移行中もアプリが正常に動作するよう、段階的な移行手順を考えてください。