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 など) | useState | 1 フィールドで遷移が 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 再レンダリング最小化パターン