state ロジックをリデューサに抽出する
多くのイベントハンドラにまたがって state の更新コードが含まれるコンポーネントは、理解が大変になりがちです。このような場合、コンポーネントの外部に、リデューサ (reducer) と呼ばれる単一の関数を作成し、すべての state 更新ロジックを集約することができます。
このページで学ぶこと
- リデューサ関数とは何か
useState
からuseReducer
にリファクタリングする方法- リデューサを使用するタイミング
- リデューサを適切に記述する方法
リデューサで state ロジックを集約する
コンポーネントの複雑さが増すにつれ、コンポーネントの state がどのように更新されるかを一目で確認することが難しくなります。例えば、以下の TaskApp
コンポーネントは state として tasks
という配列を保持しており、タスクの追加・削除・編集を行うために 3 つの異なるイベントハンドラが使用されています。
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); } function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); } function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {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}, ];
それぞれのイベントハンドラは setTasks
を呼び出して state を更新しています。このコンポーネントが大きくなるにつれ、そこにバラバラに書かれる state ロジックの量も増えていきます。複雑さを減らし、すべてのロジックを 1 つの簡単にアクセスできる場所にまとめるために、コンポーネントの外部にある 1 つの関数、すなわちリデューサ関数とよばれるものに、state ロジックを移動させることができます。
リデューサは、state を扱うもう 1 つの方法です。useState
から useReducer
への移行は、次の 3 つのステップで行うことができます。
- state セットをアクションのディスパッチに置き換える。
- リデューサ関数を作成する。
- コンポーネントからリデューサを使用する。
ステップ 1:state セットをアクションのディスパッチに置き換える
現在のイベントハンドラは、state をセットすることで何をするのかを指定しています。
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
state をセットするロジックをすべて削除します。残るのは以下の 3 つのイベントハンドラです。
handleAddTask(text)
は、ユーザが “Add” を押したときに呼び出される。handleChangeTask(task)
は、ユーザがタスクのチェック状態を切り替えたときや “Save” を押したときに呼び出される。handleDeleteTask(taskId)
は、ユーザが “Delete” を押したときに呼び出される。
リデューサを使った state 管理は state を直接セットするのとは少し異なります。React に対して state をセットして「何をするか」を指示するのではなく、イベントハンドラから「アクション」をディスパッチすることで「ユーザが何をしたか」を指定します。(state の更新ロジックは別の場所に書きます!)つまりイベントハンドラで「tasks
をセットする」のではなく、「タスクを追加/変更/削除した」というアクションのディスパッチを行います。これはユーザの意図をより具体的に表現するものです。
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
dispatch
に渡すオブジェクトは “アクション (action)” と呼ばれます。
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
アクションは通常の JavaScript オブジェクトです。何を入れるかはあなたが決めることですが、一般的には「何が起こったか」に関する最小限の情報が含まれているべきです。(dispatch
関数自体は後のステップで追加します。)
ステップ 2:リデューサ関数を作成する
リデューサ関数が、state のロジックを記述する場所です。現在の state とアクションオブジェクトの 2 つを引数に取り、次の state を返すようにします。
function yourReducer(state, action) {
// return next state for React to set
}
React が state をリデューサからの返り値にセットします。
この例では、イベントハンドラからリデューサ関数に state の設定ロジックを移動するために、以下の手順を実施します。
- 現在の state (
tasks
) を最初の引数として宣言する。 action
オブジェクトを 2 番目の引数として宣言する。- リデューサから次の state を返す(React が state をその値にセットする)。
以下がリデューサ関数に移行した state 設定ロジックの全体像です :
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}
リデューサ関数は state (tasks
) を引数として取るため、コンポーネントの外部で宣言することができます。これにより、インデントレベルが減り、コードが読みやすくなります。
さらに深く知る
リデューサによりコンポーネント内のコード量を「削減 (reduce)」することもできますが、実際にはリデューサは配列で行うことができる reduce()
という操作にちなんで名付けられています。
reduce()
操作とは、配列を受け取り、多くの値を 1 つの値に「まとめる」ことができるものです。
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
ここで reduce
に渡している関数が “リデューサ” と呼ばれるものです。これは「ここまでの結果」と「現在の要素」を受け取り、「次の結果」を返す関数です。React のリデューサも同じアイディアを用いています。「ここまでの state」と「アクション」を受け取り、「次の state」を返します。このようにして、経時的に発生する複数のアクションを 1 つの state に「まとめて」いるわけです。
reduce()
メソッドにリデューサを渡して使用することで、初期 state とアクションの配列から最終 state を計算することもできます。
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visit Kafka Museum'}, {type: 'added', id: 2, text: 'Watch a puppet show'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Lennon Wall pic'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
自前でこれを行う必要はないでしょうが、React が行っていることもこれと似ています!
ステップ 3:コンポーネントからリデューサを使用する
最後に、tasksReducer
をコンポーネントに接続する必要があります。React から useReducer
フックをインポートしてください。
import { useReducer } from 'react';
そして、以下の useState
呼び出しを:
const [tasks, setTasks] = useState(initialTasks);
このように useReducer
で置き換えます:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
useReducer
フックは、初期 state を受け取り、state 値とそれを更新するための手段(この場合は dispatch 関数)を返す、という点では useState
に似ています。ただし少し違いもあります。
useReducer
フックは 2 つの引数を取ります。
- リデューサ関数
- 初期 state
そして次のものを返します。
- state 値
- ディスパッチ関数(ユーザアクションをリデューサに「ディスパッチ」する)
さあ、これですべてが繋がりました! ここでは、リデューサがコンポーネントファイルの最後に宣言されています。
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } let nextId = 3; const initialTasks = [ {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}, ];
必要であれば、リデューサを別のファイルに移動させることもできます。
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {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}, ];
このように関心を分離することで、コンポーネントのロジックが読みやすくなります。イベントハンドラはアクションをディスパッチすることで「何が起こったか」を指定し、リデューサ関数がそれらに対する「state の更新方法」を決定します。
useState
と useReducer
の比較
リデューサにもデメリットがないわけではありません。以下のような様々な点で両者には違いがあります。
- コードサイズ:一般に、
useState
を使った方が最初に書くコードは少なくなります。useReducer
の場合、リデューサ関数とアクションをディスパッチするコードを両方書く必要があります。ただし、多くのイベントハンドラが同様の方法で state を変更している場合、useReducer
によりコードを削減できます。 - 可読性:シンプルな state 更新の場合は
useState
を読むのは非常に簡単です。しかし、より複雑になると、コンポーネントのコードが肥大化し、見通すことが難しくなります。このような場合、useReducer
を使うことで、更新ロジックによって書かれる「どう更新するのか」と、イベントハンドラに書かれる「何が起きたのか」とを、きれいに分離することができます。 - デバッグ:
useState
を使っていてバグがある場合、state がどこで誤ってセットされたのか、なぜそうなったかを特定するのが難しくなることがあります。useReducer
を使えば、リデューサにコンソールログを追加することで、すべての state 更新と、それがなぜ起こったか(どのaction
のせいか)を確認できます。それぞれのaction
が正しい場合、リデューサのロジック自体に問題があることが分かります。ただし、useState
と比べてより多くのコードを調べる必要があります。 - テスト:リデューサはコンポーネントに依存しない純関数です。これは、リデューサをエクスポートし、他のものとは別に単体でテストできることを意味します。一般的には、より現実的な環境でコンポーネントをテストするのがベストですが、複雑な state 更新ロジックがある場合は、特定の初期 state とアクションに対してリデューサが特定の state を返すことをテストすることが役立ちます。
- 個人の好み:人によってリデューサが好きだったり、好きではなかったりします。それで構いません。好みの問題です。
useState
とuseReducer
の間を行ったり来たりすることはいつでも可能です。どちらも同等のものです!
バグが頻繁に発生しておりコンポーネントのコードに構造を導入したい場合に、リデューサを利用することをお勧めします。あらゆるコンポーネントにリデューサを使用する必要はありません。自由に組み合わせてください! 同じコンポーネントで useState
と useReducer
を両方使うことも可能です。
良いリデューサの書き方
リデューサを書く際には、以下の 2 つのポイントを心に留めておきましょう。
- リデューサは純粋である必要があります。state の更新用関数と同様に、リデューサはレンダー中に実行されます!(アクションは次のレンダーまでキューに入れられます。)つまりリデューサは純粋でなければならないということです。同じ入力に対して常に同じ出力になります。リクエストを送信したり、タイムアウトを設定したり、副作用(コンポーネントの外部に影響を与える操作)を実行したりすべきではありません。リデューサは、オブジェクトや配列をミューテーション(書き換え)せずに更新する必要があります。
- 各アクションは、複数データの更新を伴う場合であっても単一のユーザ操作を記述するようにします。たとえば、リデューサで管理されるフォームに “Reset” ボタンがあり、そのフォームには 5 つのフィールドがある場合、5 つの別々の
set_field
アクションをディスパッチするよりも、1 つのreset_form
アクションをディスパッチする方が理にかなっています。リデューサの各アクションを記録している場合、そのログは、どんなユーザ操作やレスポンスがどんな順序で発生したかを再構築できるほど明確でなければなりません。これはデバッグに役立ちます!
Immer を使用した簡潔なリデューサの記述
通常の state におけるオブジェクトの更新や配列の更新と同様に、Immer ライブラリを使用してリデューサをより簡潔に記述できます。以下の例では、useImmerReducer
を使って、push
または arr[i] =
という代入を使って state の書き換えを行っています。
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
リデューサは純関数である必要があるため、state を書き換えてはいけません。しかし、Immer は特別な draft
オブジェクトを提供しており、これを書き換えることは安全です。Immer は内部で、draft
に加えたミューテーションが適用された state のコピーを作成します。これが、useImmerReducer
で管理されるリデューサが、最初の引数に書き換えを行えばよく、state を返す必要がない理由です。
まとめ
useState
からuseReducer
に変換するには:- イベントハンドラからアクションをディスパッチする。
- state とアクションから次の state を返すリデューサ関数を書く。
useState
をuseReducer
に置き換える。
- リデューサにより書くコードの量は少し増えるが、デバッグやテストに有用である。
- リデューサは純粋である必要がある。
- 各アクションは、ユーザの操作を 1 つだけ記述する。
- Immer を使えば、ミューテーション型のスタイルでリデューサを書ける。
チャレンジ 1/4: イベントハンドラからアクションをディスパッチ
現在、ContactList.js
と Chat.js
のイベントハンドラは // TODO
というコメントになっています。このため入力フィールドに入力しても動作せず、ボタンをクリックしても選択された送信先が変更されません。
これら 2 つの // TODO
を、適切なアクションを dispatch
するコードに置き換えてください。アクションの構造やタイプを確認するには、messengerReducer.js
内のリデューサをチェックしてください。リデューサ自体はすでに書かれているので、変更する必要はありません。ContactList.js
と Chat.js
でアクションをディスパッチするだけで構いません。
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];