こんにちは、かつコーチです。
前回は、カスタムフックを自作してロジックを再利用する方法を解説しました。
今回は、状態管理の話をもう一段階深く掘り下げます。
useState で状態を管理していると、フォームの入力値・エラー内容・送信中フラグなど、関連する状態がどんどん増えていくことがありますよね。
そのたびに setXxx を何個も呼び出すコードになり、更新のタイミングがバラバラで追いにくくなった経験はないでしょうか。
そんなときに使えるのが useReducer です。
今日は、useReducer の基本から実践的な使い方まで、しっかり解説していきます。
useReducerとは?
useStateとの違い
useReducer は、useState と同じく状態を管理するためのHookです。
大きな違いは、状態の更新方法にあります。
useState は setState(newValue) のように「新しい値」を直接渡して更新します。
一方 useReducer は、「どんな種類の操作をしたいか」を表すアクションを dispatch に渡し、reducer関数がそのアクションを元に新しい状態を計算する、という仕組みです。
// useStateの場合
const [count, setCount] = useState<number>(0);
setCount(count + 1);
// useReducerの場合
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: "increment" });
一見遠回りに見えますが、状態の更新ロジックを1箇所(reducer関数)にまとめられるのが最大のメリットです。
なぜuseReducerが必要になるのか
useState を複数並べて管理していると、こんな問題が起きがちです。
- ある状態を更新するときに、関連する別の状態の更新を忘れる
- 更新ロジックがコンポーネントのあちこちに散らばる
- 「今どういう操作が行われたか」がコードから読み取りにくい
たとえばフォームの状態を useState だけで管理すると、次のようになります。
const [name, setName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
状態が4つに分かれていて、それぞれ個別に更新する関数を書く必要があります。
useReducer を使えば、これらを1つの状態オブジェクトにまとめ、「何が起きたか」をアクションとして表現できます。
基本の書き方
reducer関数を定義する
まずは基本の型を押さえましょう。
useReducer は (reducer, initialState) の2つを受け取り、[state, dispatch] を返します。
type CounterState = {
count: number;
};
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
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 };
default:
return state;
}
}
CounterAction をユニオン型(複数の型のどれか、という意味の型)で定義しておくと、switch 文の中でTypeScriptが型を絞り込んでくれます。
存在しない type を dispatch しようとするとコンパイルエラーになるので、タイプミスにも気づきやすくなります。
コンポーネントで使う
定義したreducerをコンポーネントで使ってみましょう。
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>
</div>
);
}
ボタンを押すと dispatch が呼ばれ、counterReducer が現在の状態と受け取ったアクションを元に新しい状態を計算します。
コンポーネント側は「何をしたいか」を dispatch するだけで、実際の計算ロジックはreducer関数に任せられるのが分かると思います。
アクションにペイロードを持たせる
アクションには追加の情報(ペイロード)を持たせることもできます。
type FormState = {
name: string;
email: string;
};
type FormAction =
| { type: "setName"; payload: string }
| { type: "setEmail"; payload: string }
| { type: "reset" };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "setName":
return { ...state, name: action.payload };
case "setEmail":
return { ...state, email: action.payload };
case "reset":
return { name: "", email: "" };
default:
return state;
}
}
payload という名前は慣習的なもので、必須ではありません。
大事なのは「アクションの種類」と「そのアクションに必要なデータ」をセットで型として表現していることです。
つまずきやすいポイント:状態を直接書き換えてしまう
かつコーチが実際にハマった話
私が useReducer を初めて使ったとき、reducer関数の中で状態オブジェクトを直接書き換えてしまい、画面が再描画されないというバグに数時間ハマったことがあります。
原因は、スプレッド構文を忘れて既存のオブジェクトのプロパティを直接変更していたことでした。
❌ Before:stateを直接ミューテーションしている
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "setName":
state.name = action.payload; // stateを直接書き換えている
return state; // 参照が変わらないため再レンダリングされない
default:
return state;
}
}
このコードは一見動いているように見えますが、state オブジェクトの参照が変わらないため、Reactが「状態が更新された」と判断できず、画面が再描画されないことがあります。
しかも見た目上はエラーも出ないので、原因に気づくまでかなり時間がかかりました。
✅ After:新しいオブジェクトを返す
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "setName":
return { ...state, name: action.payload }; // 新しいオブジェクトを返す
default:
return state;
}
}
reducer関数は必ず新しいオブジェクトを返すことを徹底しましょう。
useState の更新関数と同じく、「既存の状態を直接変更せず、新しい状態を作って返す」というルールはReact全体で共通の考え方です。
defaultケースを忘れずに書く
もう一つの落とし穴が、switch 文の default ケースの書き忘れです。
default を書かずに未知のアクションが渡されると、undefined が返されてしまい、状態が消えてしまうことがあります。
必ず default: return state; を書いて、想定外のアクションが来ても元の状態をそのまま返すようにしておきましょう。
応用・一歩先の使い方
初期化関数を使って初期状態を遅延生成する
useReducer の第3引数に初期化関数を渡すと、初期状態の計算を遅延させられます。
type TodoState = {
todos: string[];
};
function init(initialCount: number): TodoState {
// 重い処理や外部データの読み込みを想定
return { todos: Array.from({ length: initialCount }, (_, i) => `Todo ${i + 1}`) };
}
function todoReducer(state: TodoState, action: { type: "clear" }): TodoState {
switch (action.type) {
case "clear":
return { todos: [] };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, 5, init);
return <p>Todo数:{state.todos.length}</p>;
}
初期状態の計算にコストがかかる場合、useReducer(reducer, initialArg, init) の形にすることで、コンポーネントの再レンダリング時に無駄な再計算を避けられます。
useContextと組み合わせてグローバルな状態にする
useReducer は単体でも便利ですが、useContext と組み合わせることで、複数のコンポーネントから同じ状態を参照・更新できるようになります。
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
type TodoAction = { type: "add"; payload: string } | { type: "clear" };
function useTodoDispatch() {
const dispatch = useContext(TodoDispatchContext);
if (!dispatch) {
throw new Error("useTodoDispatchはTodoProviderの内側で使ってください");
}
return dispatch;
}
このパターンは次回、Context APIを使ったグローバル状態管理の記事で詳しく解説します。
useReducer 単体では「1つのコンポーネント内で複雑な状態を整理する道具」ですが、Contextと組み合わせることで一気に活用の幅が広がります。
まとめ
この記事のポイント
useReducerは「アクション」と「reducer関数」で状態更新ロジックを1箇所にまとめるHook- 状態やアクションが複数絡み合う場面では
useStateより見通しがよくなる - reducer関数の中でstateを直接書き換えず、必ず新しいオブジェクトを返す
switch文にはdefaultケースを忘れずに書くuseContextと組み合わせることでグローバルな状態管理にも発展できる
次に読むべき記事
次回は、Context APIを使って実践的なグローバル状態を作る方法を解説します。
→ 次の記事:Context APIで実践的なグローバル状態を作る
