TOOLS BOX/ガイド/useReducer と useState の使い分け
Concept

useReducer と useState の使い分け

ローカル状態の持ち方として useState と useReducer をどう選ぶかを整理するガイド。単純な値・独立した状態は useState が自然で、複数フィールドの連動・イベント種別ごとの分岐・状態遷移の明示化には useReducer が向く。useReducer を過剰に使わないための判断基準も整理する。

reactuseStateuseReducerreducerstate-managementlocal-statedispatchaction

どういう場面で使うか

  • ·useState: 独立したフラグ・数値・文字列など、単純な値の管理
  • ·useState: 互いに影響しない複数の状態を別々に持つとき
  • ·useReducer: 複数のフィールドが連動して更新される状態(フォーム全体など)
  • ·useReducer: 更新のトリガーが複数種類あり、種別ごとにロジックを分けたいとき
  • ·useReducer: 状態遷移(loading → success → error など)を明示的に管理したいとき

注意点 / Pitfalls

  • ·useReducer を「useState の上位互換」として捉えると過剰に使いすぎる。単純な値は useState の方がシンプル
  • ·reducer の action type に文字列リテラルを使うと typo が型チェックされない。Union 型で定義する
  • ·複雑なフォームは react-hook-form が適している。useReducer で自前実装するとバリデーション・dirty・touched の管理コストが高くなる
  • ·dispatch を子コンポーネントに渡し続けると props drilling になる。context か store と組み合わせて解消する
  • ·reducer 内で副作用(API コールなど)を実行しない。副作用は useEffect や event handler で行う

補足

useState と useReducer は排他的ではなく、1 コンポーネント内に混在してよい。「この状態の更新ロジックを reducer でまとめると読みやすくなるか」を判断基準にする。テスタビリティの観点では、reducer は pure function なので単体テストが書きやすい。

useState が自然な場面

// ✅ 独立したフラグ・単純な値 → useState
const [isOpen, setIsOpen] = useState(false);
const [count, setCount] = useState(0);
const [name, setName] = useState("");

// ✅ 互いに影響しない複数の状態を別々に管理
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// loading と error は独立して変化する → useState を並べるだけで十分

「次の state が現在の state だけに依存する」「フィールド同士が連動しない」場合は useState がシンプル。

useReducer が向く場面

複数フィールドが連動して更新される

// ❌ useState を並べると更新が分散して読みにくくなる
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);
const [errors, setErrors] = useState<Record<string, string>>({});

function handleSubmit() {
  const newErrors: Record<string, string> = {};
  if (!name) newErrors.name = "必須";
  if (!email) newErrors.email = "必須";
  setErrors(newErrors);
  // フィールドと errors が別々の setState になる
}

// ✅ useReducer でフォーム全体をまとめる
type FormState = {
  name: string;
  email: string;
  age: number;
  errors: Record<string, string>;
};

type FormAction =
  | { type: "SET_FIELD"; field: keyof Omit<FormState, "errors">; value: string | number }
  | { type: "SET_ERRORS"; errors: Record<string, string> }
  | { type: "RESET" };

const initialState: FormState = { name: "", email: "", age: 0, errors: {} };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_FIELD":
      return { ...state, [action.field]: action.value };
    case "SET_ERRORS":
      return { ...state, errors: action.errors };
    case "RESET":
      return initialState;
    default:
      return state;
  }
}

function ProfileForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  function handleSubmit() {
    const errors: Record<string, string> = {};
    if (!state.name) errors.name = "必須";
    if (!state.email) errors.email = "必須";
    dispatch({ type: "SET_ERRORS", errors }); // 1 回の dispatch でエラー全体を更新
  }
  // ...
}

イベント種別ごとにロジックを分岐したい

// ✅ action type で分岐を明示する
type CartAction =
  | { type: "ADD_ITEM"; item: CartItem }
  | { type: "REMOVE_ITEM"; id: string }
  | { type: "UPDATE_QUANTITY"; id: string; quantity: number }
  | { type: "CLEAR" };

function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
  switch (action.type) {
    case "ADD_ITEM":
      return [...state, action.item];
    case "REMOVE_ITEM":
      return state.filter((item) => item.id !== action.id);
    case "UPDATE_QUANTITY":
      return state.map((item) =>
        item.id === action.id ? { ...item, quantity: action.quantity } : item
      );
    case "CLEAR":
      return [];
  }
}

// イベントの発生元が異なっても dispatch で統一して扱える
dispatch({ type: "ADD_ITEM", item: newItem });
dispatch({ type: "REMOVE_ITEM", id: "abc" });

状態遷移を明示したい

// ✅ loading → success / error の遷移を reducer で表現する
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

type FetchAction<T> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; data: T }
  | { type: "FETCH_ERROR"; message: string }
  | { type: "RESET" };

function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
  switch (action.type) {
    case "FETCH_START":
      return { status: "loading" };
    case "FETCH_SUCCESS":
      return { status: "success", data: action.data };
    case "FETCH_ERROR":
      return { status: "error", message: action.message };
    case "RESET":
      return { status: "idle" };
  }
}

function UserProfile({ userId }: { userId: string }) {
  const [state, dispatch] = useReducer(fetchReducer<User>, { status: "idle" });

  useEffect(() => {
    dispatch({ type: "FETCH_START" });
    fetchUser(userId)
      .then((data) => dispatch({ type: "FETCH_SUCCESS", data }))
      .catch((err) => dispatch({ type: "FETCH_ERROR", message: err.message }));
  }, [userId]);

  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <p>{state.message}</p>;
  if (state.status === "success") return <p>{state.data.name}</p>;
  return null;
}

過剰になるケース

// ❌ 単純な toggle に useReducer は不要
function toggleReducer(state: boolean, action: { type: "TOGGLE" }): boolean {
  switch (action.type) {
    case "TOGGLE":
      return !state;
  }
}
const [isOpen, dispatch] = useReducer(toggleReducer, false);
dispatch({ type: "TOGGLE" });

// ✅ useState で十分
const [isOpen, setIsOpen] = useState(false);
setIsOpen((prev) => !prev);
// ❌ フィールドが 1 つだけなのに useReducer を使う
type CountAction = { type: "INCREMENT" } | { type: "DECREMENT" };
function countReducer(state: number, action: CountAction): number {
  switch (action.type) {
    case "INCREMENT": return state + 1;
    case "DECREMENT": return state - 1;
  }
}

// ✅ useState で十分
const [count, setCount] = useState(0);

判断基準: reducer でまとめると更新ロジックが読みやすくなるか? が Yes なら useReducer。No なら useState。

フォーム・UI・非同期状態での使い分け

用途推奨理由
単純なフラグ(open/closed など)useState1 フィールドで遷移が trivial
複数フィールドのフォーム(軽量)useReducer更新の一元管理
複雑なフォーム(バリデーション・dirty・touched)react-hook-form専用ライブラリが管理コストを吸収
ローカルの非同期状態(loading/data/error)useReducer遷移の明示化
グローバルな非同期状態TanStack Queryキャッシュ・再取得を自前実装しなくてよい

よくある誤用

// ❌ action type に文字列リテラルを直接書く → typo が型チェックされない
dispatch({ type: "INCREEMENT" }); // typo でも TypeScript が検出できない場合がある

// ✅ Union 型で action type を定義する
type CountAction = { type: "INCREMENT" } | { type: "DECREMENT" };
// 誤った type を渡すとコンパイルエラーになる
// ❌ reducer 内で副作用を実行する
function badReducer(state: State, action: Action): State {
  if (action.type === "SUBMIT") {
    fetch("/api/submit", { body: JSON.stringify(state) }); // ❌ reducer 内で API コール
    return { ...state, submitted: true };
  }
  return state;
}

// ✅ 副作用は useEffect か event handler で行う
function handleSubmit() {
  dispatch({ type: "SUBMIT_START" });     // reducer は state だけ更新
  fetch("/api/submit", { body: JSON.stringify(state) }) // 副作用はここで
    .then(() => dispatch({ type: "SUBMIT_SUCCESS" }))
    .catch(() => dispatch({ type: "SUBMIT_ERROR" }));
}
// ❌ dispatch を子コンポーネントに渡し続ける(props drilling)
<Child dispatch={dispatch} />
<GrandChild dispatch={dispatch} />

// ✅ context と組み合わせて dispatch を配布する
const DispatchContext = createContext<Dispatch<Action> | null>(null);

function Parent() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <DispatchContext.Provider value={dispatch}>
      <Child />
    </DispatchContext.Provider>
  );
}

function GrandChild() {
  const dispatch = useContext(DispatchContext)!;
  return <button onClick={() => dispatch({ type: "RESET" })}>リセット</button>;
}

useState か useReducer かの選び方 → React Hook によるデータ受け渡しパターン

再レンダリングを抑える関連パターン → React 再レンダリング最小化パターン

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例