10日で覚えるReduxDay 1: なぜReduxが必要なのか

Day 1: なぜReduxが必要なのか

今日学ぶこと

  • Reactにおける状態管理の課題を理解する
  • Prop Drilling問題とその影響を把握する
  • Context APIの利点と限界を知る
  • Reduxが解決する問題を理解する
  • Reduxの3つの原則を学ぶ
  • Redux Toolkitがなぜ現在の標準なのかを知る

Reactの状態管理における課題

Reactアプリケーションを開発していると、最初は useState だけで十分に感じます。しかし、アプリケーションが成長するにつれて、状態管理は急速に複雑になります。

useStateの散在

小さなアプリケーションでは、各コンポーネントが独自の状態を持つことは自然です。

function ProductPage() {
  const [product, setProduct] = useState(null);
  const [cart, setCart] = useState([]);
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [theme, setTheme] = useState('light');
  const [language, setLanguage] = useState('ja');

  // これらの状態を子コンポーネントに渡す必要がある...
}
TypeScript版
interface Product {
  id: string;
  name: string;
  price: number;
}

interface CartItem {
  product: Product;
  quantity: number;
}

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

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'warning' | 'error';
}

function ProductPage() {
  const [product, setProduct] = useState<Product | null>(null);
  const [cart, setCart] = useState<CartItem[]>([]);
  const [user, setUser] = useState<User | null>(null);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [language, setLanguage] = useState<string>('ja');
}

問題は、これらの状態が複数のコンポーネントで必要になったときに始まります。


Prop Drilling問題

Prop Drillingとは、中間のコンポーネントが使わないpropsを、深い階層の子コンポーネントに渡すためだけにバケツリレーする現象です。

具体例: ECサイトのコンポーネントツリー

以下のようなECサイトを考えてみましょう。ユーザー情報とカート情報を複数の場所で表示する必要があります。

flowchart TB
    App["App<br/>(user, cart)"]
    App --> Header["Header<br/>(user, cart)"]
    App --> Main["Main<br/>(user, cart)"]
    App --> Footer["Footer"]

    Header --> Logo["Logo"]
    Header --> Nav["Navigation<br/>(user, cart)"]
    Nav --> UserMenu["UserMenu<br/>(user)"]
    Nav --> CartIcon["CartIcon<br/>(cart)"]

    Main --> ProductList["ProductList<br/>(cart)"]
    Main --> Sidebar["Sidebar<br/>(user)"]
    ProductList --> ProductCard["ProductCard<br/>(cart)"]
    ProductCard --> AddToCartBtn["AddToCartButton<br/>(cart)"]

    Sidebar --> UserProfile["UserProfile<br/>(user)"]
    Sidebar --> Recommendations["Recommendations<br/>(user)"]

    style App fill:#ef4444,color:#fff
    style Header fill:#f59e0b,color:#fff
    style Nav fill:#f59e0b,color:#fff
    style Main fill:#f59e0b,color:#fff
    style ProductList fill:#f59e0b,color:#fff
    style ProductCard fill:#f59e0b,color:#fff
    style UserMenu fill:#22c55e,color:#fff
    style CartIcon fill:#22c55e,color:#fff
    style AddToCartBtn fill:#22c55e,color:#fff
    style UserProfile fill:#22c55e,color:#fff
    style Recommendations fill:#22c55e,color:#fff

緑色のコンポーネントが実際にデータを使うコンポーネントです。黄色のコンポーネントは、自身ではデータを使わないのに、子コンポーネントに渡すためだけにpropsを受け取っています。

コードで見るProp Drilling

// Level 0: App - 状態の定義元
function App() {
  const [user, setUser] = useState({ name: '田中太郎', email: 'tanaka@example.com' });
  const [cart, setCart] = useState([]);

  const addToCart = (product) => {
    setCart(prev => [...prev, product]);
  };

  return (
    <div>
      <Header user={user} cart={cart} />
      <Main user={user} cart={cart} addToCart={addToCart} />
      <Footer />
    </div>
  );
}

// Level 1: Header - userとcartを使わず、ただ渡すだけ
function Header({ user, cart }) {
  return (
    <header>
      <Logo />
      <Navigation user={user} cart={cart} />
    </header>
  );
}

// Level 2: Navigation - userとcartを使わず、ただ渡すだけ
function Navigation({ user, cart }) {
  return (
    <nav>
      <UserMenu user={user} />
      <CartIcon cart={cart} />
    </nav>
  );
}

// Level 3: UserMenu - ようやくuserを使う!
function UserMenu({ user }) {
  return <span>こんにちは、{user.name}さん</span>;
}

// Level 3: CartIcon - ようやくcartを使う!
function CartIcon({ cart }) {
  return <span>カート ({cart.length})</span>;
}

// Level 1: Main - user, cart, addToCartを使わず、ただ渡すだけ
function Main({ user, cart, addToCart }) {
  return (
    <main>
      <ProductList cart={cart} addToCart={addToCart} />
      <Sidebar user={user} />
    </main>
  );
}

// Level 2: ProductList - cartとaddToCartを使わず、ただ渡すだけ
function ProductList({ cart, addToCart }) {
  const products = [{ id: 1, name: 'Tシャツ', price: 2000 }];
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p} cart={cart} addToCart={addToCart} />
      ))}
    </div>
  );
}

// Level 3: ProductCard - addToCartを使わず、ただ渡すだけ
function ProductCard({ product, cart, addToCart }) {
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <div>
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <AddToCartButton product={product} addToCart={addToCart} isInCart={isInCart} />
    </div>
  );
}

// Level 4: AddToCartButton - ようやくaddToCartを使う!
function AddToCartButton({ product, addToCart, isInCart }) {
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'カートに入っています' : 'カートに追加'}
    </button>
  );
}
TypeScript版
interface User {
  name: string;
  email: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

function App() {
  const [user, setUser] = useState<User>({ name: '田中太郎', email: 'tanaka@example.com' });
  const [cart, setCart] = useState<Product[]>([]);

  const addToCart = (product: Product): void => {
    setCart(prev => [...prev, product]);
  };

  return (
    <div>
      <Header user={user} cart={cart} />
      <Main user={user} cart={cart} addToCart={addToCart} />
      <Footer />
    </div>
  );
}

function Header({ user, cart }: { user: User; cart: Product[] }) {
  return (
    <header>
      <Logo />
      <Navigation user={user} cart={cart} />
    </header>
  );
}

function Navigation({ user, cart }: { user: User; cart: Product[] }) {
  return (
    <nav>
      <UserMenu user={user} />
      <CartIcon cart={cart} />
    </nav>
  );
}

function UserMenu({ user }: { user: User }) {
  return <span>こんにちは、{user.name}さん</span>;
}

function CartIcon({ cart }: { cart: Product[] }) {
  return <span>カート ({cart.length})</span>;
}

function Main({ user, cart, addToCart }: { user: User; cart: Product[]; addToCart: (p: Product) => void }) {
  return (
    <main>
      <ProductList cart={cart} addToCart={addToCart} />
      <Sidebar user={user} />
    </main>
  );
}

function ProductList({ cart, addToCart }: { cart: Product[]; addToCart: (p: Product) => void }) {
  const products: Product[] = [{ id: 1, name: 'Tシャツ', price: 2000 }];
  return (
    <div>
      {products.map(p => (
        <ProductCard key={p.id} product={p} cart={cart} addToCart={addToCart} />
      ))}
    </div>
  );
}

function ProductCard({ product, cart, addToCart }: { product: Product; cart: Product[]; addToCart: (p: Product) => void }) {
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <div>
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <AddToCartButton product={product} addToCart={addToCart} isInCart={isInCart} />
    </div>
  );
}

function AddToCartButton({ product, addToCart, isInCart }: { product: Product; addToCart: (p: Product) => void; isInCart: boolean }) {
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'カートに入っています' : 'カートに追加'}
    </button>
  );
}

Prop Drillingの問題点

  1. 可読性の低下: 中間コンポーネントに不要なpropsが大量に並ぶ
  2. 保守性の低下: 状態の型や構造を変更すると、途中のすべてのコンポーネントを修正する必要がある
  3. リファクタリングの困難: コンポーネントの階層を変更するとpropsの流れも壊れる
  4. テストの複雑化: 中間コンポーネントのテストでも不要なpropsをモックする必要がある

Context APIという部分的解決策

React 16.3で導入されたContext APIは、Prop Drillingを解決するためのReact組み込みの仕組みです。

import { createContext, useContext, useState } from 'react';

// Contextの作成
const UserContext = createContext(null);
const CartContext = createContext(null);

// Provider
function App() {
  const [user, setUser] = useState({ name: '田中太郎' });
  const [cart, setCart] = useState([]);
  const addToCart = (product) => setCart(prev => [...prev, product]);

  return (
    <UserContext.Provider value={user}>
      <CartContext.Provider value={{ cart, addToCart }}>
        <Header />
        <Main />
        <Footer />
      </CartContext.Provider>
    </UserContext.Provider>
  );
}

// どの階層からでも直接アクセスできる
function UserMenu() {
  const user = useContext(UserContext);
  return <span>こんにちは、{user.name}さん</span>;
}

function CartIcon() {
  const { cart } = useContext(CartContext);
  return <span>カート ({cart.length})</span>;
}

function AddToCartButton({ product }) {
  const { cart, addToCart } = useContext(CartContext);
  const isInCart = cart.some(item => item.id === product.id);
  return (
    <button onClick={() => addToCart(product)} disabled={isInCart}>
      {isInCart ? 'カートに入っています' : 'カートに追加'}
    </button>
  );
}

Context APIの限界

Context APIはProp Drillingを解決しますが、いくつかの重大な制限があります。

1. 再レンダリング問題

Contextの値が変わると、そのContextを購読しているすべてのコンポーネントが再レンダリングされます。

// この例では、cartが変更されると、CartIconもAddToCartButtonも
// すべて再レンダリングされる
const CartContext = createContext(null);

function CartProvider({ children }) {
  const [cart, setCart] = useState([]);
  const [totalPrice, setTotalPrice] = useState(0);

  // cartかtotalPriceのどちらかが変わるだけで、
  // CartContextを使うすべてのコンポーネントが再レンダリングされる
  return (
    <CartContext.Provider value={{ cart, totalPrice, setCart, setTotalPrice }}>
      {children}
    </CartContext.Provider>
  );
}

2. 複数Contextのネスト地獄

アプリケーションの状態が増えると、Providerが深くネストします。

function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <NotificationProvider>
            <LanguageProvider>
              <ModalProvider>
                <Content />
              </ModalProvider>
            </LanguageProvider>
          </NotificationProvider>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

3. デバッグの困難さ

Context APIには、状態変更の履歴を追跡する仕組みがありません。「いつ、なぜ状態が変わったのか」を特定するのが困難です。

4. ミドルウェアの欠如

APIコール、ログ記録、状態の永続化など、横断的な処理を差し込む仕組みがありません。


Reduxが解決する問題

Reduxは、上記のすべての問題に対する体系的な解決策を提供します。

Reduxの主な利点

flowchart LR
    subgraph Problems["課題"]
        P1["Prop Drilling"]
        P2["再レンダリング"]
        P3["デバッグ困難"]
        P4["非同期処理"]
    end

    subgraph Solutions["Reduxの解決策"]
        S1["グローバルStore"]
        S2["セレクタによる最適化"]
        S3["DevTools"]
        S4["ミドルウェア"]
    end

    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4

    style Problems fill:#ef4444,color:#fff
    style Solutions fill:#22c55e,color:#fff
  1. 単一のデータソース(Single Source of Truth): アプリケーション全体の状態が1つのStoreに集約される
  2. 予測可能な状態更新: 状態の変更は必ずActionとReducerを通る
  3. 強力なDevTools: 状態変更の履歴、タイムトラベルデバッグ、状態のエクスポート/インポート
  4. ミドルウェア: API通信、ログ記録、状態の永続化などを宣言的に組み込める
  5. パフォーマンス最適化: useSelector による細粒度の購読で不要な再レンダリングを防止

Reduxの3つの原則

Reduxは3つのシンプルな原則に基づいて設計されています。

原則1: Single Source of Truth(単一のデータソース)

アプリケーション全体の状態は、1つのStoreオブジェクトツリーに保存されます。

// アプリケーション全体の状態が1つのオブジェクトに集約される
const store = {
  user: {
    name: '田中太郎',
    email: 'tanaka@example.com',
    isLoggedIn: true
  },
  cart: {
    items: [
      { id: 1, name: 'Tシャツ', price: 2000, quantity: 1 }
    ],
    totalPrice: 2000
  },
  notifications: [
    { id: 1, message: '注文が確定しました', type: 'success' }
  ],
  ui: {
    theme: 'light',
    language: 'ja',
    sidebarOpen: false
  }
};

原則2: State is Read-Only(状態は読み取り専用)

状態を変更する唯一の方法は、Actionを発行(dispatch)することです。直接状態を書き換えることはできません。

// NG: 状態を直接変更
store.cart.items.push(newItem);

// OK: Actionをdispatchする
store.dispatch({
  type: 'cart/addItem',
  payload: { id: 2, name: 'パーカー', price: 5000 }
});

原則3: Changes are Made with Pure Functions(純粋関数による変更)

状態の変更は、純粋関数であるReducerによって行われます。Reducerは現在の状態とActionを受け取り、新しい状態を返します。

// Reducerは純粋関数
// 同じ入力には必ず同じ出力を返す
function cartReducer(state = { items: [], totalPrice: 0 }, action) {
  switch (action.type) {
    case 'cart/addItem':
      const newItems = [...state.items, action.payload];
      const newTotal = newItems.reduce((sum, item) => sum + item.price, 0);
      return {
        ...state,
        items: newItems,
        totalPrice: newTotal
      };
    case 'cart/removeItem':
      const filteredItems = state.items.filter(item => item.id !== action.payload);
      const filteredTotal = filteredItems.reduce((sum, item) => sum + item.price, 0);
      return {
        ...state,
        items: filteredItems,
        totalPrice: filteredTotal
      };
    default:
      return state;
  }
}

Reduxのデータフロー

Reduxのデータは常に一方向に流れます。この予測可能なフローが、デバッグと理解を容易にします。

flowchart LR
    UI["UI<br/>ユーザー操作"]
    Action["Action<br/>{type, payload}"]
    Dispatch["Dispatch<br/>store.dispatch()"]
    Middleware["Middleware<br/>(thunk, logger等)"]
    Reducer["Reducer<br/>純粋関数"]
    Store["Store<br/>新しい状態"]
    Render["再レンダリング<br/>UIを更新"]

    UI -->|"イベント発生"| Action
    Action -->|"送信"| Dispatch
    Dispatch -->|"通過"| Middleware
    Middleware -->|"処理後"| Reducer
    Reducer -->|"新しい状態を返す"| Store
    Store -->|"変更を通知"| Render
    Render -->|"表示"| UI

    style UI fill:#3b82f6,color:#fff
    style Action fill:#f59e0b,color:#fff
    style Dispatch fill:#f59e0b,color:#fff
    style Middleware fill:#8b5cf6,color:#fff
    style Reducer fill:#22c55e,color:#fff
    style Store fill:#22c55e,color:#fff
    style Render fill:#3b82f6,color:#fff
  1. ユーザーがUIで操作を行う(ボタンクリックなど)
  2. Actionオブジェクトが作成される({ type: 'cart/addItem', payload: item }
  3. dispatch() でActionがStoreに送信される
  4. ミドルウェアがActionを処理(ログ記録、API通信など)
  5. ReducerがActionに基づいて新しい状態を計算する
  6. Storeが新しい状態で更新される
  7. UIが新しい状態を反映して再レンダリングされる

レガシーReduxとRedux Toolkit

レガシーReduxの問題点

Reduxが登場した2015年当初は、大量のボイラープレートコードが必要でした。

// --- legacy Redux ---

// Action Types (定数定義)
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';

// Action Creators
function addTodo(text) {
  return {
    type: ADD_TODO,
    payload: {
      id: Date.now(),
      text,
      completed: false
    }
  };
}

function toggleTodo(id) {
  return {
    type: TOGGLE_TODO,
    payload: id
  };
}

function deleteTodo(id) {
  return {
    type: DELETE_TODO,
    payload: id
  };
}

// Reducer (イミュータブルな更新を手動で行う)
function todosReducer(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [...state, action.payload];
    case TOGGLE_TODO:
      return state.map(todo =>
        todo.id === action.payload
          ? { ...todo, completed: !todo.completed }
          : todo
      );
    case DELETE_TODO:
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

// Store (手動でミドルウェアを組み合わせる)
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const rootReducer = combineReducers({
  todos: todosReducer,
});

const store = createStore(rootReducer, applyMiddleware(thunk));

Redux Toolkit (RTK) — 現在の標準

Redux Toolkitは2019年にリリースされ、Redux公式が推奨する標準的なアプローチです。上記のコードがこれだけシンプルになります。

// --- Redux Toolkit ---
import { createSlice, configureStore } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo(state, action) {
      // Immerにより「ミュータブル風」に書ける(実際はイミュータブル)
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo(state, action) {
      const todo = state.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action) {
      return state.filter(todo => todo.id !== action.payload);
    }
  }
});

// Action Creatorsが自動生成される
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;

// Storeの作成(DevToolsとthunkが自動設定される)
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  }
});
TypeScript版
import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    addTodo(state, action: PayloadAction<string>) {
      state.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo(state, action: PayloadAction<number>) {
      const todo = state.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action: PayloadAction<number>) {
      return state.filter(todo => todo.id !== action.payload);
    }
  }
});

export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;

const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  }
});

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

状態管理手法の比較

特徴 useState / props Context API Redux (RTK)
学習コスト 低い 低い 中程度
セットアップ 不要 少ない やや多い
Prop Drilling解消 不可 可能 可能
パフォーマンス最適化 手動(memo等) 困難 useSelector で自動
デバッグツール React DevTools React DevTools Redux DevTools
ミドルウェア なし なし あり(thunk等)
タイムトラベルデバッグ 不可 不可 可能
非同期処理 useEffect useEffect createAsyncThunk
状態の永続化 手動 手動 redux-persist
テスト容易性 コンポーネント依存 コンポーネント依存 ロジックを分離可能
適したアプリ規模 小規模 小〜中規模 中〜大規模

いつReduxを使うべきか

Reduxが適しているケース

  • 複数のコンポーネントが同じ状態を参照・更新する: ユーザー認証、ショッピングカートなど
  • 状態の更新ロジックが複雑: 条件分岐が多い、計算が必要など
  • 状態変更の追跡が必要: デバッグやログ記録が重要な業務アプリ
  • サーバーデータのキャッシュ管理: RTK Queryとの組み合わせ
  • チーム開発: 状態管理のパターンを統一できる

Reduxが不要なケース

  • シンプルなアプリ: フォーム1つ、ページ数が少ない
  • サーバー状態の管理だけが目的: TanStack Query(React Query)で十分
  • ローカル状態のみ: useStateやuseReducerで対応可能
  • プロトタイプ・MVP: 速度優先の場合

まとめ

概念 説明
Prop Drilling 中間コンポーネントが不要なpropsを渡し続ける問題
Context API Prop Drillingの解決策だが、再レンダリングやデバッグに課題
Redux 予測可能な状態管理ライブラリ、単一Store・読み取り専用状態・純粋Reducer
Redux Toolkit (RTK) Redux公式推奨のツールキット、ボイラープレートを大幅削減
Single Source of Truth アプリ全体の状態を1つのStoreで管理する原則
一方向データフロー Action → Dispatch → Reducer → Store → UI の流れ

今日学んだのは「なぜReduxが必要なのか」という動機の部分です。Reactの組み込み機能だけでは、中〜大規模アプリの状態管理が困難になること、そしてReduxがその課題をどのように解決するのかを理解しました。

明日のDay 2では、Redux Toolkitを使って実際にコードを書き始めます。


練習問題

問題1: Prop Drillingの特定

以下のコンポーネント構造で、Prop Drillingが発生している箇所を特定してください。

App (theme, user)
  └── Dashboard (theme, user)
        ├── DashboardHeader (theme)
        │     └── ThemeToggle (theme)
        └── DashboardContent (user)
              └── UserCard (user)

質問: Dashboard コンポーネントは themeuser を自身で使用していません。このProp Drillingを解消するために、Context APIを使ってリファクタリングしてください。

問題2: 状態管理手法の選択

以下のシナリオについて、最適な状態管理手法(useState / Context API / Redux)を選び、理由を説明してください。

  1. ログインフォームの入力値管理
  2. 10ページ以上で使われるユーザー認証情報
  3. リアルタイムチャットアプリのメッセージ一覧
  4. モーダルの開閉状態
  5. ECサイトのショッピングカート(商品追加・削除・数量変更・合計計算)

問題3: Reduxの3原則

Reduxの3つの原則を自分の言葉で説明し、それぞれがなぜ重要なのかを述べてください。