第6回
useReducerでの状態管理:複雑なstateをスッキリ整理する
ReactのuseReducerを解説します。action・reducer・dispatchの概念、TypeScriptでの型安全な実装、useStateとの使い分け基準を、ToDoアプリを例に実践的に学べます。
·14分で読める
たける
キャラクター選択ページで `selectedId`・`isLoading`・`error`・`searchQuery` って4つのstateができて、更新が絡み合ってどこで何が起きてるかわからなくなってきました……。
りこ
それがuseReducerに切り替えるサイン。stateの数より「更新ロジックが複雑かどうか」「複数のstateが連動して変わるか」で判断する。
useReducerとは
useReducer は複数のstateが連動して変化したり、更新ロジックが複雑なときに useState の代わりに使うHookです。
const [state, dispatch] = useReducer(reducer, initialState);state— 現在の状態dispatch— アクションを送る関数reducer— 「どのアクションが来たらどう状態を変えるか」を定義した純粋関数initialState— 初期状態
reducerとactionの概念
現在の状態 + アクション → 新しい状態
(state, action) => newStatetype CounterState = { count: number };
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'setCount'; payload: number };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'setCount':
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>リセット</button>
<button onClick={() => dispatch({ type: 'setCount', payload: 10 })}>10にする</button>
</div>
);
}実践:ToDoアプリ
複数の操作(追加・完了・削除・フィルター)が絡むToDoアプリで useReducer の真価が発揮されます。
type Todo = {
id: number;
text: string;
completed: boolean;
};
type Filter = 'all' | 'active' | 'completed';
type TodoState = {
todos: Todo[];
filter: Filter;
nextId: number;
};
type TodoAction =
| { type: 'add'; text: string }
| { type: 'toggle'; id: number }
| { type: 'delete'; id: number }
| { type: 'setFilter'; filter: Filter }
| { type: 'clearCompleted' };
const initialState: TodoState = {
todos: [],
filter: 'all',
nextId: 1,
};
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'add':
return {
...state,
todos: [...state.todos, { id: state.nextId, text: action.text, completed: false }],
nextId: state.nextId + 1,
};
case 'toggle':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'delete':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.id),
};
case 'setFilter':
return { ...state, filter: action.filter };
case 'clearCompleted':
return { ...state, todos: state.todos.filter((todo) => !todo.completed) };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [input, setInput] = useState('');
const filteredTodos = state.todos.filter((todo) => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
const handleAdd = () => {
if (!input.trim()) return;
dispatch({ type: 'add', text: input });
setInput('');
};
return (
<div>
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={handleAdd}>追加</button>
</div>
<div>
{(['all', 'active', 'completed'] as Filter[]).map((f) => (
<button
key={f}
onClick={() => dispatch({ type: 'setFilter', filter: f })}
style={{ fontWeight: state.filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({ type: 'toggle', id: todo.id })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'delete', id: todo.id })}>削除</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'clearCompleted' })}>
完了済みを削除
</button>
</div>
);
}useStateとuseReducerの使い分け
| 状況 | 推奨 |
|---|---|
| 独立したシンプルな値 | useState |
| 複数のstateが連動して変化する | useReducer |
| 更新ロジックが複雑 | useReducer |
| ロジックをコンポーネントから分離したい | useReducer |
| テストを書きやすくしたい | useReducer(reducerは純粋関数なのでテストが容易) |
reducerをテストする
reducerは純粋関数(同じ入力 → 同じ出力)なので、簡単にテストできます。
describe('todoReducer', () => {
it('add アクションでToDoが追加される', () => {
const state = initialState;
const next = todoReducer(state, { type: 'add', text: '買い物' });
expect(next.todos).toHaveLength(1);
expect(next.todos[0].text).toBe('買い物');
});
it('toggle アクションで完了状態が反転する', () => {
const state = { ...initialState, todos: [{ id: 1, text: 'test', completed: false }] };
const next = todoReducer(state, { type: 'toggle', id: 1 });
expect(next.todos[0].completed).toBe(true);
});
});
たける
Redux的な考え方ですか? action・dispatch・reducerって用語が一緒で。
りこ
Reduxのコアコンセプトと同じ。ReduxはuseReducerをアプリ全体に拡張して、さらにミドルウェアやDevToolsを追加したライブラリ。理解したならReduxを学ぶコストは大きく下がる。
ユナ
reducerが純粋関数というのはテスト設計でも重要。同じinputに同じoutputが保証されるので、UIを描画せずロジックだけ単体テストできる。
まとめ
useReducerは(state, action) => newStateの reducer関数で状態を管理する- 複数のstateが連動する・更新ロジックが複雑な場合に
useStateより適している - TypeScriptのDiscriminated Union型でactionの型を安全に定義する
- reducerは純粋関数なのでテストしやすい
次の第7回では、DOM参照やレンダリングをトリガーしない値の保持に使う useRef を学びます。