Reactの基礎 - useReducer
概要
useReducer は、複雑な状態ロジックを管理するためのReact Hookである。
基本構文は const [state, dispatch] = useReducer(reducer, initialState) であり、現在の状態値 state と、状態更新をトリガーする dispatch 関数を返す。
reducer は (state, action) => newState という形式の純粋関数であり、現在の状態とアクションを受け取り、新しい状態を返す。
この設計はReduxパターンに基づいており、状態遷移が予測可能で、テストしやすい構造になっている。
useReducer と useState は用途によって使い分ける。
単純な値や独立した状態には useState が適しているが、複雑なオブジェクト・配列の状態管理、複数の関連する状態の一元管理、多くのアクション型が存在する場合には useReducer が適している。
目安として、useState を3個以上使用している場合は useReducer への移行を検討するとよい。
useReducer を使用することにより、状態更新ロジックをコンポーネントの外部に分離でき、コードの見通しが改善される。
また、dispatch 関数は再レンダリング間で安定した参照を持つため、useCallback でラップする必要がなく、子コンポーネントへの受け渡しにも適している。
TypeScriptとの相性も良く、アクション型にDiscriminated Union (判別付きユニオン) を使用することで、switch 文内で型の絞り込みが自動的に行われる。
第3引数として初期化関数を渡すことで、遅延初期化 (Lazy Initialization) も可能であり、初期状態の計算コストが高い場合に有用である。
基本的な使用方法
Reducer関数の定義
reducer 関数は、(state, action) => newState という形式の純粋関数として定義する。
switch文を使用してアクションの type に応じた処理に分岐し、常に新しい状態オブジェクトを返す必要がある。
import { useReducer } from 'react';
// 状態の型定義
interface CounterState {
count: number;
}
// アクションの型定義
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' }
| { type: 'SET'; payload: number };
// Reducer関数 : 純粋関数として定義する
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 'SET':
return { count: action.payload };
default:
return state;
}
}
// 初期状態
const initialState: CounterState = { count: 0 };
// コンポーネント
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>増加</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>減少</button>
<button onClick={() => dispatch({ type: 'RESET' })}>リセット</button>
<button onClick={() => dispatch({ type: 'SET', payload: 10 })}>10に設定</button>
</div>
);
}
dispatch関数
dispatch 関数は、reducer に渡すアクションオブジェクトを引数に取る。
アクションオブジェクトには type プロパティが必須であり、追加データを渡す場合は慣例として payload プロパティを使用する。
// type のみのアクション (ペイロードなし)
dispatch({ type: 'INCREMENT' });
dispatch({ type: 'RESET' });
// payload を持つアクション
dispatch({ type: 'SET', payload: 42 });
dispatch({ type: 'SET_NAME', payload: 'Alice' });
dispatch は同期的に処理されるため、dispatch 呼び出し直後に state を参照しても、更新後の値は得られない点に注意する。
更新後の状態が反映されるのは次のレンダリング以降である。
TypeScriptでの型定義
Discriminated Union型
TypeScriptでは、アクション型をDiscriminated Union (判別可能なユニオン型) として定義することを推奨する。
各アクションオブジェクトの type プロパティをリテラル型にすることで、TypeScriptが各caseブロック内でアクションの型を自動的に絞り込む。
// Discriminated Union型によるアクション定義
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_COUNT'; payload: number }
| { type: 'SET_NAME'; payload: string }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_COUNT':
// この case ブロック内では action.payload が number 型に絞り込まれる
return { ...state, count: action.payload };
case 'SET_NAME':
// この case ブロック内では action.payload が string 型に絞り込まれる
return { ...state, name: action.payload };
case 'INCREMENT':
// この case ブロック内では action に payload プロパティは存在しない
return { ...state, count: state.count + 1 };
default:
return state;
}
}
State型の定義
状態の型は interface または type を使用して定義する。
複雑な状態を持つ場合でも、型定義により状態の構造が明確になる。
// State型の定義
interface State {
count: number;
history: number[];
name: string;
isLoading: boolean;
error: string | null;
}
// 初期状態
const initialState: State = {
count: 0,
history: [],
name: '',
isLoading: false,
error: null,
};
// アクション型の定義
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET_COUNT'; payload: number }
| { type: 'SET_NAME'; payload: string }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return {
...state,
count: state.count + 1,
history: [...state.history, state.count + 1],
};
case 'DECREMENT':
return {
...state,
count: state.count - 1,
history: [...state.history, state.count - 1],
};
case 'SET_COUNT':
return { ...state, count: action.payload };
case 'SET_NAME':
return { ...state, name: action.payload };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload };
case 'RESET':
return initialState;
default:
return state;
}
}
useReducerとuseStateの使い分け
useReducer と useState はどちらも状態管理に使用するが、それぞれに適したユースケースが異なる。
| 比較項目 | useState | useReducer |
|---|---|---|
| 状態の複雑さ | 単純な値 (数値・文字列・真偽値) | 複雑なオブジェクトや配列 |
| 状態の構造 | 独立した単一の値 | 複数の関連した値をまとめた構造体 |
| アクション数 | 1〜2種類の更新操作 | 多くの種類のアクションが存在する。 |
| 状態の依存性 | 各状態が独立している。 | 次の状態が現在の状態に依存する。 |
| テスト容易性 | コンポーネントと一体 | reducerを単体でテストできる。 |
| ロジックの分離 | コンポーネント内に記述 | コンポーネント外に分離できる。 |
| 推奨規模 | 小規模・単純なコンポーネント | 中〜大規模・複雑な状態管理 |
下表に、使い分けの判断基準を示す。
| 状況 | 推奨するHook |
|---|---|
| カウンターや入力値など単一の値を管理する場合 | useState |
| 真偽値のトグル操作 | useState |
| 3つ以上の useState が存在し、関連している場合 | useReducer |
| 状態の更新に複数のアクションパターンがある場合 | useReducer |
| 次の状態が現在の状態の複数フィールドに依存する場合 | useReducer |
| 状態更新ロジックをコンポーネント外にテストしたい場合 | useReducer |
遅延初期化
useReducer の第3引数に初期化関数 (init) を渡すことにより、遅延初期化を実現できる。
初期化関数は初回レンダリング時にのみ実行されるため、処理コストの高い初期状態の計算を遅延させるのに適している。
構文は useReducer(reducer, initialArg, init) であり、init(initialArg) の戻り値が初期状態として使用される。
interface State {
count: number;
items: string[];
}
type Action =
| { type: 'INCREMENT' }
| { type: 'RESET'; payload: string }
| { type: 'ADD_ITEM'; payload: string };
// 初期化関数 : 引数を受け取り、初期状態を返す
// この関数は初回レンダリング時のみ実行される
function createInitialState(userId: string): State {
// 処理コストの高い初期化処理
const savedData = localStorage.getItem(`state_${userId}`);
if (savedData) {
return JSON.parse(savedData) as State;
}
return { count: 0, items: [] };
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'RESET':
// init関数を使用してリセット
return createInitialState(action.payload);
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
default:
return state;
}
}
function UserCounter({ userId }: { userId: string }) {
// 第3引数にinit関数を指定 : createInitialState(userId) が初回のみ実行される
const [state, dispatch] = useReducer(reducer, userId, createInitialState);
return (
<div>
<p>カウント: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>増加</button>
<button onClick={() => dispatch({ type: 'RESET', payload: userId })}>リセット</button>
</div>
);
}
遅延初期化には、reset アクション時に init 関数を再利用して初期状態に戻すことができるメリットもある。
サンプルコード : Reducerパターン
Todoリストの管理
複数のアクション型を持つTodoリストは、useReducer が効果的に機能するユースケースの代表例である。
interface Todo {
id: number;
text: string;
completed: boolean;
}
type FilterType = 'ALL' | 'ACTIVE' | 'COMPLETED';
interface TodoState {
todos: Todo[];
filter: FilterType;
nextId: number;
}
type TodoAction =
| { type: 'ADD'; payload: string }
| { type: 'REMOVE'; payload: number }
| { type: 'TOGGLE'; payload: number }
| { type: 'SET_FILTER'; payload: FilterType }
| { type: 'CLEAR_COMPLETED' };
const initialTodoState: 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.payload, completed: false },
],
nextId: state.nextId + 1,
};
case 'REMOVE':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload),
};
case 'TOGGLE':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter((todo) => !todo.completed),
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialTodoState);
const [inputText, setInputText] = useState('');
const filteredTodos = state.todos.filter((todo) => {
if (state.filter === 'ACTIVE') return !todo.completed;
if (state.filter === 'COMPLETED') return todo.completed;
return true;
});
return (
<div>
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="新しいTodoを入力"
/>
<button onClick={() => {
if (inputText.trim()) {
dispatch({ type: 'ADD', payload: inputText.trim() });
setInputText('');
}
}}>追加</button>
<div>
<button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'ALL' })}>全て</button>
<button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'ACTIVE' })}>未完了</button>
<button onClick={() => dispatch({ type: 'SET_FILTER', payload: 'COMPLETED' })}>完了</button>
</div>
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: 'TOGGLE', payload: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'REMOVE', payload: todo.id })}>削除</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>完了済みを削除</button>
</div>
);
}
フォーム状態の管理
複数の入力フィールドを持つフォームの状態を、useReducer で一元管理する例を以下に示す。
interface FormState {
name: string;
email: string;
password: string;
isSubmitting: boolean;
errors: Record<string, string>;
}
type FormAction =
| { type: 'SET_FIELD'; field: keyof Pick<FormState, 'name' | 'email' | 'password'>; payload: string }
| { type: 'SET_SUBMITTING'; payload: boolean }
| { type: 'SET_ERROR'; field: string; payload: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'RESET' };
const initialFormState: FormState = {
name: '',
email: '',
password: '',
isSubmitting: false,
errors: {},
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.payload };
case 'SET_SUBMITTING':
return { ...state, isSubmitting: action.payload };
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.payload },
};
case 'CLEAR_ERRORS':
return { ...state, errors: {} };
case 'RESET':
return initialFormState;
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'CLEAR_ERRORS' });
dispatch({ type: 'SET_SUBMITTING', payload: true });
try {
// フォーム送信処理
await submitForm(state);
}
catch (error) {
dispatch({ type: 'SET_ERROR', field: 'submit', payload: '送信に失敗しました' });
}
finally {
dispatch({ type: 'SET_SUBMITTING', payload: false });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.name}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'name', payload: e.target.value })}
placeholder="名前"
/>
<input
value={state.email}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'email', payload: e.target.value })}
placeholder="メールアドレス"
/>
<input
type="password"
value={state.password}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'password', payload: e.target.value })}
placeholder="パスワード"
/>
{state.errors.submit && <p>{state.errors.submit}</p>}
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? '送信中...' : '登録'}
</button>
</form>
);
}
Reduxパターンとの比較
useReducer はReduxと同じ設計思想に基づいているが、スコープや機能面で重要な違いがある。
| 比較項目 | useReducer | Redux (Redux Toolkit) |
|---|---|---|
| スコープ | 単一コンポーネント (またはContextを通じたサブツリー) | アプリケーション全体 |
| セットアップ | React組み込みのため追加インストール不要 | パッケージのインストールが必要 |
| ミドルウェア | 非対応 | redux-thunk、redux-saga等を使用可能 |
| DevTools | 非対応 | Redux DevToolsで状態遷移を可視化できる。 |
| 非同期処理 | useEffectと組み合わせる必要がある。 | createAsyncThunk等で統合的に処理できる。 |
| 拡張性 | コンポーネントレベルに限定される | プラグインやミドルウェアで拡張できる。 |
| 学習コスト | 低い (React Hooksの知識のみ) | 高い (Redux固有の概念が多い) |
| 適した規模 | 小〜中規模アプリケーション | 中〜大規模アプリケーション |
下表に、類似点をまとめる。
| 共通概念 | 説明 |
|---|---|
| reducer関数 | どちらも (state, action) => newState の純粋関数を使用する。
|
| アクションオブジェクト | どちらも type プロパティを持つアクションオブジェクトを使用する。
|
| 予測可能な状態遷移 | 同じアクションと状態に対して常に同じ結果を返す。 |
| イミュータブルな状態更新 | 既存の状態を変更せず、新しい状態オブジェクトを返す。 |
useReducer と useContextの組み合わせ
useReducer と useContext を組み合わせることにより、コンポーネントツリー全体でグローバルな状態管理を実現できる。
Props drilling (プロップスのバケツリレー) を避けながら、複数のコンポーネントから状態にアクセスできるようになる。
状態と dispatch を分離してContextを定義することで、dispatch のみを使用するコンポーネントの不要な再レンダリングを防ぐことができる。
import { createContext, useContext, useReducer, ReactNode } from 'react';
// 型定義
interface Task {
id: number;
text: string;
completed: boolean;
}
interface TaskState {
tasks: Task[];
nextId: number;
}
type TaskAction =
| { type: 'ADD'; payload: string }
| { type: 'REMOVE'; payload: number }
| { type: 'TOGGLE'; payload: number };
// Contextの型定義 (StateとDispatchを分離)
const TaskStateContext = createContext<TaskState | null>(null);
const TaskDispatchContext = createContext<React.Dispatch<TaskAction> | null>(null);
// Reducer
function taskReducer(state: TaskState, action: TaskAction): TaskState {
switch (action.type) {
case 'ADD':
return {
...state,
tasks: [...state.tasks, { id: state.nextId, text: action.payload, completed: false }],
nextId: state.nextId + 1,
};
case 'REMOVE':
return { ...state, tasks: state.tasks.filter((t) => t.id !== action.payload) };
case 'TOGGLE':
return {
...state,
tasks: state.tasks.map((t) =>
t.id === action.payload ? { ...t, completed: !t.completed } : t
),
};
default:
return state;
}
}
// Providerコンポーネント
function TaskProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(taskReducer, { tasks: [], nextId: 1 });
return (
<TaskStateContext.Provider value={state}>
<TaskDispatchContext.Provider value={dispatch}>
{children}
</TaskDispatchContext.Provider>
</TaskStateContext.Provider>
);
}
// カスタムHook : StateとDispatchを個別に取得
function useTaskState(): TaskState {
const context = useContext(TaskStateContext);
if (!context) throw new Error('useTaskState must be used within TaskProvider');
return context;
}
function useTaskDispatch(): React.Dispatch<TaskAction> {
const context = useContext(TaskDispatchContext);
if (!context) throw new Error('useTaskDispatch must be used within TaskProvider');
return context;
}
// 使用例
function TaskList() {
// 状態のみを購読するため、dispatchが変化しても再レンダリングされない
const { tasks } = useTaskState();
const dispatch = useTaskDispatch();
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span onClick={() => dispatch({ type: 'TOGGLE', payload: task.id })}>
{task.text}
</span>
<button onClick={() => dispatch({ type: 'REMOVE', payload: task.id })}>削除</button>
</li>
))}
</ul>
);
}
function App() {
return (
<TaskProvider>
<TaskList />
</TaskProvider>
);
}
Immerライブラリとの組み合わせ
Immerライブラリを使用すると、ネストされた状態の更新をスプレッド演算子なしで簡単に記述できる。
Immerの produce 関数内では、状態を直接変更するような記述ができるが、実際には内部でイミュータブルな更新が行われる。
Immerを使用する場合は、npm install immer コマンドを実行して、Immerパッケージをインストールする必要がある。
import { useReducer } from 'react';
import { produce } from 'immer';
interface User {
id: number;
name: string;
email: string;
address: {
city: string;
zipCode: string;
};
}
interface UserState {
users: Record<number, User>;
selectedId: number | null;
}
type UserAction =
| { type: 'UPDATE_EMAIL'; id: number; payload: string }
| { type: 'UPDATE_CITY'; id: number; payload: string }
| { type: 'SELECT'; payload: number };
// スプレッド演算子を使用した従来の記述
function reducerWithSpread(state: UserState, action: UserAction): UserState {
switch (action.type) {
case 'UPDATE_EMAIL':
return {
...state,
users: {
...state.users,
[action.id]: {
...state.users[action.id],
email: action.payload,
},
},
};
case 'UPDATE_CITY':
return {
...state,
users: {
...state.users,
[action.id]: {
...state.users[action.id],
address: {
...state.users[action.id].address,
city: action.payload,
},
},
},
};
default:
return state;
}
}
// Immerを使用した簡潔な記述
const reducerWithImmer = produce((draft: UserState, action: UserAction) => {
switch (action.type) {
case 'UPDATE_EMAIL':
// draft を直接変更するように記述できる
draft.users[action.id].email = action.payload;
break;
case 'UPDATE_CITY':
// ネストが深い場合も簡潔に記述できる
draft.users[action.id].address.city = action.payload;
break;
case 'SELECT':
draft.selectedId = action.payload;
break;
}
});
function UserManager() {
const [state, dispatch] = useReducer(reducerWithImmer, {
users: {},
selectedId: null,
});
return <div>...</div>;
}
よくある間違い
状態の直接変更
reducer 内で既存の状態オブジェクトを直接変更してそのまま返すと、Reactが変更を検知できず、再レンダリングが発生しない。
常に新しいオブジェクトを返す必要がある。
// 正しい : 新しいオブジェクトを返す
function goodReducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }; // スプレッドで新しいオブジェクトを作成
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] }; // 新しい配列を作成
}
}
// 誤り : 既存のオブジェクトを直接変更して返している
function badReducer(state: State, action: Action): State {
switch (action.type) {
case 'INCREMENT':
state.count += 1; // 直接変更
return state; // 同じオブジェクト参照を返す (変更を検知できない)
case 'ADD_ITEM':
state.items.push(action.payload); // 配列の直接変更
return state;
}
}
アクション型の不整合
dispatch に渡すアクションの type 文字列と、reducer の case 文字列が一致しない場合、アクションが処理されない。
TypeScriptのDiscriminated Union型を使用することにより、この種のミスをコンパイル時に検知できる。
// 正しい : TypeScriptの型定義で整合性を保証する
type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' };
dispatch({ type: 'INCREMENT' }); // 型チェックにより一致が保証される
// 誤り : caseの文字列が大文字なのに、dispatchは小文字
dispatch('increment'); // string型で渡している
dispatch({ type: 'increment' }); // 小文字
// reducer側
case 'INCREMENT': // 大文字 (一致しない)
Reducer内の副作用
reducer 関数は純粋関数でなければならない。
API呼び出し、ローカルストレージへの書き込み、乱数生成、タイマの設定等の副作用を reducer 内に記述してはいけない。
副作用は useEffect 内で処理する。
// 誤り : reducer内で副作用を実行している
function badReducer(state: State, action: Action): State {
switch (action.type) {
case 'SAVE':
localStorage.setItem('data', JSON.stringify(state)); // 副作用
fetch('/api/save', { method: 'POST', body: JSON.stringify(state) }); // 副作用
return state;
case 'ADD':
return { ...state, id: Math.random() }; // 乱数生成も副作用
}
}
// 正しい : 副作用はuseEffectで処理する
function goodReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD':
return { ...state, items: [...state.items, action.payload] };
default:
return state;
}
}
function MyComponent() {
const [state, dispatch] = useReducer(goodReducer, initialState);
// 副作用はuseEffectで処理する
useEffect(() => {
localStorage.setItem('data', JSON.stringify(state));
}, [state]);
return <div>...</div>;
}
関連情報