React の useState / useReducer 使い分けガイド
概要
React で状態管理をするとき、まず最初に手が伸びるのは useState です。多くの場面ではそれで十分ですが、状態が絡み合ってくると useState の羅列がだんだん破綻していきます。そこで登場するのが useReducer です。
この記事では、useState と useReducer の違いを整理し、「どちらを使うべきか」を判断できるようになることをゴールにします。React の基本を触ったことがある人向けの入門記事です。
前提: どちらも「状態を持つ」ためのフック
useState も useReducer も、コンポーネントに状態を持たせるためのフックです。できることは本質的に同じで、useReducer でできることは useState でも書けますし、逆も成り立ちます。違うのは「状態の更新ロジックをどこに置くか」です。
useState: 更新方法をコンポーネント内に直接書くuseReducer: 更新方法をreducer関数に切り出し、「アクション」を送って更新する
useState の基本
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>+1</button>
<button onClick={() => setCount((prev) => prev - 1)}>-1</button>
<button onClick={() => setCount(0)}>reset</button>
</div>
);
}
useState(初期値) は [現在の値, 更新関数] を返します。単一の値、あるいは独立した少数の値を扱うぶんには、これがいちばん読みやすい書き方です。前の状態に依存する更新は、setCount((prev) => prev + 1) のように関数形式で書くのが安全です。
useState が苦しくなる瞬間
useState は手軽ですが、状態が増えて連動し始めると綻びます。たとえば「送信ボタンを押したら isSubmitting を true にし、errors をクリアする」という処理は、複数の更新関数を並べて呼ぶことになります。
const handleSubmit = () => {
setIsSubmitting(true);
setErrors({}); // ← これを書き忘れると前回のエラーが残る
// ...
};
こうした「状態遷移のルール」がコンポーネントのあちこちに散らばると、呼び忘れによって状態が矛盾し、バグの温床になります。
useReducer の基本
useReducer は、状態の更新ルールを reducer という 1 つの関数にまとめます。
import { useReducer } from "react";
type State = { count: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
function reducer(state: State, action: Action): State {
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;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+1</button>
<button onClick={() => dispatch({ type: "decrement" })}>-1</button>
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
</div>
);
}
コンポーネント側は「何が起きたか(アクション)」を dispatch するだけで、「どう更新するか」を知る必要がありません。更新ロジックが reducer に集約されるのが最大の利点です。
連動する状態を reducer に集約する
type State = {
isSubmitting: boolean;
errors: Record<string, string>;
};
type Action =
| { type: "submitStart" }
| { type: "submitError"; errors: Record<string, string> }
| { type: "submitSuccess" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "submitStart":
// 「送信開始」に isSubmitting=true と errors クリアが必ずセットになる
return { ...state, isSubmitting: true, errors: {} };
case "submitError":
return { ...state, isSubmitting: false, errors: action.errors };
case "submitSuccess":
return { ...state, isSubmitting: false };
default:
return state;
}
}
「送信開始」という 1 アクションに複数の更新ルールがまとまりました。呼び出し側は dispatch({ type: "submitStart" }) を送るだけで、呼び忘れによる矛盾が起きません。
型で不整合を防ぐ: 非同期処理の状態遷移
useReducer が最も活きるのは、判別可能なユニオン型(discriminated union)と組み合わせた状態遷移です。
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
type Action =
| { type: "fetch" }
| { type: "resolve"; data: string[] }
| { type: "reject"; message: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "fetch":
return { status: "loading" };
case "resolve":
return { status: "success", data: action.data };
case "reject":
return { status: "error", message: action.message };
default:
return state;
}
}
status をユニオン型にすると、「loading なのに data が入っている」といった不整合な状態を型レベルで作れなくなります。useState を並べる書き方ではこの表現は難しく、useReducer の強みが出る場面です。
使い分けの判断基準
useState を選ぶ
- 状態が単一、または独立した少数の値
- 更新ロジックが単純(トグル、入力値、フラグ)
- 状態同士が連動しない
useReducer を選ぶ
- 複数の値がひとまとまりで同時に変化する
- 次の状態が前の状態に依存する複雑な遷移がある
- コンポーネントに更新関数が散らばってきた
- 状態遷移をテストしたい(reducer は純粋関数で単体テストしやすい)
迷ったら
まず useState で書き始めて構いません。「呼び忘れが怖い」「同じ組み合わせの更新を何度も書いている」と感じたら useReducer へリファクタするサインです。最初から凝る必要はありません。
テストのしやすさ
reducer は純粋関数なので、コンポーネントを描画せずに単体テストできます。
test("increment", () => {
expect(reducer({ count: 0 }, { type: "increment" })).toEqual({ count: 1 });
});
注意点
- reducer は純粋関数にする。API 呼び出しや
Date.now()などの副作用を中に書かない。副作用はuseEffectやイベントハンドラ側に置く。 - state はイミュータブルに更新する(
{ ...state, ... })。破壊的変更はしない。 useReducerは万能薬ではない。単純な状態に持ち込むと記述量が増えて読みにくくなる。- グローバルな状態共有が目的なら
useReducer+ Context か状態管理ライブラリを検討する。ローカル状態の整理とは別の問題。
まとめ
useStateとuseReducerはできることは同じ。違いは「更新ロジックをどこに置くか」。- 単一・独立・単純な状態は
useState。 - 複数の値が連動する・遷移が複雑・テストしたいなら
useReducer。 - まず
useStateで始め、綻んできたらuseReducerへ。最初から凝る必要はない。
「状態が絡み合ってきたな」と感じたときに useReducer を思い出せれば、この記事の目的は達成です。
