Day 2: Redux Toolkitの基本
今日学ぶこと
- Redux ToolkitとReact-Reduxのインストール方法
configureStoreでStoreを作成するcreateSliceでstate、reducers、actionsを定義する- カウンターアプリをステップバイステップで構築する
- ToDoリストアプリを構築する
Provider、useSelector、useDispatchの使い方- Immerによるイミュータブル更新の仕組み
- レガシーReduxとRTKのコード比較
インストール
Redux Toolkitを使うには、2つのパッケージが必要です。
npm install @reduxjs/toolkit react-redux
| パッケージ | 役割 |
|---|---|
@reduxjs/toolkit |
Redux本体 + 便利なユーティリティ(createSlice, configureStore等) |
react-redux |
ReactとReduxを接続するバインディング(Provider, useSelector, useDispatch等) |
configureStore: Storeの作成
configureStore は、Redux Storeを作成するための関数です。レガシーReduxの createStore に代わるもので、以下が自動的に設定されます。
- Redux DevTools Extension との接続
- redux-thunk ミドルウェア(非同期処理用)
- 開発時のチェック(状態の直接変更検出、シリアライズ不可能な値の検出)
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {
// ここにsliceのreducerを登録する
}
});
export default store;
TypeScript版
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {
// ここにsliceのreducerを登録する
}
});
// Store自体から型を推論する
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
createSlice: スライスの定義
createSlice はRedux Toolkitの中核となるAPIです。1つの関数呼び出しで以下をすべて定義できます。
- 初期状態 (initialState)
- Reducer関数 (状態の更新ロジック)
- Action Creator (自動生成)
- Action Type (自動生成)
flowchart TB
subgraph createSlice["createSlice()"]
Name["name: 'counter'"]
Initial["initialState: { value: 0 }"]
Reducers["reducers: { increment, decrement, ... }"]
end
createSlice --> Actions["自動生成されるActions<br/>counter/increment<br/>counter/decrement"]
createSlice --> Reducer["エクスポートされるReducer<br/>counterSlice.reducer"]
Actions --> Dispatch["dispatch()で使用"]
Reducer --> Store["configureStoreに登録"]
style createSlice fill:#3b82f6,color:#fff
style Actions fill:#f59e0b,color:#fff
style Reducer fill:#22c55e,color:#fff
実践1: カウンターアプリ
最もシンプルなReduxアプリケーションとして、カウンターを構築しましょう。
Step 1: Sliceの作成
// src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0
},
reducers: {
increment(state) {
// Immerにより、直接「変更」するように書ける
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
reset(state) {
state.value = 0;
}
}
});
// Action Creatorsをエクスポート
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Reducerをエクスポート
export default counterSlice.reducer;
TypeScript版
// src/features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0
};
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
reset(state) {
state.value = 0;
}
}
});
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
export default counterSlice.reducer;
Step 2: Storeの設定
// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
const store = configureStore({
reducer: {
counter: counterReducer
}
});
export default store;
TypeScript版
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
const store = configureStore({
reducer: {
counter: counterReducer
}
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Step 3: Providerでアプリをラップ
Provider コンポーネントは、Reactコンポーネントツリー全体にRedux Storeを供給します。
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './app/store';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<App />
</Provider>
);
Step 4: コンポーネントからStoreにアクセス
// src/features/counter/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from './counterSlice';
function Counter() {
// useSelector: Storeから状態を読み取る
const count = useSelector((state) => state.counter.value);
// useDispatch: Actionをdispatchするための関数を取得
const dispatch = useDispatch();
return (
<div>
<h1>カウンター: {count}</h1>
<div>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
<button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
<button onClick={() => dispatch(reset())}>リセット</button>
</div>
</div>
);
}
export default Counter;
TypeScript版
// src/features/counter/Counter.tsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from './counterSlice';
import type { RootState } from '../../app/store';
function Counter() {
const count = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<h1>カウンター: {count}</h1>
<div>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
<button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
<button onClick={() => dispatch(reset())}>リセット</button>
</div>
</div>
);
}
export default Counter;
データフローの確認
カウンターで「+1」ボタンをクリックしたときのフローを確認しましょう。
sequenceDiagram
participant User as ユーザー
participant UI as Counter コンポーネント
participant Dispatch as dispatch()
participant Reducer as counterSlice.reducer
participant Store as Redux Store
User->>UI: 「+1」ボタンをクリック
UI->>Dispatch: dispatch(increment())
Note over Dispatch: Action: { type: 'counter/increment' }
Dispatch->>Reducer: Action を処理
Reducer->>Store: state.value = 0 + 1 = 1
Store->>UI: useSelector が新しい値を受け取る
UI->>User: カウンター: 1 と表示
useSelector の詳細
useSelector はRedux Storeから状態を読み取るためのhookです。
基本的な使い方
// Store全体の状態から必要な部分だけを選択する
const count = useSelector((state) => state.counter.value);
重要なポイント: パフォーマンス最適化
useSelector は参照の等価性でコンポーネントの再レンダリングを判断します。セレクタの戻り値が前回と同じ参照であれば、再レンダリングはスキップされます。
// GOOD: プリミティブ値を返す(値が同じなら再レンダリングされない)
const count = useSelector((state) => state.counter.value);
const userName = useSelector((state) => state.user.name);
// CAUTION: 新しいオブジェクトを返すと毎回再レンダリングされる
// (毎回新しいオブジェクト参照が生まれるため)
const userData = useSelector((state) => ({
name: state.user.name,
email: state.user.email,
}));
// ↑ これは毎回再レンダリングを引き起こす!
// GOOD: 複数の値が必要な場合は個別にセレクタを使う
const name = useSelector((state) => state.user.name);
const email = useSelector((state) => state.user.email);
useDispatch の詳細
useDispatch はActionをdispatchするための関数を返すhookです。
const dispatch = useDispatch();
// Action Creatorの戻り値をdispatchする
dispatch(increment());
// これは以下と同じ意味:
// dispatch({ type: 'counter/increment' })
dispatch(incrementByAmount(5));
// これは以下と同じ意味:
// dispatch({ type: 'counter/incrementByAmount', payload: 5 })
実践2: ToDoリストアプリ
より実践的な例として、ToDoリストアプリを構築しましょう。
Step 1: ToDo Sliceの作成
// src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
let nextId = 1;
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all' // 'all' | 'active' | 'completed'
},
reducers: {
addTodo(state, action) {
state.items.push({
id: nextId++,
text: action.payload,
completed: false,
createdAt: new Date().toISOString()
});
},
toggleTodo(state, action) {
const todo = state.items.find(item => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo(state, action) {
state.items = state.items.filter(item => item.id !== action.payload);
},
editTodo(state, action) {
const { id, text } = action.payload;
const todo = state.items.find(item => item.id === id);
if (todo) {
todo.text = text;
}
},
setFilter(state, action) {
state.filter = action.payload;
},
clearCompleted(state) {
state.items = state.items.filter(item => !item.completed);
},
toggleAll(state) {
const allCompleted = state.items.every(item => item.completed);
state.items.forEach(item => {
item.completed = !allCompleted;
});
}
}
});
export const {
addTodo,
toggleTodo,
deleteTodo,
editTodo,
setFilter,
clearCompleted,
toggleAll
} = todosSlice.actions;
export default todosSlice.reducer;
TypeScript版
// src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: string;
}
type FilterType = 'all' | 'active' | 'completed';
interface TodosState {
items: Todo[];
filter: FilterType;
}
const initialState: TodosState = {
items: [],
filter: 'all'
};
let nextId = 1;
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo(state, action: PayloadAction<string>) {
state.items.push({
id: nextId++,
text: action.payload,
completed: false,
createdAt: new Date().toISOString()
});
},
toggleTodo(state, action: PayloadAction<number>) {
const todo = state.items.find(item => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo(state, action: PayloadAction<number>) {
state.items = state.items.filter(item => item.id !== action.payload);
},
editTodo(state, action: PayloadAction<{ id: number; text: string }>) {
const { id, text } = action.payload;
const todo = state.items.find(item => item.id === id);
if (todo) {
todo.text = text;
}
},
setFilter(state, action: PayloadAction<FilterType>) {
state.filter = action.payload;
},
clearCompleted(state) {
state.items = state.items.filter(item => !item.completed);
},
toggleAll(state) {
const allCompleted = state.items.every(item => item.completed);
state.items.forEach(item => {
item.completed = !allCompleted;
});
}
}
});
export const {
addTodo,
toggleTodo,
deleteTodo,
editTodo,
setFilter,
clearCompleted,
toggleAll
} = todosSlice.actions;
export default todosSlice.reducer;
Step 2: Storeに登録
// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer
}
});
export default store;
TypeScript版
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer
}
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Step 3: ToDo入力コンポーネント
// src/features/todos/AddTodo.js
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addTodo } from './todosSlice';
function AddTodo() {
const [text, setText] = useState('');
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text.trim()));
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="タスクを入力..."
/>
<button type="submit">追加</button>
</form>
);
}
export default AddTodo;
TypeScript版
// src/features/todos/AddTodo.tsx
import { useState, FormEvent } from 'react';
import { useDispatch } from 'react-redux';
import { addTodo } from './todosSlice';
function AddTodo() {
const [text, setText] = useState('');
const dispatch = useDispatch();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text.trim()));
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="タスクを入力..."
/>
<button type="submit">追加</button>
</form>
);
}
export default AddTodo;
Step 4: ToDoリスト表示コンポーネント
// src/features/todos/TodoList.js
import { useSelector, useDispatch } from 'react-redux';
import { toggleTodo, deleteTodo, setFilter, clearCompleted, toggleAll } from './todosSlice';
function TodoList() {
const { items, filter } = useSelector((state) => state.todos);
const dispatch = useDispatch();
// フィルタリング
const filteredItems = items.filter(item => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
const activeCount = items.filter(item => !item.completed).length;
const completedCount = items.filter(item => item.completed).length;
return (
<div>
{/* フィルタボタン */}
<div>
<button
onClick={() => dispatch(setFilter('all'))}
style={{ fontWeight: filter === 'all' ? 'bold' : 'normal' }}
>
すべて ({items.length})
</button>
<button
onClick={() => dispatch(setFilter('active'))}
style={{ fontWeight: filter === 'active' ? 'bold' : 'normal' }}
>
未完了 ({activeCount})
</button>
<button
onClick={() => dispatch(setFilter('completed'))}
style={{ fontWeight: filter === 'completed' ? 'bold' : 'normal' }}
>
完了済み ({completedCount})
</button>
</div>
{/* 一括操作 */}
<div>
<button onClick={() => dispatch(toggleAll())}>すべて切替</button>
<button onClick={() => dispatch(clearCompleted())}>完了済みを削除</button>
</div>
{/* ToDoリスト */}
<ul>
{filteredItems.map(item => (
<li key={item.id}>
<input
type="checkbox"
checked={item.completed}
onChange={() => dispatch(toggleTodo(item.id))}
/>
<span style={{
textDecoration: item.completed ? 'line-through' : 'none',
color: item.completed ? '#999' : '#000'
}}>
{item.text}
</span>
<button onClick={() => dispatch(deleteTodo(item.id))}>削除</button>
</li>
))}
</ul>
{filteredItems.length === 0 && (
<p>タスクがありません</p>
)}
</div>
);
}
export default TodoList;
TypeScript版
// src/features/todos/TodoList.tsx
import { useSelector, useDispatch } from 'react-redux';
import { toggleTodo, deleteTodo, setFilter, clearCompleted, toggleAll } from './todosSlice';
import type { RootState } from '../../app/store';
function TodoList() {
const { items, filter } = useSelector((state: RootState) => state.todos);
const dispatch = useDispatch();
const filteredItems = items.filter(item => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
const activeCount = items.filter(item => !item.completed).length;
const completedCount = items.filter(item => item.completed).length;
return (
<div>
<div>
<button
onClick={() => dispatch(setFilter('all'))}
style={{ fontWeight: filter === 'all' ? 'bold' : 'normal' }}
>
すべて ({items.length})
</button>
<button
onClick={() => dispatch(setFilter('active'))}
style={{ fontWeight: filter === 'active' ? 'bold' : 'normal' }}
>
未完了 ({activeCount})
</button>
<button
onClick={() => dispatch(setFilter('completed'))}
style={{ fontWeight: filter === 'completed' ? 'bold' : 'normal' }}
>
完了済み ({completedCount})
</button>
</div>
<div>
<button onClick={() => dispatch(toggleAll())}>すべて切替</button>
<button onClick={() => dispatch(clearCompleted())}>完了済みを削除</button>
</div>
<ul>
{filteredItems.map(item => (
<li key={item.id}>
<input
type="checkbox"
checked={item.completed}
onChange={() => dispatch(toggleTodo(item.id))}
/>
<span style={{
textDecoration: item.completed ? 'line-through' : 'none',
color: item.completed ? '#999' : '#000'
}}>
{item.text}
</span>
<button onClick={() => dispatch(deleteTodo(item.id))}>削除</button>
</li>
))}
</ul>
{filteredItems.length === 0 && (
<p>タスクがありません</p>
)}
</div>
);
}
export default TodoList;
Step 5: メインアプリコンポーネント
// src/App.js
import AddTodo from './features/todos/AddTodo';
import TodoList from './features/todos/TodoList';
function App() {
return (
<div>
<h1>ToDoリスト</h1>
<AddTodo />
<TodoList />
</div>
);
}
export default App;
Immerの仕組み
Redux Toolkitが内部で使用しているImmerライブラリは、Redux開発体験を劇的に向上させます。
なぜImmerが重要なのか
Reduxでは、状態をイミュータブル(不変)に更新する必要があります。レガシーReduxでは、スプレッド構文を使って手動でイミュータブルな更新を行う必要がありました。
// レガシーRedux: 手動でイミュータブル更新(ネストが深いと地獄)
function reducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
third: {
...state.first.second.third,
value: action.payload
}
}
}
};
}
// Redux Toolkit (Immer): 直感的な「ミュータブル風」記法
function reducer(state, action) {
state.first.second.third.value = action.payload;
}
Immerの動作原理
flowchart LR
Original["元の状態<br/>(Frozen Object)"]
Draft["Draft(下書き)<br/>(Proxy Object)"]
New["新しい状態<br/>(New Frozen Object)"]
Original -->|"Immerがプロキシを作成"| Draft
Draft -->|"変更を記録"| Draft
Draft -->|"変更を適用して<br/>新しいオブジェクトを生成"| New
style Original fill:#3b82f6,color:#fff
style Draft fill:#f59e0b,color:#fff
style New fill:#22c55e,color:#fff
- Immerは元の状態のProxy(Draft)を作成する
- あなたのReducerコードはDraftに対して「変更」を行う
- Immerは行われた変更を追跡し、変更された部分だけを含む新しいオブジェクトを生成する
- 元の状態は一切変更されない
Immerの注意点
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// OK: stateを直接変更する(Immerが処理する)
addTodo(state, action) {
state.push(action.payload);
},
// OK: 新しい値を返す(通常のReducerと同じ)
clearAll() {
return [];
},
// NG: stateを変更しつつ、新しい値も返す(エラーになる)
// badReducer(state, action) {
// state.push(action.payload);
// return state; // これはNG!
// }
}
});
ルール: Reducerの中では「stateを変更する」か「新しい値を返す」かのどちらか一方だけを行ってください。両方同時に行うとエラーになります。
PayloadAction型
TypeScriptを使う場合、PayloadAction 型でActionのpayloadに型を付けることができます。
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UserState {
name: string;
email: string;
age: number;
}
const userSlice = createSlice({
name: 'user',
initialState: { name: '', email: '', age: 0 } as UserState,
reducers: {
// PayloadAction<T> でpayloadの型を指定
setName(state, action: PayloadAction<string>) {
state.name = action.payload; // action.payload は string 型
},
setEmail(state, action: PayloadAction<string>) {
state.email = action.payload;
},
setAge(state, action: PayloadAction<number>) {
state.age = action.payload; // action.payload は number 型
},
updateProfile(state, action: PayloadAction<Partial<UserState>>) {
// action.payload は { name?: string, email?: string, age?: number }
Object.assign(state, action.payload);
}
}
});
PayloadAction を使うことで、dispatch時にも正しい型が強制されます。
dispatch(setName('田中太郎')); // OK
dispatch(setName(123)); // TypeScriptエラー!
dispatch(setAge(25)); // OK
dispatch(setAge('twenty-five')); // TypeScriptエラー!
レガシーRedux vs Redux Toolkit 比較
同じToDoアプリを、レガシーReduxとRTKで比較してみましょう。
レガシーRedux
// actionTypes.js
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const DELETE_TODO = 'DELETE_TODO';
export const SET_FILTER = 'SET_FILTER';
// actionCreators.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER } from './actionTypes';
let nextId = 1;
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { id: nextId++, text, completed: false }
});
export const toggleTodo = (id) => ({ type: TOGGLE_TODO, payload: id });
export const deleteTodo = (id) => ({ type: DELETE_TODO, payload: id });
export const setFilter = (filter) => ({ type: SET_FILTER, payload: filter });
// reducer.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER } from './actionTypes';
const initialState = { items: [], filter: 'all' };
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return { ...state, items: [...state.items, action.payload] };
case TOGGLE_TODO:
return {
...state,
items: state.items.map(item =>
item.id === action.payload
? { ...item, completed: !item.completed }
: item
)
};
case DELETE_TODO:
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case SET_FILTER:
return { ...state, filter: action.payload };
default:
return state;
}
}
// store.js
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import todosReducer from './reducer';
export default createStore(
combineReducers({ todos: todosReducer }),
applyMiddleware(thunk)
);
ファイル数: 4ファイル、約60行
Redux Toolkit
// todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
let nextId = 1;
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
addTodo(state, action) {
state.items.push({ id: nextId++, text: action.payload, completed: false });
},
toggleTodo(state, action) {
const todo = state.items.find(item => item.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
deleteTodo(state, action) {
state.items = state.items.filter(item => item.id !== action.payload);
},
setFilter(state, action) {
state.filter = action.payload;
}
}
});
export const { addTodo, toggleTodo, deleteTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;
// store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';
export default configureStore({
reducer: { todos: todosReducer }
});
ファイル数: 2ファイル、約30行
比較まとめ
| 観点 | レガシーRedux | Redux Toolkit |
|---|---|---|
| ファイル数 | 4ファイル | 2ファイル |
| コード量 | 約60行 | 約30行 |
| Action Types | 手動で定数定義 | 自動生成 |
| Action Creators | 手動で関数定義 | 自動生成 |
| イミュータブル更新 | スプレッド構文(手動) | Immer(自動) |
| ミドルウェア設定 | applyMiddleware(手動) | 自動設定 |
| DevTools | 別途設定が必要 | 自動設定 |
| 型安全性 | 手動で型定義 | PayloadAction で簡単 |
まとめ
| 概念 | 説明 |
|---|---|
configureStore |
Storeを作成する関数。DevTools、thunk、開発チェックが自動設定される |
createSlice |
state、reducers、actionsを1つの関数で定義する |
Provider |
ReactコンポーネントツリーにStoreを供給するコンポーネント |
useSelector |
Storeから状態を読み取るhook。参照の等価性で再レンダリングを最適化 |
useDispatch |
Actionをdispatchするための関数を返すhook |
| Immer | 「ミュータブル風」記法でイミュータブルな更新を実現するライブラリ |
PayloadAction |
TypeScript用のAction型。payloadの型を指定できる |
| Slice | state + reducers + actionsのまとまり。機能ごとに分割する |
今日はRedux Toolkitの基本的なAPIを学び、カウンターアプリとToDoリストアプリを実際に構築しました。createSlice と configureStore を使うことで、レガシーReduxと比較して大幅にコード量を削減でき、開発体験が向上することを体験しました。
明日のDay 3では、複数のSliceを組み合わせた実践的なアプリケーションと、セレクタの活用方法を学びます。
練習問題
問題1: ショッピングカートSlice
以下の機能を持つショッピングカートSliceを createSlice で作成してください。
addItem(product): 商品をカートに追加する(同じ商品がある場合は数量を+1)removeItem(productId): 商品をカートから完全に削除するupdateQuantity({ productId, quantity }): 商品の数量を変更するclearCart(): カートを空にする
初期状態:
{
items: [], // { id, name, price, quantity }
totalItems: 0,
totalPrice: 0
}
問題2: useSelector の最適化
以下のコードにはパフォーマンスの問題があります。問題を特定し、修正してください。
function CartSummary() {
const cartData = useSelector((state) => ({
items: state.cart.items,
total: state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
count: state.cart.items.length,
}));
return (
<div>
<p>商品数: {cartData.count}</p>
<p>合計: {cartData.total}円</p>
</div>
);
}
問題3: Immerの理解
以下のReducerのうち、正しく動作するものと動作しないものを判別し、理由を説明してください。
const slice = createSlice({
name: 'example',
initialState: { items: [], count: 0 },
reducers: {
// A
addItem(state, action) {
state.items.push(action.payload);
state.count += 1;
},
// B
resetItems(state) {
return { ...state, items: [], count: 0 };
},
// C
badReset(state) {
state.items = [];
return state;
},
// D
replaceAll(state, action) {
return { items: action.payload, count: action.payload.length };
}
}
});