10日で覚えるReduxDay 10: 実践パターン
books.chapter 1010日で覚えるRedux

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

移行のポイント:

  1. カスタムフックで抽象化しておくと、内部実装の切り替えが容易
  2. 一度にすべて移行する必要はない — 機能ごとに段階的に移行
  3. テストを先に書いて、移行後も同じ振る舞いを保証する

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 実践パターン 認証、プロジェクト構造、移行、判断基準

次のステップ

この書籍で基礎を学んだ後、さらに深く学ぶためのリソースを紹介します。

公式ドキュメント

発展的なトピック

  1. Redux Saga — より複雑な非同期フローの管理(Listener Middlewareでは不十分な場合)
  2. Normalized State — createEntityAdapterを使ったエンティティ管理
  3. Server-Side Rendering — Next.jsやRemixでのRedux統合
  4. State Machines — XStateとReduxの組み合わせ
  5. Offline First — Redux Persistを使ったオフライン対応

実践プロジェクトのアイデア

プロジェクト 学べるスキル
Todoアプリ(フル機能版) CRUD、フィルタ、永続化
ブログプラットフォーム RTK Query、認証、ページネーション
Eコマースサイト カート管理、チェックアウトフロー
チャットアプリ WebSocket統合、リアルタイム更新
ダッシュボード データ可視化、複数のAPIソース

まとめ

パターン 使用場面 主なツール
認証フロー ログイン/ログアウト createAsyncThunk + ProtectedRoute
機能ベース構造 中〜大規模アプリ features/ディレクトリ構造
コード分割 大規模アプリ combineSlices + React.lazy
エラーハンドリング 全アプリ Error Boundary + middleware
DevTools 開発時 Redux DevTools Extension

最終的な心構え:

  1. Reduxは道具であり目的ではない — 問題に対して適切なツールを選ぶ
  2. 小さく始める — 必要になるまでReduxを導入しない
  3. RTKを使う — 素のReduxを書く理由はほぼない
  4. テストを書く — 統合テストが最もコストパフォーマンスが良い
  5. 公式ドキュメントを読む — 最も正確で最新の情報源

練習問題

問題1: 認証フローの実装

以下の機能を持つ認証システムを実装してください:

  • メールアドレスとパスワードでのログイン
  • ログイン状態の永続化(ページリロード後も維持)
  • 未認証ユーザーのリダイレクト
  • ログアウト機能

問題2: プロジェクト構造の設計

以下の機能を持つEコマースアプリのディレクトリ構造を設計してください:

  • ユーザー認証
  • 商品一覧・検索
  • ショッピングカート
  • 注文管理
  • ユーザープロフィール

問題3: State管理の判断

以下の各stateについて、最適な管理方法(useState、Context、Redux、React Query等)を選び、理由を説明してください:

  • サイドバーの開閉状態
  • 現在のユーザー情報
  • 商品一覧データ
  • ショッピングカートの中身
  • フォームの入力値
  • ダークモード設定

問題4: 移行計画の作成

React ContextとuseReducerで構築された既存のTodoアプリを、Redux Toolkitに移行する計画を作成してください。移行中もアプリが正常に動作するよう、段階的な移行手順を考えてください。