React の useReducer を使いこなす ─ useState との使い分けと実践パターン
React で状態管理をするとき、useState しか使っていない人、けっこう多いと思います。
でも useState を何個も並べていくと、「この状態はあの状態と連動している」「同時に更新しないといけない状態がある」みたいな場面が出てきます。そこで useReducer を知ると、コードの構造がかなりスッキリすることがあります。
この記事で学べること:
-
useReducerの基本的な使い方 -
useStateとuseReducerの使い分け基準 - 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 に切り替えるタイミングだと思います。