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 つのステップで行うことができます。

  1. state セットをアクションのディスパッチに置き換える
  2. リデューサ関数を作成する。
  3. コンポーネントからリデューサを使用する。

ステップ 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 関数自体は後のステップで追加します。)

補足

アクションオブジェクトはどのような形状でも構いません。

一般的な慣習としては、何が起こったかを説明する文字列の type を与え、他のフィールドを使って追加情報を渡します。type はコンポーネント固有のもので、この例では 'added''added_task' といったものがよいでしょう。何が起こったかを説明する名前を選んでください!

dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});

ステップ 2:リデューサ関数を作成する

リデューサ関数が、state のロジックを記述する場所です。現在の state とアクションオブジェクトの 2 つを引数に取り、次の state を返すようにします。

function yourReducer(state, action) {
// return next state for React to set
}

React が state をリデューサからの返り値にセットします。

この例では、イベントハンドラからリデューサ関数に state の設定ロジックを移動するために、以下の手順を実施します。

  1. 現在の state (tasks) を最初の引数として宣言する。
  2. action オブジェクトを 2 番目の引数として宣言する。
  3. リデューサから次の 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) を引数として取るため、コンポーネントの外部で宣言することができます。これにより、インデントレベルが減り、コードが読みやすくなります。

補足

上記のコードでは if/else 文が使用されていますが、リデューサの中では switch 文を使うことが一般的です。結果は同じですが、switch 文の方が一目でわかりやすくなります。

これ以降のドキュメントでは、switch 文を以下のように使用します。

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);
}
}
}

case ブロックを {} の波括弧で囲むことをお勧めします。これにより、異なる case の中で宣言された変数が互いに衝突するのを防ぐことができます。また、case は通常 return で終わるべきです。return を忘れると、コードが次の case に「流れて」してしまい、誤りが発生することがあります!

もしまだ switch 文に慣れていないのであれば、if/else を使用しても全く問題ありません。

さらに深く知る

なぜリデューサと呼ばれるのか?

リデューサによりコンポーネント内のコード量を「削減 (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 つの引数を取ります。

  1. リデューサ関数
  2. 初期 state

そして次のものを返します。

  1. state 値
  2. ディスパッチ関数(ユーザアクションをリデューサに「ディスパッチ」する)

さあ、これですべてが繋がりました! ここでは、リデューサがコンポーネントファイルの最後に宣言されています。

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 の更新方法」を決定します。

useStateuseReducer の比較

リデューサにもデメリットがないわけではありません。以下のような様々な点で両者には違いがあります。

  • コードサイズ:一般に、useState を使った方が最初に書くコードは少なくなります。useReducer の場合、リデューサ関数とアクションをディスパッチするコードを両方書く必要があります。ただし、多くのイベントハンドラが同様の方法で state を変更している場合、useReducer によりコードを削減できます。
  • 可読性:シンプルな state 更新の場合は useState を読むのは非常に簡単です。しかし、より複雑になると、コンポーネントのコードが肥大化し、見通すことが難しくなります。このような場合、useReducer を使うことで、更新ロジックによって書かれる「どう更新するのか」と、イベントハンドラに書かれる「何が起きたのか」とを、きれいに分離することができます。
  • デバッグuseState を使っていてバグがある場合、state がどこで誤ってセットされたのか、なぜそうなったかを特定するのが難しくなることがあります。useReducer を使えば、リデューサにコンソールログを追加することで、すべての state 更新と、それがなぜ起こったか(どの action のせいか)を確認できます。それぞれの action が正しい場合、リデューサのロジック自体に問題があることが分かります。ただし、useState と比べてより多くのコードを調べる必要があります。
  • テスト:リデューサはコンポーネントに依存しない純関数です。これは、リデューサをエクスポートし、他のものとは別に単体でテストできることを意味します。一般的には、より現実的な環境でコンポーネントをテストするのがベストですが、複雑な state 更新ロジックがある場合は、特定の初期 state とアクションに対してリデューサが特定の state を返すことをテストすることが役立ちます。
  • 個人の好み:人によってリデューサが好きだったり、好きではなかったりします。それで構いません。好みの問題です。useStateuseReducer の間を行ったり来たりすることはいつでも可能です。どちらも同等のものです!

バグが頻繁に発生しておりコンポーネントのコードに構造を導入したい場合に、リデューサを利用することをお勧めします。あらゆるコンポーネントにリデューサを使用する必要はありません。自由に組み合わせてください! 同じコンポーネントで useStateuseReducer を両方使うことも可能です。

良いリデューサの書き方

リデューサを書く際には、以下の 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 に変換するには:
    1. イベントハンドラからアクションをディスパッチする。
    2. state とアクションから次の state を返すリデューサ関数を書く。
    3. useStateuseReducer に置き換える。
  • リデューサにより書くコードの量は少し増えるが、デバッグやテストに有用である。
  • リデューサは純粋である必要がある。
  • 各アクションは、ユーザの操作を 1 つだけ記述する。
  • Immer を使えば、ミューテーション型のスタイルでリデューサを書ける。

チャレンジ 1/4:
イベントハンドラからアクションをディスパッチ

現在、ContactList.jsChat.js のイベントハンドラは // TODO というコメントになっています。このため入力フィールドに入力しても動作せず、ボタンをクリックしても選択された送信先が変更されません。

これら 2 つの // TODO を、適切なアクションを dispatch するコードに置き換えてください。アクションの構造やタイプを確認するには、messengerReducer.js 内のリデューサをチェックしてください。リデューサ自体はすでに書かれているので、変更する必要はありません。ContactList.jsChat.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'},
];