Reactで開発を進めるうち、「useStateだけでは状態管理が複雑になってきた」「関連する複数の値をまとめて更新するのが大変」と感じたことはありませんか?
本記事では、そんな課題を解決する強力なフック、useReducerに焦点を当てています。このフックを使えば、状態(state)の更新ロジックをコンポーネントから切り離し、dispatchとreducer関数で一元管理できるようになります。
基本的な構造から、具体的なコードの実例を通じて、useReducerの使い方を解説しています。
useReducerとは何か?
useReducerは、Reactの状態(State)を管理するためのフックの一つです。
Reactで状態を扱う基本的なフックであるuseStateより高度で予測可能なものです。
状態が複雑なオブジェクトや、状態の更新ロジックが複数あり複雑な場合に、そのロジックをコンポーネント外に分離して管理するために使用されます。
useReducerを使うと、状態の更新を「アクション(Action)」という形式で統一的に行うため、状態の変化が予測しやすく、バグが入りにくいという特徴があります。
useReducerを理解するには、useStateやstateを理解しておくことが欠かせません。詳細は下記をご参考ください。
> 【React】stateやuseState, setIndex, セッタ関数とは何かを実例で解説(prevの使い方)
なぜリデューサーというのか?
useReducerには「リデューサー」という言葉が入っています。
リデューサー(reducer)の本来の意味は「減らす、軽減する」です。JavaScriptにおいては、「状態を集約する」という意味を持ちます。
Reactも似ているのですが少し違って「状態の変更ロジックを一か所に『集約』する」という意味になります。
useStateの場合、状態更新のロジック(例: setCount(count + 1))は、コンポーネント内の様々なイベントハンドラ(onClickなど)に分散して書かれます。つまり、コンポーネント毎に管理しなければいけません。
そこで、useReducerを使うことで、状態の更新方法に関するすべてのロジックを、reducer関数という一つの場所に集約します。
これにより、複雑な状態を持つアプリケーションでも、「この状態がどう変わるのか」というルールブックがreducerという関数一つにまとまり、可読性と保守性が大幅に向上します。
この「状態の更新ロジックを一元管理し、次の状態に集約する」という役割から、このパターンは「Reducer」と呼ばれ、フックの名前がuseReducerとなっています。
useReducerの元の意味となる、JavaScriptのリデューサーとは何かについては下記をご参考ください。
useReducerの使い方
useReducerの基本形は以下のようになっています。
import { useReducer } from 'react';
//基本形
const [state, dispatch] = useReducer(reducer, initialState);useReducerに関数と初期値を渡し、最終的にstateとdispatchを分割代入で取得します。
分割代入はReactを扱う上で必須です。分割代入の詳しい解説については下記をご参考ください。
> 【JavaScript】分割代入とは何か?(変数名を変える方法(変更),必要なデータだけ取り出す, デフォルト値, 多次元/ネストしたデータ)
state, dispatch, reducer, initialStateのいずれも、任意の名前をつけることができます。
例えば、以下のようにすることで、現在のデータがuserDataという変数に入り、updateというdispatchにactionを渡すことで、userDataを更新します。
const [userData, update] = useReducer(countLogic, { count: 0 });なお、初期状態は{ count: 0 }です。
主に以下の4つの要素で構成されます。
reducer関数
状態を更新するロジックを定義する純粋な関数です。
reducer(state, action)現在のstateとactionを受け取り、新しいstateを返します。
initialState (初期状態)
コンポーネントが最初にマウントされたときの状態の初期値です。
dispatch関数
アクションを reducer に送るための関数です。
引数としてaction(=変更内容)を渡します。dispatchが呼び出されると、reducer関数が実行され、状態が更新されます。
dispatch(action)action
更新する内容を表すオブジェクトです。
状態を更新するために必要な具体的なデータをプロパティで渡します。その際、プロパティ名は「type」と「payload」を使うことが慣習となっています。
actionオブジェクトは、通常、以下の2つの主要なプロパティを持ちます。
type: 何が起こったか?(どんな種類の更新をしたいか?)を識別する文字列(例:'increment','addUser')。payload: その更新を実行するために必要な情報(具体的なデータ)
例えば、以下のように記述します。
| 動作 | actionオブジェクトの例 | payloadの内容 |
| カウンターを特定数増やす | dispatch({ type: 'add', payload: 5 }) | 増やしたい具体的な数値 5 |
| 特定の商品をカートに追加 | dispatch({ type: 'ADD_ITEM', payload: { id: 101, name: 'Tシャツ' } }) | 追加したい商品のオブジェクト全体 |
| フォームの入力値を更新 | dispatch({ type: 'SET_FIELD', payload: { field: 'name', value: '山田' } }) | どのフィールドを、どんな値にするか |
もっと簡単に!
useReducerをもっとかみ砕いて解説します。
useReducerは、「状態(State)を更新するためのルールを決めておく」ためのフックです。
useStateが「自分で直接テーブルの上の料理(State)を入れ替える」イメージだとすると、useReducerは「キッチン(Reducer)に注文(Action)を渡して、決められた手順で料理(State)を変えてもらう」イメージです。
(例えでレストランを使うのが最適かはわかりませんが、Reactの公式ページでそうしているので倣います。)
useReducerの4つの要素
Reactコンポーネントを「ホール店員」、状態(State)を「テーブルの上の料理の数や種類」だとすると、useReducerの各要素は以下の4つに分類することができます。
useReducerの要素 | レストランでの役割 | 説明 |
| state(状態) | テーブルの上の料理 | 今、カウンターに何がいくつあるか(例: {count: 0})という現在の情報。 |
| action(行動) | 注文書(オーダー) | どんな変更をしたいかという意図を伝えるメッセージ。 |
| dispatch(ディスパッチ) | 注文を伝える行為 | 店員が「注文書(Action)」を「キッチン(Reducer)」に渡すことです。 |
| reducer(リデューサー) | キッチン(料理のルールブック) | 注文書(Action)を見て、現在の料理(State)をどう変えるかを決めるシェフ。 |
仕組みの流れ
- 店員(コンポーネント)は、「ハンバーガーを追加」したいとき、直接料理を触りません。
- Dispatchを使って、「注文書(Action): ‘ADD_BURGER’」をキッチン(Reducer)に渡します。
- キッチン(Reducer)は、現在の料理の状態(State)と注文書(Action)を受け取ります。
- ルールブックに従い、「今のハンバーガーの数に1を足した、新しい料理の状態」を作ってホール係に戻します。
- ホール係は、新しい料理の状態を受け取り、テーブル(画面)を更新します。
メリット
useReducerを使うことで、状態の変更はすべてキッチン(Reducer)で一元管理され、店員(コンポーネント)は「何をしたいか(Action)」を伝えるだけで済むため、変更のルールが明確になり、コードが整理されます。
実例1:useReducerを使う流れ(カウンター)
useReducerの実際の流れを、カウンター機能を作る例で紹介します。
STEP1:初期状態の定義
初めに、stateの初期状態を定義します。カウンターを0から始めたいため、{ count: 0 }とします。
const initialState = {
count: 0
};STEP2:reducer関数の定義
状態を更新するロジック(カウンターを増やす、減らす、リセットする)を定義します。
reducer関数は現在のstateとactionを引数として受け取り、新しいstateを返します。
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
// action.payload を使ってリセット値を受け取ることも可能
return initialState;
default:
// 未知のactionが来た場合は現在のstateをそのまま返すか、エラーを投げる
return state;
}
}処理の中でstateを直接変更せず、新しいオブジェクトを返します。(イミュータブルな更新)
return { count: state.count + 1 };STEP3:コンポーネント内でuseReducerを呼び出す
コンポーネント内でuseReducerを呼び出し、stateとdispatch関数を取得します。
import { useReducer } from 'react';
function Counter() {
// useReducer(reducer関数, 初期状態) の形式で呼び出す
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
{/* 1. stateを表示する */}
<h1>カウンター: {state.count}</h1>
{/* 2. ボタンクリック時に dispatch関数を呼び出す */}
<button onClick={() => dispatch({ type: 'increment' })}>
プラス1
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
マイナス1
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
リセット
</button>
</div>
);
}
// export default Counter;「プラス1」ボタンをクリックすると、dispatchにaction、ここでは { type: ‘increment’ } を渡し、reducer関数を実行します。
return { count: state.count + 1 }; が実行され、カウンター: {state.count}の値が+1されます。
同一ファイルに記述する場合
上記カウンターを実際に使用するときは、reducerとコンポーネントを同一ファイルに記述するのが一般的です。(reducerがより複雑な場合は別ファイルに切り出しを行います)
例えば、Counter.jsxにカウンターの処理を記述し、App.jsxでそのファイルを読み込む場合は以下のようになります。
import React, { useReducer } from 'react'; //フックをインポート
// 1. 状態の初期値 (initialState) を定義
// 一般的にコンポーネントの定義前に記述します。
const initialState = {
count: 0
};
// 2. 状態を更新するロジック (reducer) を定義
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
return state;
}
}
// 3. Counterコンポーネントを定義
function Counter() {
// useReducerを呼び出し、stateとdispatchを取得する
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<h1>カウンター: {state.count}</h1>
<button onClick={() => dispatch({ type: 'increment' })}>
プラス1
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
マイナス1
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
リセット
</button>
</div>
);
}
export default Counter; // コンポーネントをエクスポートuseReducerを使ったCounterコンポーネントをルートコンポーネント(App.jsx)などで呼び出します。
import Counter from './Counter.jsx'; //Counterコンポーネントをインポート
function App() {
return (
<div className="App">
<Counter />
</div>
);
}
export default App;Reducer関数を別ファイルに切り出す場合
Reducer関数の処理が複雑な場合、Reducer関数を別ファイルに切り出すことが一般的です。
先ほどのカウンターの例で、Counterコンポーネント内の、Reducer関数をcounterReducer.jsxに切り出した場合は以下のようになります。
参考としてReducer関数の名前をcounterReducerにしておきます。
export const initialState = {
count: 0
};
export function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
return state;
}
}counterReducer.jsxの中の、initialStateとcounterReducerを分割代入で取得します。
import { useReducer } from 'react';
import { counterReducer, initialState } from './counterReducer';
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<h1>カウンター: {state.count}</h1>
<button onClick={() => dispatch({ type: 'increment' })}>
プラス1
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
マイナス1
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
リセット
</button>
</div>
);
}
export default Counter; // コンポーネントをエクスポートreducerを別ファイルにすることで、コンポーネント(Counter.jsx)はUIの表示に集中し、ロジック(counterReducer.js)は状態の計算に集中できるため、コードの可読性がさらに向上します。
どちらの方法も可能ですが、別ファイルに分離する方がより保守性が高く、テストしやすいコード構造と言えます。
実例2:Todo リスト(追加・削除・完了)
Reducer関数とuseReducerフックを使って、Todoリストを作成することもできます。
Todoリストの状態と入力フォームの状態は別物なので、入力フォームの状態を取得する際は、useStateを使います。
import { useReducer, useState } from "react";
//1. 初期状態の定義
const initialTodos = [];
//2. Reducer関数を定義
function todoReducer(state, action) {
switch (action.type) {
case "ADD":
return [...state, { id: Date.now(), text: action.text, done: false }];
case "TOGGLE":
return state.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case "DELETE":
return state.filter((todo) => todo.id !== action.id);
default:
return state;
}
}
export default function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, initialTodos);
const [text, setText] = useState(""); // ← 入力用にだけ useState を使う
return (
<div>
<h1>Todo</h1>
<form
onSubmit={(e) => {
e.preventDefault();
dispatch({ type: "ADD", text });
setText(""); // 入力欄をクリア
}}
>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">追加</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
onClick={() => dispatch({ type: "TOGGLE", id: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: "DELETE", id: todo.id })}>
削除
</button>
</li>
))}
</ul>
</div>
);
}


