ReducerとContextを使ったスケールアップ

Reducerを使用すると、コンポーネントのstate更新ロジックを統合できます。Contextを使用すると、他のコンポーネントに情報を深く渡すことができます。ReducerとContextを組み合わせることで、複雑な画面のstateを管理できます。

このページで学ぶこと

  • ReducerとContextを組み合わせる方法
  • Propsを介したstateとdispatchの受け渡しを避ける方法
  • Contextとstateロジックを別のファイルに保持する方法

ReducerとContextの組み合わせ

Reducerの入門のこの例では、stateはReducerによって管理されています。Reducer関数にはすべての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 }
];

Reducerはイベントハンドラを短く簡潔に保つのに役立ちます。ただし、アプリが成長するにつれて、別の困難に直面する可能性があります。現在、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と関数を渡すのは非常にイライラする可能性があります!

そのため、propsを介してそれらを渡す代わりに、tasks stateとdispatch関数の両方をContextに入れることをお勧めします。これにより、ツリー内のTaskAppより下のコンポーネントは、反復的な「prop drilling」なしに、タスクを読み取ってアクションをdispatchできます。

ReducerとContextを組み合わせる方法は次のとおりです。

  1. Contextを作成する。
  2. StateとdispatchをContextに入れる。
  3. ツリー内の任意の場所でContextを使用する。

ステップ1:Contextを作成する

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

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

それらをツリーに渡すために、2つの別々のContextを作成します。

  • TasksContextは、現在のタスクリストを提供します。
  • 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 }
];

次のステップでは、プロパティの受け渡しを削除します。

ステップ 3: ツリー内の任意の場所でコンテキストを使用する

これで、タスクのリストやイベントハンドラーをツリーの下に渡す必要はありません

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

状態はまだ最上位のTaskAppコンポーネントに「存在」し、useReducerで管理されています。 しかし、そのtasksdispatchは、これらのコンテキストをインポートして使用することにより、ツリー内の下のすべてのコンポーネントで利用できるようになりました。

すべての配線を1つのファイルに移動する

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

import { createContext } from 'react';

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

このファイルは、まもなく混雑します!リデューサーを同じファイルに移動します。次に、同じファイルに新しいTasksProviderコンポーネントを宣言します。このコンポーネントは、すべてのピースを結び付けます。

  1. リデューサーで状態を管理します。
  2. 下のコンポーネントに両方のコンテキストを提供します。
  3. JSXを渡せるように、プロパティとしてchildrenを受け取ります。
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のような他のフックを使用できます。

アプリが成長するにつれて、このようなコンテキストとリデューサーのペアが多数存在する可能性があります。これは、アプリをスケーリングし、ツリーの深い場所にあるデータにアクセスしたいときに、あまり手間をかけずに状態を上位に移動するための強力な方法です。

要約

  • リデューサーとコンテキストを組み合わせて、任意のコンポーネントが上位の状態を読み取り、更新できるようにします。
  • 下のコンポーネントに状態とディスパッチ関数を提供するには
    1. 2つのコンテキストを作成します(状態用とディスパッチ関数用)。
    2. リデューサーを使用するコンポーネントから両方のコンテキストを提供します。
    3. それらを読み取る必要があるコンポーネントからいずれかのコンテキストを使用します。
  • すべての配線を1つのファイルに移動することで、コンポーネントをさらに整理できます。
    • コンテキストを提供するTasksProvider のようなコンポーネントをエクスポートできます。
    • また、useTasksuseTasksDispatch のようなカスタムフックをエクスポートして、それを読み取ることもできます。
  • このように、アプリ内に多くのコンテキストとリデューサーのペアを持つことができます。