React の useState / useReducer 使い分けガイド

概要

React で状態管理をするとき、まず最初に手が伸びるのは useState です。多くの場面ではそれで十分ですが、状態が絡み合ってくると useState の羅列がだんだん破綻していきます。そこで登場するのが useReducer です。

この記事では、useStateuseReducer の違いを整理し、「どちらを使うべきか」を判断できるようになることをゴールにします。React の基本を触ったことがある人向けの入門記事です。

前提: どちらも「状態を持つ」ためのフック

useStateuseReducer も、コンポーネントに状態を持たせるためのフックです。できることは本質的に同じで、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 か状態管理ライブラリを検討する。ローカル状態の整理とは別の問題。

まとめ

  • useStateuseReducer はできることは同じ。違いは「更新ロジックをどこに置くか」。
  • 単一・独立・単純な状態は useState
  • 複数の値が連動する・遷移が複雑・テストしたいなら useReducer
  • まず useState で始め、綻んできたら useReducer へ。最初から凝る必要はない。

「状態が絡み合ってきたな」と感じたときに useReducer を思い出せれば、この記事の目的は達成です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください