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 StatecreateEntityAdapterを使ったエンティティ管理
  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に移行する計画を作成してください。移行中もアプリが正常に動作するよう、段階的な移行手順を考えてください。