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の問題点
- 可読性の低下: 中間コンポーネントに不要なpropsが大量に並ぶ
- 保守性の低下: 状態の型や構造を変更すると、途中のすべてのコンポーネントを修正する必要がある
- リファクタリングの困難: コンポーネントの階層を変更するとpropsの流れも壊れる
- テストの複雑化: 中間コンポーネントのテストでも不要な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
- 単一のデータソース(Single Source of Truth): アプリケーション全体の状態が1つのStoreに集約される
- 予測可能な状態更新: 状態の変更は必ずActionとReducerを通る
- 強力なDevTools: 状態変更の履歴、タイムトラベルデバッグ、状態のエクスポート/インポート
- ミドルウェア: API通信、ログ記録、状態の永続化などを宣言的に組み込める
- パフォーマンス最適化:
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
- ユーザーがUIで操作を行う(ボタンクリックなど)
- Actionオブジェクトが作成される(
{ type: 'cart/addItem', payload: item }) dispatch()でActionがStoreに送信される- ミドルウェアがActionを処理(ログ記録、API通信など)
- ReducerがActionに基づいて新しい状態を計算する
- Storeが新しい状態で更新される
- 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 コンポーネントは theme と user を自身で使用していません。このProp Drillingを解消するために、Context APIを使ってリファクタリングしてください。
問題2: 状態管理手法の選択
以下のシナリオについて、最適な状態管理手法(useState / Context API / Redux)を選び、理由を説明してください。
- ログインフォームの入力値管理
- 10ページ以上で使われるユーザー認証情報
- リアルタイムチャットアプリのメッセージ一覧
- モーダルの開閉状態
- ECサイトのショッピングカート(商品追加・削除・数量変更・合計計算)
問題3: Reduxの3原則
Reduxの3つの原則を自分の言葉で説明し、それぞれがなぜ重要なのかを述べてください。