1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React の useReducer を使いこなす ─ useState との使い分けと実践パターン

1
Posted at

React の useReducer を使いこなす ─ useState との使い分けと実践パターン

React で状態管理をするとき、useState しか使っていない人、けっこう多いと思います。

でも useState を何個も並べていくと、「この状態はあの状態と連動している」「同時に更新しないといけない状態がある」みたいな場面が出てきます。そこで useReducer を知ると、コードの構造がかなりスッキリすることがあります。

この記事で学べること:

  • useReducer の基本的な使い方
  • useStateuseReducer の使い分け基準
  • TypeScript での型安全な実装
  • useContext との組み合わせ(簡易グローバル状態管理)
  • よくある実践パターン(フォーム・フェッチ状態管理)

検証環境: React 18+, TypeScript 5+


useReducer の基本

まずシンプルなカウンターで構造を掴みます。

import { useReducer } from "react";

// 状態の型
type State = {
  count: number;
};

// アクションの型
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "set"; payload: number };

// Reducer: (現在の状態, アクション) → 新しい状態
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 };
    case "set":
      return { count: action.payload };
    default:
      return state;
  }
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, { 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>
      <button onClick={() => dispatch({ type: "set", payload: 10 })}>10にする</button>
    </div>
  );
}

useReducer の構造は:

  • state: 現在の状態
  • dispatch: アクションを送る関数
  • reducer: (state, action) => newState の純粋関数
  • 初期値: useReducer の第2引数

useState との使い分け

どちらを使うか迷ったときの基準をまとめると:

useState を使う場面:

  • 独立したシンプルな値(true/false のフラグ、入力テキスト等)
  • 状態の更新ロジックがシンプル(setCount(c => c + 1) 程度)
  • 状態同士の依存関係がない

useReducer を使う場面:

  • 複数の状態が連動して更新される
  • 状態遷移のパターンが複数ある(例: loading / success / error)
  • 更新ロジックが複雑で、コンポーネントの外に切り出したい
  • 次の状態が現在の状態に依存する処理が多い
// ❌ useState で複数の連動する状態を管理(煩雑になりがち)
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchData = async () => {
  setLoading(true);
  setError(null); // 忘れがち
  try {
    const result = await fetch("/api/data").then(r => r.json());
    setData(result);
    setLoading(false); // 忘れがち
  } catch (e) {
    setError(String(e));
    setLoading(false); // 忘れがち
  }
};
// ✅ useReducer で状態遷移を一元管理
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

type FetchAction<T> =
  | { type: "fetch" }
  | { type: "success"; payload: T }
  | { type: "error"; message: string }
  | { type: "reset" };

function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
  switch (action.type) {
    case "fetch":
      return { status: "loading" };
    case "success":
      return { status: "success", data: action.payload };
    case "error":
      return { status: "error", message: action.message };
    case "reset":
      return { status: "idle" };
    default:
      return state;
  }
}

実践パターン1: フェッチ状態管理

import { useReducer, useCallback } from "react";

type User = { id: number; name: string; email: string };
type State = FetchState<User[]>;
type Action = FetchAction<User[]>;

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "fetch":    return { status: "loading" };
    case "success":  return { status: "success", data: action.payload };
    case "error":    return { status: "error", message: action.message };
    case "reset":    return { status: "idle" };
    default:         return state;
  }
}

export function useUsers() {
  const [state, dispatch] = useReducer(reducer, { status: "idle" });

  const fetchUsers = useCallback(async () => {
    dispatch({ type: "fetch" });
    try {
      const res = await fetch("/api/users");
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      dispatch({ type: "success", payload: data });
    } catch (e) {
      dispatch({ type: "error", message: e instanceof Error ? e.message : "不明なエラー" });
    }
  }, []);

  return { state, fetchUsers };
}

// コンポーネントで使う
export default function UserList() {
  const { state, fetchUsers } = useUsers();

  return (
    <div>
      <button onClick={fetchUsers}>ユーザー取得</button>
      {state.status === "loading" && <p>読み込み中...</p>}
      {state.status === "error" && <p>エラー: {state.message}</p>}
      {state.status === "success" && (
        <ul>
          {state.data.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

status フィールドで状態を discriminated union として扱うと、TypeScript が状態に応じたプロパティを正しく推論してくれます。state.status === "success" のブロック内では state.data が確実に存在するとわかります。


実践パターン2: フォーム状態管理

import { useReducer } from "react";

type FormState = {
  name: string;
  email: string;
  message: string;
  errors: Partial<Record<"name" | "email" | "message", string>>;
  isSubmitting: boolean;
};

type FormAction =
  | { type: "set_field"; field: keyof Omit<FormState, "errors" | "isSubmitting">; value: string }
  | { type: "set_error"; field: keyof FormState["errors"]; message: string }
  | { type: "clear_errors" }
  | { type: "submit_start" }
  | { type: "submit_done" }
  | { type: "reset" };

const initialState: FormState = {
  name: "",
  email: "",
  message: "",
  errors: {},
  isSubmitting: false,
};

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "set_field":
      return { ...state, [action.field]: action.value };
    case "set_error":
      return { ...state, errors: { ...state.errors, [action.field]: action.message } };
    case "clear_errors":
      return { ...state, errors: {} };
    case "submit_start":
      return { ...state, isSubmitting: true, errors: {} };
    case "submit_done":
      return { ...state, isSubmitting: false };
    case "reset":
      return initialState;
    default:
      return state;
  }
}

export default function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    // バリデーション
    if (!state.name) {
      dispatch({ type: "set_error", field: "name", message: "名前を入力してください" });
      return;
    }
    if (!state.email.includes("@")) {
      dispatch({ type: "set_error", field: "email", message: "有効なメールアドレスを入力してください" });
      return;
    }

    dispatch({ type: "submit_start" });
    try {
      await fetch("/api/contact", {
        method: "POST",
        body: JSON.stringify({ name: state.name, email: state.email, message: state.message }),
      });
      dispatch({ type: "reset" });
    } catch {
      dispatch({ type: "set_error", field: "message", message: "送信に失敗しました" });
    } finally {
      dispatch({ type: "submit_done" });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          value={state.name}
          onChange={e => dispatch({ type: "set_field", field: "name", value: e.target.value })}
          placeholder="名前"
        />
        {state.errors.name && <span>{state.errors.name}</span>}
      </div>
      <div>
        <input
          value={state.email}
          onChange={e => dispatch({ type: "set_field", field: "email", value: e.target.value })}
          placeholder="メールアドレス"
        />
        {state.errors.email && <span>{state.errors.email}</span>}
      </div>
      <div>
        <textarea
          value={state.message}
          onChange={e => dispatch({ type: "set_field", field: "message", value: e.target.value })}
          placeholder="メッセージ"
        />
      </div>
      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? "送信中..." : "送信"}
      </button>
    </form>
  );
}

useContext との組み合わせ(簡易グローバル状態管理)

Redux を使うほどではないけど、複数コンポーネントで状態を共有したい場合に。

import { createContext, useContext, useReducer, ReactNode } from "react";

// 状態とアクション型
type AppState = {
  user: { name: string; role: "admin" | "user" } | null;
  theme: "light" | "dark";
};

type AppAction =
  | { type: "login"; user: AppState["user"] }
  | { type: "logout" }
  | { type: "toggle_theme" };

function appReducer(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case "login":
      return { ...state, user: action.user };
    case "logout":
      return { ...state, user: null };
    case "toggle_theme":
      return { ...state, theme: state.theme === "light" ? "dark" : "light" };
    default:
      return state;
  }
}

// Context の型
type AppContextType = {
  state: AppState;
  dispatch: React.Dispatch<AppAction>;
};

const AppContext = createContext<AppContextType | null>(null);

// Provider
export function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(appReducer, {
    user: null,
    theme: "light",
  });

  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  );
}

// カスタムフック
export function useApp() {
  const context = useContext(AppContext);
  if (!context) throw new Error("useApp は AppProvider 内で使ってください");
  return context;
}

// 使う側のコンポーネント
function Header() {
  const { state, dispatch } = useApp();

  return (
    <header>
      {state.user ? (
        <>
          <span>{state.user.name}</span>
          <button onClick={() => dispatch({ type: "logout" })}>ログアウト</button>
        </>
      ) : (
        <button onClick={() => dispatch({ type: "login", user: { name: "Alice", role: "user" } })}>
          ログイン
        </button>
      )}
      <button onClick={() => dispatch({ type: "toggle_theme" })}>
        {state.theme === "light" ? "🌙" : "☀️"}
      </button>
    </header>
  );
}

まとめ

useReducer を一言で表すと、 「状態遷移のパターンを名前をつけて整理できる仕組み」 です。

  • 複数の連動する状態useReducer で一元管理
  • loading / success / error のような状態遷移 → discriminated union と組み合わせると型安全
  • 更新ロジックをコンポーネントの外に出したい → reducer 関数として切り出せる
  • グローバル状態管理useContext と組み合わせると Redux 不要のシンプルな構成が作れる

useState が1〜2個を超えて複雑になってきたと感じたら、useReducer に切り替えるタイミングだと思います。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?