状態ロジックを Reducer に抽出する

多くのイベントハンドラにまたがって多数の状態更新を持つコンポーネントは、圧倒される可能性があります。このような場合は、コンポーネント外部の単一の関数(*reducer* と呼ばれます)にすべての状態更新ロジックを統合できます。

学習内容

  • reducer 関数とは何か
  • useState から useReducer にリファクタリングする方法
  • reducer を使用するタイミング
  • reducer を適切に記述する方法

reducer で状態ロジックを統合する

コンポーネントが複雑になるにつれて、コンポーネントの状態が更新されるさまざまな方法をすべて一目で確認することが難しくなる可能性があります。たとえば、以下の TaskApp コンポーネントは、状態に 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 を呼び出します。このコンポーネントが大きくなるにつれて、コンポーネント全体に散りばめられた状態ロジックの量も増加します。この複雑さを軽減し、すべてのロジックを 1 つのアクセスしやすい場所に保持するために、その状態ロジックをコンポーネント外部の単一の関数(**「reducer」**と呼ばれます)に移動できます。

Reducer は状態を処理する別の方法です。useState から useReducer に移行するには、次の 3 つの手順を実行します。

  1. **移動:** 状態の設定からアクションのディスパッチに移動します。
  2. **記述:** reducer 関数を記述します。
  3. **使用:** コンポーネントから reducer を使用します。

手順 1: 状態の設定からアクションのディスパッチに移動する

イベントハンドラは現在、状態を設定することで*何をするか*を指定しています。

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

すべての状態設定ロジックを削除します。残っているのは 3 つのイベントハンドラです。

  • ユーザーが「追加」を押すと、handleAddTask(text) が呼び出されます。
  • ユーザーがタスクを切り替えたり「保存」を押したりすると、handleChangeTask(task) が呼び出されます。
  • ユーザーが「削除」を押すと、handleDeleteTask(taskId) が呼び出されます。

reducer を使用した状態の管理は、状態を直接設定するのと少し異なります。React に状態を設定することで「何をするか」を指示する代わりに、イベントハンドラから「アクション」をディスパッチすることで「ユーザーが何をしたか」を指定します。(状態更新ロジックは他の場所に配置されます!)そのため、イベントハンドラを介して「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 に渡すオブジェクトは「アクション」と呼ばれます。

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: reducer 関数を記述する

reducer 関数は、状態ロジックを配置する場所です。現在の状態とアクションオブジェクトの 2 つの引数を取り、次の状態を返します。

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

React は、reducer から返された内容に状態を設定します。

この例では、状態設定ロジックをイベントハンドラから reducer 関数に移動するために、次の手順を実行します。

  1. 現在の状態(tasks)を最初の引数として宣言します。
  2. 2 番目の引数として action オブジェクトを宣言します。
  3. リデューサーから *次の* 状態を返します(React はこの状態に設定します)。

すべての状態設定ロジックをリデューサー関数に移行したものがこちらです。

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

リデューサー関数は状態(tasks)を引数として受け取るため、コンポーネントの外で宣言できます。 これにより、インデントレベルが減り、コードが読みやすくなります。

注記

上記のコードは if/else 文を使用していますが、リデューサー内では 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() 操作では、配列を受け取り、多数の値から単一の値を「累積」できます。

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

reduce に渡す関数は「リデューサー」と呼ばれます。 *これまでの結果* と *現在の項目* を受け取り、*次の結果* を返します。React リデューサーも同じ考え方の例です。*これまでの状態* と *アクション* を受け取り、*次の状態* を返します。このようにして、時間の経過とともにアクションを状態に累積します。

initialStateactions の配列を使用して reduce() メソッドを使用し、リデューサー関数を渡すことで最終状態を計算することもできます。

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 Hook をインポートします。

import { useReducer } from 'react';

次に、useState を置き換えることができます。

const [tasks, setTasks] = useState(initialTasks);

useReducer を使用すると、次のようになります。

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer Hook は useState に似ています。初期状態を渡す必要があり、状態の値と状態を設定する方法(この場合は dispatch 関数)を返します。ただし、少し異なります。

useReducer Hook は 2 つの引数を取ります。

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

そして、以下を返します。

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

これで完全に接続されました!ここでは、リデューサーはコンポーネントファイルの下部に宣言されています。

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},
];

このように関心を分離すると、コンポーネントロジックが読みやすくなる可能性があります。これで、イベントハンドラーはアクションをディスパッチすることで *何が起こったか* を指定するだけで、リデューサー関数はそれらに応じて *状態がどのように更新されるか* を決定します。

useStateuseReducer の比較 ...

リデューサーにも欠点がないわけではありません!ここで、それらを比較する方法をいくつか紹介します。

  • コードサイズ:一般的に、useState を使用する場合、初期段階で記述するコード量は少なくなります。useReducer を使用する場合、reducer 関数_と_ dispatch アクションの両方を記述する必要があります。ただし、useReducer は、多くのイベントハンドラが類似の方法で状態を変更する場合、コードの削減に役立ちます。
  • 可読性:状態の更新が単純な場合、useState は非常に読みやすいです。更新が複雑になると、コンポーネントのコードが肥大化し、概要を把握しにくくなる可能性があります。この場合、useReducer を使用すると、更新ロジックの_方法_とイベントハンドラの_何が起こったか_を明確に分離できます。
  • デバッグ:useState でバグが発生した場合、状態が_どこで_誤って設定されたのか、_なぜ_設定されたのかを特定するのが難しい場合があります。useReducer を使用すると、reducer に console.log を追加して、すべての状態更新と、それが_なぜ_発生したか(どの action によって発生したか)を確認できます。各 action が正しい場合、間違いは reducer ロジック自体にあることがわかります。ただし、useState よりも多くのコードをステップ実行する必要があります。
  • テスト:reducer は、コンポーネントに依存しない純粋関数です。つまり、reducer を個別にエクスポートしてテストできます。一般的に、コンポーネントはより現実的な環境でテストするのが最善ですが、複雑な状態更新ロジックの場合、reducer が特定の初期状態とアクションに対して特定の状態を返すことをアサートすると便利です。
  • 個人的な好み:reducer が好きな人もいれば、そうでない人もいます。それは問題ありません。好みの問題です。useStateuseReducer は同等であるため、いつでも相互に変換できます。

コンポーネントの状態の誤った更新が原因でバグが発生することが多く、コードに構造を追加したい場合は、reducer の使用をお勧めします。すべてに reducer を使用する必要はありません。自由に組み合わせて使用​​してください!同じコンポーネントで useStateuseReducer を使用することもできます。

reducer を適切に記述する ...

reducer を記述する際には、次の 2 つのヒントを覚えておいてください。

  • reducer は純粋でなければなりません。状態更新関数と同様に、reducer はレンダリング中に実行されます!(アクションは次のレンダリングまでキューに入れられます。)これは、reducer が純粋でなければならないことを意味します。つまり、同じ入力は常に同じ出力を生成します。reducer は、リクエストを送信したり、タイムアウトをスケジュールしたり、副作用(コンポーネントの外部に影響を与える操作)を実行したりしてはなりません。 オブジェクト配列をミューテーションなしで更新する必要があります。
  • 各アクションは、データに複数の変更が生じる場合でも、単一のユーザーインタラクションを表します。たとえば、reducer によって管理される 5 つのフィールドを持つフォームでユーザーが「リセット」を押した場合、5 つの個別の set_field アクションではなく、1 つの reset_form アクションをディスパッチする方が理にかなっています。reducer のすべてのアクションをログに記録すると、そのログは、どのインタラクションまたはレスポンスがどの順序で発生したかを再構築するのに十分明確になります。これはデバッグに役立ちます!

Immer を使用して簡潔な reducer を記述する ...

オブジェクト配列を通常の状態で更新する場合と同様に、Immer ライブラリを使用して reducer をより簡潔にすることができます。ここで、useImmerReducer を使用すると、push または arr[i] = 代入で状態を変更できます。

{
  "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": {}
}

reducer は純粋でなければならないため、状態を変更してはなりません。ただし、Immer は、変更しても安全な特別な draft オブジェクトを提供します。内部的には、Immer は draft に加えた変更を含む状態のコピーを作成します。このため、useImmerReducer によって管理される reducer は、最初の引数を変更でき、状態を返す必要がありません。

要約 ...

  • useState から useReducer に変換するには
    1. イベントハンドラからアクションをディスパッチします。
    2. 特定の状態とアクションの次の状態を返す reducer 関数を記述します。
    3. useStateuseReducer に置き換えます。
  • reducer では少し多くのコードを記述する必要がありますが、デバッグとテストに役立ちます。
  • reducer は純粋でなければなりません。
  • 各アクションは、単一のユーザーインタラクションを表します。
  • 変更スタイルで reducer を記述する場合は、Immer を使用します。

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

現在、`ContactList.js` と `Chat.js` のイベントハンドラには `// TODO` コメントがあります。これが、input に入力しても動作せず、ボタンをクリックしても選択された受信者が変更されない理由です。

これら2つの `// TODO` を、対応するアクションを `dispatch` するコードに置き換えてください。アクションの期待される形状とタイプを確認するには、`messengerReducer.js` の reducer を確認してください。reducer はすでに記述されているため、変更する必要はありません。`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'},
];

...前へ:状態の保持とリセット

...次へ:Context を使用したデータの深い渡し方