React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Extracting State Logic into a Reducer」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
様々な状態更新をもつコンポーネントが多くのイベントハンドラにまたがると、手に負えなくなりそうです。こういう場合、すべての状態更新のロジックをコンポーネントから切り出して、ひとつの関数にまとめることが考えられます。これがリデューサです。
状態のロジックをリデューサにまとめる
コンポーネントが複雑になるにつれて、コンポーネントひとつひとつの状態はどう更新されているのか、ひと目で確かめるのは難しくなってくるでしょう。たとえば、つぎのTaskApp
コンポーネントは、タスクの配列を状態にもちます。そして、タスクの追加や削除、そして編集を担うのが3つの異なるイベントハンドラです(サンプル001)。
const initialTasks: TaskType[] = [
{ id: 0, text: "Visit Kafka Museum", done: true },
{ id: 1, text: "Watch a puppet show", done: false },
{ id: 2, text: "Lennon Wall pic", done: false }
];
export default function TaskApp() {
const [tasks, setTasks] = useState(initialTasks);
const handleAddTask = (text: string) => {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false
}
]);
};
const handleChangeTask = (task: TaskType) => {
setTasks(
tasks.map((_task) => {
if (_task.id === task.id) {
return task;
} else {
return _task;
}
})
);
};
const handleDeleteTask = (taskId: number) => {
setTasks(tasks.filter((_task) => _task.id !== taskId));
};
}
サンプル001■React + TypeScript: Extracting State Logic into a Reducer 01
イベントハンドラはいずれもsetTasks
の呼び出しにより状態を更新します。コンポーネントが拡大すれば、全体に散らばる状態ロジックの量も増えるでしょう。複雑になるのを抑えるため、ロジックはすべてアクセスしやすいひとつの場所に置きましょう。状態ロジックをコンポーネントから切り出した単一の関数が「リデューサ」です。
リデューサは状態を別のやり方で扱います。useState
からuseReducer
に書き替える手順はつぎの3つです。
- 状態の設定からアクションの配信に移行しましょう。
- リデューサ関数を記述してください。
- コンポーネントからリデューサを使用します。
手順1: 状態の設定からアクションの配信に移行する
前掲の3つのイベントハンドラは、いずれも状態設定関数setTasks
を呼んでいました。状態設定のロジックはすべて除いてください。ただし、3つのイベントハンドラは残し、ユーザーの操作に応じて呼び出すのです。
-
handleAddTask(text)
: ユーザーが[Add]ボタンを押したとき。 -
handleChangeTask(task)
: ユーザーが[Change]ボタンを押したとき。 -
handleDeleteTask(taskId)
: ユーザーが[Delete]ボタンを押したとき。
リデューサによる状態の管理は、状態を直接設定するのとは少し異なります。状態の設定というのは、Reactに「何をすべきか」を示すことです。それに対して、イベントハンドラから新たに送る「アクション」は、「ユーザーが今何をしたのか」伝えます(状態更新のロジックがあるのは別の場所です)。イベントハンドラで「タスクを設定」するのではありません。「タスクの追加/変更/削除」のアクションを送るのです。ユーザーの意図がよりわかりやすくなるでしょう。
dispatch
の引数に渡されるオブジェクトが「アクション」です(useReducer
とdispatch
の呼び出しは、あとの手順で定めます)。
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
const handleAddTask = (text: string) => {
dispatch({
type: 'added',
id: nextId++,
text: text
});
};
const handleChangeTask = (task: TaskType) => {
dispatch({
type: 'changed',
task: task
});
};
const handleDeleteTask = (taskId: number) => {
dispatch({
type: 'deleted',
id: taskId
});
};
}
アクションは標準のJavaScriptオブジェクトです。何を収めても問題ありません。ただし、起きたことを知らせる最小限の情報にとどめるのが一般的です。
アクションオブジェクトは、どのように構成しても構いません。
大抵は、何が起こったのかをtype
に与えた文字列で示します。他のフィールドに渡されるのが追加情報です。type
はコンポーネントに固有なので、今回の例ではadded
をadded_task
にしても差し支えありません。何が起きたのかわかりやすい名前を選んでください。
手順2: リデューサ関数を記述する
リデューサ関数は、状態ロジックを置く場所です。引数は現在の状態とアクションオブジェクトのふたつで、つぎの状態を返します。
export const yourReducer: Reducer<State, ActionType> = (
state,
action
) => {
// Reactが設定するつぎの状態を返す
};
Reactの設定する状態はリデューサの戻り値です。状態設定ロジックをイベントハンドラからリデューサ関数(tasksReducer
)に移すには、リデューサはつぎのように定めてください。
- 第1引数: 現在の状態(
tasks
)。 - 第2引数: アクションオブジェクト(
action
)。 - 戻り値: Reactが設定するつぎの状態。
すべての状態ロジックを移したのが、つぎのリデューサ関数です。
export const tasksReducer: Reducer<TaskType[], ActionType> = (
tasks,
action
) => {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false
}
];
}
case 'changed': {
return tasks.map((_task) => {
if (_task.id === action.task.id) {
return action.task;
} else {
return _task;
}
});
}
case 'deleted': {
return tasks.filter((_task) => _task.id !== action.id);
}
default: {
throw Error('Unknown action: ' + (action as any).type);
}
}
};
リデューサ関数は状態(tasks
)を引数で受け取るので、フックとは異なりコンポーネントの外に宣言できます(なお、「状態変数を使う」参照)。インデントも下がって、コードが読みやすくなるでしょう。
case
ブロックはそれぞれをはっきり分けるため、波かっこ{}
で分けることが推奨されます。また、リデューサ関数では、各case
の最後は必ずreturn
で終了しなければなりません。return
を忘れればつぎのcase
に「フォールスルー」してバグにつながります。
リデューサはコンポーネントのコード量を減らす(reduce)ことができるものの、名前の由来は配列のメソッドreduce()
です(「JavaScript: Array.reduce()メソッドで配列要素を入れ替える」参照)。メソッドに渡すコールバック関数がリデューサと呼ばれます。
Reactのリデューサが返すのは、これまでの状態とアクションから算出したつぎの状態です。そうして時間の経過とともに、アクションを状態に積み上げます。この考え方が配列のreduce()
メソッドに通じるのです。
手順3: コンポーネントからリデューサを使用する
いよいよ、リデューサ関数を呼び出します。useReducer
フックとリデューサ関数tasksReducer
をアプリケーションモジュール(src/App.tsx
)にimport
してください。そして、フックuseState
はuseReducer
に置き替えます。
import { useReducer } from 'react';
import { tasksReducer } from "./tasksReducer";
export default function TaskApp() {
// const [tasks, setTasks] = useState(initialTasks);
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
}
useReducer
の引数と戻り値はつぎのとおりです。
- 第1引数: リデューサ関数。
- 第2引数: 状態初期値。
- 戻り値: 配列。
- 第1要素: 現在の状態。
- 第2要素:
dispatch
関数(アクションをリデューサに送ります)。
これで、アプリケーションのコンポーネントTaskApp
のイベントハンドラからリデューサ関数が呼び出せるでしょう。各モジュールのコード全体やアプリケーションの動きについては、以下のサンプル002でお確かめください。
import { useReducer } from 'react';
import { tasksReducer } from './tasksReducer';
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
const handleAddTask = (text: string) => {
dispatch({
type: 'added',
id: nextId++,
text: text
});
};
const handleChangeTask = (task: TaskType) => {
dispatch({
type: 'changed',
task: task
});
};
const handleDeleteTask = (taskId: number) => {
dispatch({
type: 'deleted',
id: taskId
});
};
}
サンプル002■React + TypeScript: Extracting State Logic into a Reducer 02
こうして関心を分けると、コンポーネントのロジックが読みやすくなります。今のイベントハンドラは、アクションを配信して何が起きたのか知らせるだけです。リデューサ関数が、アクションに応じて状態をどう更新するのか決めます。
useState
とuseReducer
を比べる
リデューサにも欠点がないわけではありません。useState
とuseReducer
をいくつかの観点から比べてみましょう。
-
コードのサイズ: 通常、
useState
の方が予め記述しなければならないコードの量は少なくて済みます。useReducer
では、リデューサ関数とアクションの配信の両方を書かなければなりません。けれど、useReducer
も、多くのイベントハンドラの状態更新が似たようなやり方の場合には、コードを減らせることもあります。 -
読みやすさ:
useState
は、状態の更新がシンプルなうちは読みやすいでしょう。複雑になってくると、コンポーネントのコードは肥大化して、追いかけるのが難しくなるかもしれません。そういう場合、useReducer
はどう更新するかのロジックを、何が起きたか伝えるイベントハンドラからはっきりと分けられます。 -
デバッグ:
useState
で起こったバグは、どこで状態の設定を誤っているのか示すのは難しいかもしれません。useReducer
なら、リデューサのログをコンソールに出力して、各状態の更新が確かめられます。(そのアクションにもとづいて)なぜ問題が生じたのかわかるでしょう。アクションが正しければ、問題はリデューサのロジックそのものにあるということです。ただし、useState
と比べて、より多くのコードステップを実行しなければなりません。 -
テスト: リデューサは、コンポーネントに依存しない純粋な関数です。つまり、
export
して個別にテストできます。一般的には、より現実的な環境でコンポーネントをテストするのが最善でしょう。けれど、複雑な状態更新ロジックには、リデューサが特定の初期状態とアクションに対して特定の状態を返すという確認も役立ちます。 -
個人的な好み: リデューサが好きな人も、そうでない人もいるでしょう。好みの問題ですので、差し支えありません。
useState
とuseReducer
は互いに置き替えることができ、機能は同じです。
コンポーネントについて、つぎのような問題がある場合はリデューサの使用をご検討ください。
- 状態の誤った更新によるバグがたびたび発生する。
- コードをもっと構造化したい。
[筆者付記] 複数の状態変数をまとめて処理するロジックで、useReducer
により最適化できる具体例について「React: useReducer()フックで複数stateの処理を行う」でご紹介しました。
リデューサをすべてに使わなければいけないことはありません。自由に組み合わせて結構です。同じコンポーネントでuseState
とuseReducer
を両方使うことも、ももちろんできます。
リデューサを適切に書く
リデューサを書くときは、つぎのふたつの点に留意してください。
- リデューサは純粋でなければなりません。状態設定関数と同じく、リデューサはレンダリング時に実行されます(アクションが加えられるのは、つぎのレンダリングのキューです)。つまり、リデューサは純粋であり、入力が同じなら出力もつねに同じでなければなりません。リクエストの送信やタイムアウトのスケジュール、その他の副作用(コンポーネントの外に影響を与える操作)の実行は禁じられるのです。また、オブジェクトと配列は直接変更せずに更新してください。
-
各アクションが示すのは、データに複数の変更が加わるとしても、ひとつのユーザーインタラクションです。たとえば、リデューサの管理する5つのフィールドをもつフォームがあったとします。ユーザーが[Reset]を押したとき、送るべきアクションは5つの別々の
set_field
でなく、ひとつのreset_form
とすべきでしょう。リデューサのアクションのログを見たとき、どのインタラクションやレスポンスがどの順序で起こるか、再構築するには十分に明確なはずです。デバッグにとても役立つでしょう。
Immerでリデューサを簡潔に書く
状態におけるオブジェクトや配列の更新と同じく、Immerを使えばリデューサが簡潔に書けます。用いるフックはuseImmerReducer
です。配列のミュータブルなメソッドpush()
や配列インデックスによる代入を用いつつ、状態はイミュータブルに保てます。
アプリケーションのモジュールsrc/App.tsx
は、import
したuseImmerReducer
でuseReducer
と差し替えます。ふたつのフックは構文が同じです。
// import { useReducer } from 'react';
import { useImmerReducer } from 'use-immer';
export default function TaskApp() {
// const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
const [tasks, dispatch] = useImmerReducer(tasksReducer, initialTasks);
}
リデューサ関数tasksReducer
は、状態tasks
に替えてラッパーオブジェクトdraft
を引数に受け取ります。そして、draft
オブジェクトに対しては、ミュータブルな処理を加えてしまって構いません(サンプル003)。
// import type { Reducer } from 'react';
import type { ImmerReducer } from 'use-immer';
// export const tasksReducer: Reducer<TaskType[], ActionType> = (
export const tasksReducer: ImmerReducer<TaskType[], ActionType> = (
// tasks,
draft,
action
) => {
switch (action.type) {
case 'added': {
/* return [
...tasks,
{
id: action.id,
text: action.text,
done: false
}
]; */
draft.push({
id: action.id,
text: action.text,
done: false
});
break;
}
case 'changed': {
/* return tasks.map((_task) => {
if (_task.id === action.task.id) {
return action.task;
} else {
return _task;
}
}); */
const index = draft.findIndex((_task) => _task.id === action.task.id);
draft[index] = action.task;
break;
}
case 'deleted': {
// return tasks.filter((_task) => _task.id !== action.id);
return draft.filter((_task) => _task.id !== action.id);
}
}
};
サンプル003■React + TypeScript: Extracting State Logic into a Reducer 03
リデューサは純粋でなければならないので、状態を変更してはいけません。けれど、Immerが提供するのは、変更しても安全な特別のdraft
オブジェクトです。内部的には、Immerがつくる状態のコピーに、draft
への変更が適用されます。そのため、useImmerReducer
が管理するリデューサは、第1引数(draft
)を直接変更でき、状態は返さなくても済むのです。
まとめ
この記事では、つぎのような項目についてご説明しました。
-
useState
をuseReducer
に書き替える手順はつぎのとおりです。- イベントハンドラをアクションの配信にしましょう。
- リデューサ関数の定義により、与えられた状態とアクションからつぎの状態を返してください。
-
useState
をuseReducer
に置き替えます。
- リデューサは、書くべきコードが少し増えるかもしれません。けれど、デバッグやテストに役立ちます。
- リデューサは純粋でなければなりません。
- アクションはひとつのユーザーインタラクションを示します。
- ミュータブルな構文でリデューサを書くとき使うのがImmerです。