「useStateをいくつ並べたら、useReducerに移行すべき?」「Redux/Redux Toolkitは大げさだけど、useStateだけだと分岐が破綻している」――Reactで中規模以上のフォームやウィザード、フィルタUI、エディタを作っていると、必ずぶつかるstate設計の踊り場です。本記事は、Reactの基本Hookでありながら「むしろ中級者以上向け」と誤解されがちなuseReducerを、現役Reactエンジニア視点で徹底的に解剖します。useStateとの明確な使い分け基準、reducerの設計パターン、TypeScriptのdiscriminated unionsを使ったaction型設計、useContextと組み合わせた軽量Reduxパターン、Redux Toolkit・Zustand・Jotaiとの比較、Immerを使った不変更新の簡略化、そして「いつuseReducerから卒業すべきか」のしきい値まで、サンプルコード18本超・比較表3つ・FAQ7問で具体的に解説します。読み終えるころには、state設計を意図して選べるレベルまで一段引き上がり、useStateの羅列で破綻していた画面を、テスト可能で読みやすいreducerに移行できるはずです。useStateとの違いを知りたい人、Redux代替を探している人、TypeScriptで型安全なstate管理を実現したい人、いずれにも刺さる2026年版・useReducer完全ガイドです。
useReducerは何のために存在するのか
useReducerは「複雑なstateを、純粋関数(reducer)とaction(指示書)に分離して扱うためのHook」です。useStateが「値を直接書き換える」モデルなのに対して、useReducerは「dispatchで指示を投げ、reducerが現在のstateを受け取り、次のstateを返す」モデルを採ります。この一段の間接化が、複雑なstateをテスト可能・予測可能・スケーラブルにします。
useStateの限界と、useReducerが解決すること
useStateは1値・2値であれば最強ですが、フィールド数が増えたり、状態遷移が複雑になったりすると、コンポーネントの中に分岐とイベントハンドラが散らかります。具体的には次のような兆候が出始めたら、useReducerへの移行を検討するタイミングです。
- 同一コンポーネント内で
useStateが5個以上並んでいる - 1回のイベントで複数のsetStateを同時に呼んでおり、片方の更新漏れバグが出ている
- 「現在のstateを参照しながら、次のstateを決める」処理が分散している
- 状態遷移の図(stateチャート)が書けてしまう、もしくは書きたくなった
- 同じstate更新ロジックを、別のコンポーネントやイベントから呼び出したい
これらの兆候は「state更新が分散している」というアンチパターンのサインです。useReducerはこの分散をreducerという1つの純粋関数に集約します。
useReducerのシグネチャを正確に押さえる
useReducerは引数にreducer関数と初期state(オプションで初期化関数)を取り、[state, dispatch]のタプルを返します。dispatchは「actionを送信する」関数で、これを呼ぶたびにReactがreducerをreducer(現在のstate, action)として実行し、返ってきた値を新しいstateとして採用します。
import { useReducer } from "react";
// 1. reducerは「現在のstateとactionを受け取り、次のstateを返す」純粋関数
function counterReducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "reset":
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
// 2. useReducerでstateとdispatchを取り出す
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
</div>
);
}
このサンプルだけ見ると「useStateで十分」と感じるかもしれません。実際、count1つだけならuseStateの方が短く書けます。useReducerの真価は、stateが構造化され、actionの種類が増えたときに発揮されます。
reducerは「純粋関数」であることが絶対条件
reducerには3つの厳格なルールがあります。これを破ると、StrictModeでの二重実行や、React 18+のバッチング・並行レンダリングで予期しないバグが出ます。
- 同じ入力には必ず同じ出力を返す(副作用なし・Date.now()やMath.random()もNG)
- 引数のstateを直接書き換えない(必ず新しいオブジェクト/配列を返す)
- fetchやsetTimeoutなどの副作用を起こさない(副作用はuseEffectやイベントハンドラ側に置く)
これらを守れば、reducerは純粋関数なので単体テストが書き放題になります。Reactの実行環境に依存しないため、Jest/Vitestでexpect(reducer(state, action)).toEqual(expected)の形でテストできるのが、useReducer最大のメリットです。
useStateとuseReducerの違いを徹底比較
「結局どっちを使えばいいの?」に明快に答えるため、まず両者を表で並べます。次に「移行ライン」を具体的なコード差分で示します。
機能比較表
| 観点 | useState | useReducer |
|---|---|---|
| シグネチャ | useState(initial) |
useReducer(reducer, initial, init?) |
| 更新方法 | setState(value | updater) | dispatch(action) |
| 向いているstate | プリミティブ・小さなオブジェクト | 複雑・ネスト・遷移が多い |
| ロジックの所在 | コンポーネント内のハンドラ | 外部のreducer関数(再利用可) |
| テスト容易性 | コンポーネント越しでないと難しい | 純粋関数なので単体テスト可 |
| パフォーマンス | setStateの参照は毎回変わる | dispatchの参照は常に同一 |
| 学習コスト | 低い | 中(reducer/actionの理解が必要) |
| DevTools連携 | 基本不可 | action履歴のロギングが容易 |
移行ラインを示す具体例(Before / After)
「state5個・更新ロジックが散らかったuseState版」と、「reducerに集約したuseReducer版」を並べると、移行ラインが体感できます。まずはuseStateの限界例から。
// Before: useStateが5個並び、更新が手書きで散らかる
function SearchForm() {
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState("all");
const [sort, setSort] = useState("new");
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
// キーワード変更時に、ページを1に戻したい
const onChangeKeyword = (e) => {
setKeyword(e.target.value);
setPage(1); // 更新漏れバグが起きやすい
};
// カテゴリ変更時にも、ページを1に戻したい(同じロジックがコピペされる)
const onChangeCategory = (e) => {
setCategory(e.target.value);
setPage(1);
};
// ...以下続く
}
このコードの問題点は、「ページをリセットする条件」がイベントハンドラごとに散らばっていることです。新しいフィルタを追加するたびに、setPage(1)を忘れると検索結果がズレます。これをuseReducerに移すと次のようになります。
// After: reducerに集約。新しいフィルタが増えてもpageリセットが漏れない
const initialState = {
keyword: "",
category: "all",
sort: "new",
page: 1,
isLoading: false,
};
function searchReducer(state, action) {
switch (action.type) {
case "set_keyword":
return { ...state, keyword: action.payload, page: 1 };
case "set_category":
return { ...state, category: action.payload, page: 1 };
case "set_sort":
return { ...state, sort: action.payload, page: 1 };
case "change_page":
return { ...state, page: action.payload };
case "loading_start":
return { ...state, isLoading: true };
case "loading_end":
return { ...state, isLoading: false };
default:
return state;
}
}
function SearchForm() {
const [state, dispatch] = useReducer(searchReducer, initialState);
// 各ハンドラは「dispatchを呼ぶだけ」。pageのリセット忘れが構造的に起きない
}
移行することで「pageリセットの意図がreducerに集約され、ハンドラはdispatchを呼ぶだけ」になりました。フィルタが10個に増えても、新しいcaseを足すだけで済みます。React/Reactエコシステム全体の入門整理はReact Hooks 完全実践ガイドも参照してください。
useStateから卒業する3つのしきい値
明確な移行ラインを言語化すると次の3つです。1つでも当てはまればuseReducerの導入を真剣に検討するタイミング、2つ以上なら即移行すべきです。
- state数のしきい値:同じコンポーネントでuseStateが5個以上、もしくは1つのオブジェクトstateで5フィールド以上
- 遷移の複雑さのしきい値:1つのイベントで複数フィールドを更新する箇所が3か所以上、または「現在のstateを見て次のstateを決める」分岐が出てきた
- 再利用性のしきい値:同じstate更新ロジックを、別コンポーネントや別ファイルからも呼びたい
useStateの基本動作や落とし穴がまだ曖昧な人は、先にuseState完全ガイドを読んでから戻ってくると、本記事の差分理解がぐっと深まります。
action設計とstateモデリングの実践
useReducerを使いこなすコツは、9割がactionとstateの設計です。reducer本体は、設計が正しければ自然と素直なswitch文になります。逆に設計が雑だと、reducerが分岐の沼に沈みます。
actionは「何が起きたか」で命名する(コマンドではなくイベント)
初心者がやりがちなのが、set_keywordのようなcommand形式で全部書いてしまうこと。これだと結局useStateと変わらず、reducerの恩恵が薄れます。代わりに「ユーザー視点で何が起きたか」を表現するイベント形式の命名にすると、reducerが状態遷移を語る場所になります。
// NG例: setterの寄せ集めにしかなっていない
dispatch({ type: "set_keyword", payload: "react" });
dispatch({ type: "set_page", payload: 1 });
dispatch({ type: "set_loading", payload: true });
// OK例: ユーザーイベントを表現する
dispatch({ type: "keyword_entered", payload: "react" });
dispatch({ type: "search_requested" });
dispatch({ type: "search_succeeded", payload: items });
dispatch({ type: "search_failed", payload: err });
OK例ではreducerが「何が起きたとき、stateがどう変わるか」を表す状態遷移表になります。Redux/Redux Toolkitの公式スタイルガイドでも推奨される考え方で、後でDevTools拡張やロギングを入れたときの可読性が圧倒的に変わります。
actionペイロードの設計パターン
action.payloadには複数のパターンがあります。プロジェクト内で形式を統一するのが重要です。Flux Standard Action(FSA)スタイルだと次のようになります。
// FSA(Flux Standard Action)スタイル
{ type: "search_succeeded", payload: items }
{ type: "search_failed", payload: error, error: true }
{ type: "filter_changed", payload: { key: "category", value: "tech" } }
payload以外のキー(meta・error)はオプションです。プロジェクトで「payloadだけ・errorはtypeに’_failed’を付ける」と決めておけば、reducerが読みやすくなり、TypeScriptのdiscriminated unions(後述)にも素直に乗ります。
stateモデリングの黄金律「正規化と派生値の分離」
useReducerでstateを設計するときの2つの黄金律はこれです。
- 同じ情報を2か所に持たない(派生可能な値はstateに置かず、selector関数で算出する)
- 配列はidをキーにしたMapに正規化する(リストとIDの2軸を持つ)
// NG: 同じTodoの情報が2か所に出てくる(片方が更新漏れすると壊れる)
const state = {
todos: [{ id: 1, text: "buy milk", done: false }, ...],
selectedTodo: { id: 1, text: "buy milk", done: false }, // 完全コピー
};
// OK: idだけ持って、本体は1か所(byId)に集約
const state = {
todos: {
byId: { 1: { id: 1, text: "buy milk", done: false } },
allIds: [1, 2, 3],
},
selectedId: 1, // 参照だけ
};
正規化しておくと、「Todoのテキストを更新する」アクションがbyId[id].text = newTextの1か所書き換えで済みます。Redux ToolkitのcreateEntityAdapterがやっているのも本質的にはこれで、useReducerでも同じ思想でstateを設計するのが定石です。
状態遷移を「stateチャート」で描いてからreducerを書く
非同期処理を含むstateは、文字どおり状態遷移図(state chart)を描いてからreducerに落とすと、抜け漏れが激減します。例として「検索フォームのfetch状態」を考えると次のような遷移になります。
| 現在の状態 | 受け取るaction | 遷移先 |
|---|---|---|
| idle | search_requested | loading |
| loading | search_succeeded | success(items保持) |
| loading | search_failed | error(message保持) |
| success | keyword_entered | idle |
| error | retry_clicked | loading |
この表があれば、reducerのswitch文は表を写経するだけです。状態をstatus: 'idle' | 'loading' | 'success' | 'error'のような判別可能なユニオンで表現すると、TypeScriptとの相性が劇的に良くなります。
TypeScriptで型安全なuseReducerを書く
useReducerの真価は、TypeScriptと組み合わせたときに最大化します。actionをdiscriminated unions(判別可能なユニオン型)で定義することで、reducerの中でswitch(action.type)するだけでaction.payloadの型まで自動で絞り込まれるという劇的な型安全性が得られます。
actionをdiscriminated unionsで型付けする
type Todo = { id: number; text: string; done: boolean };
type State = {
byId: Record<number, Todo>;
allIds: number[];
filter: "all" | "active" | "done";
};
// actionはtypeをリテラル型にして、payloadは型ごとに異なる構造を持たせる
type Action =
| { type: "added"; payload: { text: string } }
| { type: "toggled"; payload: { id: number } }
| { type: "removed"; payload: { id: number } }
| { type: "filter_changed"; payload: { filter: State["filter"] } }
| { type: "reset" };
このAction型を使ってreducerを書くと、case文の中でactionの型が自動的に絞り込まれます。
function todoReducer(state: State, action: Action): State {
switch (action.type) {
case "added": {
// この中ではaction.payloadは { text: string } と確定
const id = Math.max(0, ...state.allIds) + 1;
const todo: Todo = { id, text: action.payload.text, done: false };
return {
...state,
byId: { ...state.byId, [id]: todo },
allIds: [...state.allIds, id],
};
}
case "toggled": {
// ここではaction.payloadは { id: number } と確定
const t = state.byId[action.payload.id];
return {
...state,
byId: { ...state.byId, [t.id]: { ...t, done: !t.done } },
};
}
case "removed": {
const { [action.payload.id]: _, ...rest } = state.byId;
return {
...state,
byId: rest,
allIds: state.allIds.filter((id) => id !== action.payload.id),
};
}
case "filter_changed":
return { ...state, filter: action.payload.filter };
case "reset":
return initialState;
default: {
// ここに到達するのは「Actionに新しいtypeを足したのにcaseを書き忘れたとき」だけ
const _exhaustive: never = action;
return state;
}
}
}
最後のconst _exhaustive: never = action;が網羅性チェックです。新しいactionをUnionに足してcaseを書き忘れると、ここでコンパイルエラーが出るので、「ある日新しいactionを足したらどこかが壊れていた」という事故を未然に防げます。これがreducerをTypeScriptで書く最大のうま味です。
Action Creatorで呼び出し側の型も保証する
dispatch呼び出しのtypoは、Action Creator関数を用意することで完全に防げます。
const addTodo = (text: string): Action => ({
type: "added",
payload: { text },
});
const toggleTodo = (id: number): Action => ({
type: "toggled",
payload: { id },
});
// 使う側
dispatch(addTodo("buy milk"));
dispatch(toggleTodo(1));
Action Creatorは関数呼び出しなので、引数名・引数型までエディタが補完してくれます。tetterscript内のTypeScript活用例はTypeScript 完全実践ガイドも参考になります。
初期化関数(useReducerの第3引数)で初期stateも型安全に
useReducerは第3引数に初期化関数を取れます。これを使うと「propsから初期stateを派生させる」「localStorageから復元する」処理を、型安全に書けます。
function init(initial: State): State {
const saved = localStorage.getItem("todo-state");
if (!saved) return initial;
try {
return JSON.parse(saved) as State;
} catch {
return initial;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState, init);
// initは初回のみ呼ばれる(useStateのlazy initializationと同じ思想)
}
useStateのlazy initializationと同じく、第3引数の関数は初回レンダリング時にしか実行されません。localStorage読み込みやJSON.parseなど、重い処理を初期化に置きたいときの定型パターンです。
useReducerとuseContextの組み合わせ「軽量Redux」パターン
useReducer単体は強力ですが、深い階層の子コンポーネントにdispatchを渡したい場合、propsバケツリレーが発生します。これを解決するのがuseReducer × useContextの組み合わせです。Redux/Redux Toolkitと同じ思想を、Reactの標準APIだけで実現できます。
StateContextとDispatchContextを分離する
Contextは1つにまとめてもいいですが、stateとdispatchを別Contextに分けるとパフォーマンスが大幅に改善します。理由は、stateだけ参照したい子はstate変更時に再レンダリングされる必要があり、dispatchだけ使いたい子(ボタンなど)は再レンダリングしたくないからです。
import { createContext, useReducer, useContext, ReactNode } from "react";
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, initialState, init);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// カスタムHookで使い勝手を整える
export function useTodoState() {
const ctx = useContext(StateContext);
if (!ctx) throw new Error("useTodoState must be used within TodoProvider");
return ctx;
}
export function useTodoDispatch() {
const ctx = useContext(DispatchContext);
if (!ctx) throw new Error("useTodoDispatch must be used within TodoProvider");
return ctx;
}
これで深い子コンポーネントからも、const dispatch = useTodoDispatch();のように1行で取り出せます。propsバケツリレーが完全に消え、各コンポーネントは「自分が必要な情報だけ」を取り出せるようになります。
dispatchの参照は安定している
useReducerが返すdispatchは、コンポーネントが再レンダリングされても常に同じ参照です。これはuseStateのsetStateと同じ性質で、useEffectの依存配列やReact.memoの子コンポーネントに渡しても余計な再実行・再レンダリングを起こしません。
// dispatchは安定参照なので、依存配列に入れても問題ない(むしろ入れるのが定石)
useEffect(() => {
fetch("/api/todos")
.then((r) => r.json())
.then((items) => dispatch({ type: "loaded", payload: items }));
}, [dispatch]);
これと対照的に、自作のコールバック関数をContextで配ると毎回参照が変わりかねません。「configはuseMemoで固める・関数はuseCallbackで固める・dispatchは元から安定」と整理しておくと迷いません。Context設計の詳しい話はuseContext完全ガイドを、メモ化の話はuseMemo vs useCallback完全比較を合わせて読むと立体的に理解できます。
actionに「副作用を含めない」が鉄則
useReducerとuseContextを組み合わせると、つい「actionの中でfetchもしてしまおう」という誘惑が出ます。これは典型的なアンチパターンです。reducerは純粋関数を保ち、fetchやsetTimeoutなどの副作用はイベントハンドラかuseEffect側に置くのが原則です。
// イベントハンドラ側で副作用を起こし、結果だけdispatchする
async function handleSearch(keyword: string) {
dispatch({ type: "search_requested" });
try {
const items = await searchApi(keyword);
dispatch({ type: "search_succeeded", payload: items });
} catch (e) {
dispatch({ type: "search_failed", payload: String(e) });
}
}
これがいわゆる「ThunkらしさをuseReducerでも実現する」パターン。reducerは純粋なまま保たれるので、テストも書きやすく、StrictModeの二重実行にも耐えます。useEffect側に置く場合はuseEffect完全ガイドで解説しているクリーンアップと組み合わせて、競合や中断にも対応できるようにしましょう。
Immer・Redux Toolkit・Zustand・Jotai との比較
useReducerだけで戦えるシーンは多いですが、規模やチーム事情によっては外部ライブラリの方が圧倒的に楽な場合もあります。それぞれの位置づけを把握して、過剰投資にも過小投資にもならないようにしましょう。
state管理ライブラリ比較表
| 手段 | 向いている規模 | 学習コスト | 主な特徴 |
|---|---|---|---|
| useState | 1〜2値 | 最低 | 最小・最速 |
| useReducer | 中規模(画面〜機能単位) | 中 | 純粋関数・型安全・テスト容易 |
| useReducer + useContext | 中〜やや大規模 | 中 | 軽量Redux・依存ゼロ |
| Redux Toolkit | 大規模・チーム開発 | 中〜高 | DevTools・Immer標準・RTK Query |
| Zustand | 中〜大規模 | 低〜中 | Provider不要・軽量・shallow比較 |
| Jotai | 中〜大規模 | 中 | atomベース・依存追跡が細粒度 |
Immerでreducerを劇的に短くする
useReducerで一番だるいのが、不変更新のスプレッド地獄です。ネストが深くなると{ ...state, a: { ...state.a, b: { ...state.a.b, c: v } } }のように地獄絵図になります。これを救うのがImmerのproduce関数です。
import { produce } from "immer";
function todoReducer(state: State, action: Action): State {
return produce(state, (draft) => {
switch (action.type) {
case "added": {
const id = Math.max(0, ...draft.allIds) + 1;
draft.byId[id] = { id, text: action.payload.text, done: false };
draft.allIds.push(id);
break;
}
case "toggled": {
draft.byId[action.payload.id].done =
!draft.byId[action.payload.id].done;
break;
}
// ...他のcaseも素直なミューテートで書ける
}
});
}
draftに対しては「ふつうに代入・pushしてOK」です。Immerが内部でPro/Proxyを使い、変更を検出して新しい不変オブジェクトを作ってくれます。スプレッドのコピペミスが完全に消えるので、ネストが深いstateを扱うチームではImmerはほぼ必須投資です。
Redux Toolkit との比較・移行判断
Redux Toolkit(RTK)は、useReducerで作る「軽量Redux」を本格Reduxに拡張したものと考えられます。createSliceはreducerとaction creatorを同時に生成し、内部でImmerを使うのでスプレッドも不要です。さらにRedux DevTools・middleware・RTK Queryなどのエコシステムが付いてきます。
「useReducerからRTKに移行するライン」をざっくり言語化すると次のとおりです。
- 複数画面・複数機能で、同じstateを参照・更新したい
- 状態遷移の履歴を可視化したい(DevToolsを使いたい)
- 非同期処理のキャンセル・リトライ・キャッシュを統一的に扱いたい(RTK Queryが欲しい)
- チーム規模が5人以上で、state管理の規約をライブラリに乗せたい
逆に1画面に閉じたstate・1機能だけで完結する場合は、useReducerのままで十分です。「機能単位ではuseReducer・アプリ全体ではRTKやZustand」のハイブリッド構成も普通に有効です。
Zustand・Jotaiとの使い分け
Zustandは「ProviderなしでもグローバルstoreにアクセスできるシンプルなAPI」が魅力で、useReducer + useContextの「軽量Redux」をさらに薄くした感触です。Jotaiは「atom単位の細かい依存追跡」が特徴で、useStateの粒度感のままアプリ全体に広げられます。
2026年時点のおおまかな指針は次のとおりです。
- 「Reduxの思想で書きたい・DevTools欲しい」→ RTK
- 「Providerなしでサクッとグローバル化したい」→ Zustand
- 「コンポーネントローカルなatomを積み上げたい」→ Jotai
- 「外部依存を増やしたくない・1機能で完結」→ useReducer(+ useContext)
useReducerでよくある落とし穴と回避策
useReducerは仕組みがシンプルゆえに、初心者が踏みがちな罠もパターン化されています。代表的な5つを潰しておきましょう。
落とし穴1: reducerの中で副作用を起こす
「ついでにconsole.logしておこう」「ここでanalyticsイベントを送ろう」と思って、reducerの中でfetchやログ送信を呼ぶケース。これはStrictModeの二重実行で同じイベントが2回送られるなどのバグを生みます。副作用は必ずイベントハンドラかuseEffect側に置きます。
落とし穴2: 引数のstateを直接書き換える
// NG: stateを破壊している
case "toggled": {
state.byId[action.payload.id].done = true; // ミューテーション
return state; // 参照が変わっていない → Reactが変更を検知できない
}
これだと「stateが変わったのに画面が更新されない」謎バグを生みます。必ず新しいオブジェクト・配列を返すか、Immerを使って間接化します。
落とし穴3: dispatchを依存配列に入れ忘れる/恐れて入れない
dispatchは安定参照なので、useEffectやuseCallbackの依存配列に入れて問題ありません。むしろReactのlintルール(react-hooks/exhaustive-deps)的には入れるのが正しい振る舞いです。「変わらないなら書かなくてもいいのでは」と省くと、lintが怒り、コードレビューでも揉める典型ポイントです。
落とし穴4: actionに重い計算結果を載せて持ち回る
「filterの結果をactionに載せて配ろう」とすると、似たデータが複数箇所に分散します。派生値はstateに持たず、selector関数で算出するのが正解です。
// 派生値はselector関数で導出する(stateには生のtodosとfilterだけ持つ)
const visibleTodos = (state: State) => {
const ids = state.allIds.filter((id) => {
const t = state.byId[id];
if (state.filter === "active") return !t.done;
if (state.filter === "done") return t.done;
return true;
});
return ids.map((id) => state.byId[id]);
};
派生値をuseMemoで包んで使えば、不要な再計算も避けられます。
落とし穴5: 巨大なreducerに何もかも入れる
アプリ全体を1つのreducerで書こうとすると、すぐに数百行になります。機能ごとにreducerを分割し、合成するのが基本です。
// 機能ごとに分割
function authReducer(state, action) { /* ... */ }
function todosReducer(state, action) { /* ... */ }
function uiReducer(state, action) { /* ... */ }
// 親で合成
function rootReducer(state, action) {
return {
auth: authReducer(state.auth, action),
todos: todosReducer(state.todos, action),
ui: uiReducer(state.ui, action),
};
}
これがRedux/RTKのcombineReducersの発想で、useReducerでも同じ手法が使えます。「1機能 = 1reducerファイル」と決めておくと、ファイルが大きくなりすぎず、レビューもしやすくなります。
2026年版・useReducerの位置づけと使いどころ
2025年に正式リリースされたReact Compilerや、Server Componentsの普及、Zustand・Jotai・TanStack Queryの成熟により、「state管理の選択肢」は2020年代前半とは様変わりしました。その中でuseReducerはどう位置づけられるか整理します。
React Compiler時代でも、useReducerは要らなくならない
React Compilerはuseメモ化を自動化しますが、これは「同じ参照を保つ最適化」を肩代わりするものです。useReducerが持つ「state更新ロジックを純粋関数として集約・テスト可能にする」という価値は、コンパイラがあっても置き換わりません。むしろメモ化を意識せず済むので、reducer・dispatchの組み合わせのデメリット(コードが長くなりがち)が相対的に薄れ、useReducerの旨味が出やすくなったとさえ言えます。
Server Components / Server Actions との関係
Next.js App RouterのServer Components(RSC)とServer Actionsを使うと、サーバ側でデータを取得・更新できるので、クライアントstateの量は減ります。それでもクライアント側で「フォームの下書き」「フィルタの選択」「楽観的UI」は必要で、ここがuseReducerの主戦場として残ります。React 19のuseActionStateはuseReducerに近い形でフォーム状態を扱えるAPIで、useReducerの考え方を知っているとすぐに使いこなせます。
本記事の結論「3行サマリー」
- useStateが5個・更新が分散・遷移が複雑になったら、useReducerに移行する
- actionはdiscriminated unionsで型付けし、reducerは純粋関数を死守する
- useReducer × useContextで軽量Redux化し、不要ならRTK/Zustand/Jotaiへスケールアップする
このルートを順番にたどれば、useStateだけでは破綻していたstate管理が、テスト可能で読みやすく、チームでメンテできる形に整います。
useReducer 完全FAQ(7問)
Q1. useStateとuseReducer、いつどっちを使えばいいの?
state数が5個未満・遷移がシンプル・1コンポーネントに閉じる場合はuseStateで十分。state数が5個以上、複数フィールドを同時更新するイベントが3か所以上、現在のstateを見て次のstateを決める分岐が出てきた、のいずれかに当てはまったらuseReducerに移行を検討します。「迷ったらuseStateで書き始めて、限界が来たら段階的にuseReducerへ」がおすすめです。
Q2. useReducerはRedux/Redux Toolkitの代わりになる?
機能・画面単位の中規模state管理なら十分代わりになります。useReducer × useContextでProvider・Action Creator・selectorを揃えれば、ほぼRedux相当の構造が組めます。ただしRedux DevTools・middleware・RTK Queryなどのエコシステムが欲しい場合、また複数機能・複数画面で同じstoreを共有したい場合は、RTKの方が結果的に楽です。
Q3. reducerの中で非同期処理(fetch)はできる?
できません。reducerは純粋関数を保つのが鉄則です。fetchはイベントハンドラかuseEffect側で行い、結果だけdispatchします。これはRedux/RTKの世界観でも同じで、Thunk・Saga・RTK Queryなどはこの「reducerの外で副作用を起こす仕組み」を提供しているにすぎません。
Q4. dispatchは依存配列に入れていい?
入れて問題ありません。むしろReactのreact-hooks/exhaustive-depsルール上は入れるのが正しいです。useReducerが返すdispatchは、コンポーネントの再レンダリングをまたいでも常に同じ参照を保つように設計されています。useStateのsetStateと同じ性質です。
Q5. TypeScriptでactionの型を書くのが面倒。楽な書き方は?
actionをdiscriminated unionsで定義し、Action Creator関数を1個ずつ書くのが、結局もっとも楽で安全です。「面倒だからtype: stringにしてpayload: anyにする」というのは罠で、後でtype名の打ち間違いが温存され、デバッグ工数が爆発します。Redux ToolkitのcreateSliceを使うと、ここを全自動で型付きにできるので、TypeScriptプロジェクトでは検討する価値があります。
Q6. Immerは本当に使った方がいい?
state構造のネストが2階層以上深いプロジェクトでは、ほぼ必須です。スプレッド演算子のコピペミスが消えるだけで、レビュー工数とバグ件数が目に見えて減ります。逆にネストが1階層以下なら、Immer導入のメリットは限定的で、純粋なスプレッドのままで十分です。「ネストが深い・配列を頻繁に更新する」が判断基準です。
Q7. useReducerでパフォーマンス問題が出たときの最初の手当ては?
3つあります。(1) Stateを「頻繁に変わる部分」と「あまり変わらない部分」に分割し、Contextも分ける。(2) selectorをuseMemoで包み、派生値の再計算を抑える。(3) 子コンポーネントをReact.memoでラップし、stateContextを必要な部分だけ購読させる。これでもダメなら、ZustandやJotaiの細粒度購読モデルへの移行を検討します。React DevToolsのProfilerでの計測ベースで判断してください。
まとめ:useStateから卒業し、useReducerでstate設計を意図的に選ぶ
useReducerは「state更新ロジックを純粋関数として集約することで、複雑なstateをテスト可能で予測可能にするためのHook」です。useStateの羅列で破綻し始めたタイミングで投入することで、コードの可読性・テスト容易性・型安全性が一段ジャンプアップします。本記事のキーポイントを最後にもう一度整理します。
- useState 5個・更新分散・遷移複雑化がuseReducer移行のしきい値
- actionは「ユーザー視点で何が起きたか」で命名し、reducerを状態遷移表にする
- discriminated unions + never型の網羅性チェックで抜け漏れを構造的に防ぐ
- useReducer × useContext(state/dispatch分離)でpropsバケツリレーを撲滅
- Immerでネストの深いstateの不変更新を劇的にシンプルにする
- RTK / Zustand / Jotaiは規模・チーム事情で段階的にスケールアップ
- 2026年のReact Compiler時代でも、reducerによるロジック集約の価値は不変
関連する基礎Hookの整理はReact Hooks 完全実践ガイド、useState単体の落とし穴はuseState完全ガイド、Context設計の作法はuseContext完全ガイド、メモ化の使い分けはuseMemo vs useCallback完全比較と合わせて読むと、Reactのstate管理全体が一本の線でつながります。useStateで書き始めて限界が来たら、迷わずuseReducerに切り替えていきましょう。state設計を意図して選べるエンジニアこそが、Reactの中規模以上のアプリで本当に頼られる存在になります。

コメント