リデューサとコンテクストでスケールアップ

リデューサを使えば、コンポーネントの state 更新ロジックを集約することができます。コンテクストを使えば、他のコンポーネントに深く情報を渡すことができます。そしてリデューサとコンテクストを組み合わせることで、複雑な画面の state 管理ができるようになります。

このページで学ぶこと

  • リデューサとコンテクストを組み合わせる方法
  • state とディスパッチ関数を props を介して渡すことを避ける方法
  • コンテクストと state ロジックを別のファイルに保持する方法

リデューサとコンテクストの組み合わせ

リデューサの導入記事で紹介した以下の例では、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>Day off in Kyoto</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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

リデューサを使うことで、イベントハンドラを短く簡潔に保てます。しかしアプリが大きくなるにつれ、別の困難が発生することがあります。現在 tasks state と dispatch 関数は、トップレベルの TaskApp コンポーネントでしか使えません。他のコンポーネントがタスクリストの読み込みや変更をできるようにするには、現在の state や変更用のイベントハンドラを props として明示的に下に渡していく必要があります。

例えば TaskApp はタスクリストとイベントハンドラを TaskList に渡していますし:

<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

TaskList もイベントハンドラを Task に渡しています:

<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>

このような小さなサンプルではこれはうまく機能しますが、間に数十、数百といったコンポーネントがある場合、すべての state や関数をこのように渡していくのは非常に面倒です!

というわけで、tasks state と dispatch 関数は、props 経由で渡すのではなく、コンテクストに入れる方が望ましい場合があります。こうすることで TaskApp ツリーの下部にある任意のコンポーネントが、“props の穴掘り作業 (prop drilling)” を繰り返さずともタスクのリストを読み取り、アクションをディスパッチすることができるようになります

以下がリデューサをコンテクストと組み合わせる方法です。

  1. コンテクストを作成する
  2. state と dispatch をコンテクストに入れる
  3. ツリー内の任意の場所でコンテクストを使用する

ステップ 1:コンテクストを作成する

useReducer フックは、現在の tasks と、それを更新するための dispatch 関数を返します。

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

これらをツリーに渡すために、2 つの異なるコンテクストを作成しましょう。

  • TasksContext は、現在のタスクのリストを提供 (provide) する。
  • TasksDispatchContext は、コンポーネントがアクションをディスパッチするための関数を提供する。

別のファイルを作ってエクスポートすることで、これらを他のファイルからインポートできるようにします。

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

ここでは、両方のコンテクストにデフォルト値として null を渡しています。実際の値は TaskApp コンポーネントが提供します。

ステップ 2:state と dispatch をコンテクストに入れる

TaskApp コンポーネントで両方のコンテクストをインポートできます。useReducer() の返り値として tasksdispatch を取得し、それらを下位のツリー全体に提供します

import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
...
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

今のところ、プロパティ経由とコンテクスト経由の両方で情報を渡しています。

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.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 (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        <h1>Day off in Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

次のステップで、props による受け渡しを削除します。

ステップ 3:ツリー内の任意の場所でコンテクストを使う

現在すでに、タスクのリストやイベントハンドラを props 経由でツリーに渡す必要はなくなっています。

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

タスクのリストを必要とするコンポーネントは、代わりに TaskContext から読み込むことができます。

export default function TaskList() {
const tasks = useContext(TasksContext);
// ...

タスクリストを更新したい場合は、任意のコンポーネントがコンテクストから dispatch 関数を読み取り、それを呼び出します。

export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add</button>
// ...

TaskApp コンポーネントはイベントハンドラを一切下に渡しておらず、TaskListTask コンポーネントに一切イベントハンドラを渡していません。各コンポーネントが必要なコンテクストを読み込みます。

import { useState, useContext } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskList() {
  const tasks = useContext(TasksContext);
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useContext(TasksDispatchContext);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

state は引き続きトップレベルの TaskApp コンポーネントに「存在」しており、useReducer で管理されています。しかし今や、tasksdispatch は、コンテクストをインポートして使うという形で、下位のツリー全体で利用可能になっているのです。

すべての繋ぎ込みコードを 1 つのファイルに移動

必ずしも必要な作業ではありませんが、リデューサとコンテクストの両方を 1 つのファイルに移動することで、コンポーネントをさらに整理することもできます。現在 TasksContext.js には 2 つのコンテクスト宣言のみが含まれています。

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

このファイルの中身を増やしていきましょう! リデューサを同じファイルに移動します。次に、同ファイルで新しく TasksProvider というコンポーネントを宣言します。このコンポーネントは、すべての要素を繋ぎ合わせるためのものです。

  1. リデューサを使って state を管理する。
  2. 下位のコンポーネントに両方のコンテクストを提供する。
  3. JSX を渡すことができるように、children を prop として受け取るようにする。
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

これにより TaskApp コンポーネントから、あらゆる複雑性と繋ぎ込みコードが消え去ります

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

さらに、TasksContext.js から、コンテクストを使用するための以下のような関数をエクスポートすることもできます。

export function useTasks() {
return useContext(TasksContext);
}

export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}

コンポーネントがコンテクストを読む必要がある場合、これらの関数を使うことができます。

const tasks = useTasks();
const dispatch = useTasksDispatch();

動作は何ら変わりませんが、後でこれらのコンテクストをさらに分割したり、これらの関数にいくつかのロジックを追加したりすることができます。これで、すべてのコンテクストやリデューサの繋ぎ込みコードが TasksContext.js にあることになります。これにより、コンポーネントはクリーンで整頓された状態に保たれ、どこからデータを取得するのかではなく何を表示するのかに集中できるようになります

import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

TasksProvider はタスクの処理方法を知っている画面要素の一部であり、useTasks はツリー内の任意のコンポーネントからタスクを読み出す方法であり、useTasksDispatch はそれを更新する方法である、というように考えることができます。

補足

useTasksuseTasksDispatch のような関数は、カスタムフックと呼ばれます。関数名が use で始まる場合、その関数はカスタムフックと見なされます。これにより useContext のような他のフックを内部で使用できます。

アプリが成長するにつれ、このようなコンテクストとリデューサのペアをたくさん作ることになるかもしれません。この強力な手法により、ツリーの深くでデータにアクセスしたい場合でも、あまり手間をかけずにアプリをスケールさせ、state をリフトアップすることが可能です。

まとめ

  • リデューサとコンテクストを組み合わせることで、任意のコンポーネントが上位の state を読み取り、更新できるようになる。
  • 下位のコンポーネントに state とディスパッチ関数を提供するには以下の手順に従う。
    1. state 用とディスパッチ関数用の 2 つのコンテクストを作成する。
    2. リデューサを使うコンポーネントから両方のコンテクストを提供する。
    3. それらを読む必要があるコンポーネントからコンテクストを使用する。
  • すべての繋ぎ込みコードを 1 つのファイルに移動することで、コンポーネントをさらに整理することができる。
    • コンテクストを提供する TasksProvider のようなコンポーネントをエクスポートする。
    • コンテクストから情報を読むためのカスタムフックである useTasksuseTasksDispatch をエクスポートすることもできる。
  • アプリ内で、このようなコンテクストとリデューサのペアを多く作ることができる。