React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、基本解説の「Scaling Up with Reducer and Context」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
コンポーネントの状態更新のロジックを統合するのがリデューサです。また、コンテクストを用いれば、情報をコンポーネントツリー階層深くの子に渡せます。このふたつを組み合わせることにより、複雑な画面の状態も管理しやすくなるでしょう。
リデューサとコンテクストを組み合わせる
つぎのサンプル001は、「React + TypeScript: 状態のロジックをリデューサに切り出す」のサンプル002と基本的に同じ内容のコードです。モジュールsrc/tasksReducer.ts
のリデューサ関数tasksReducer
が、すべての状態更新ロジックを担っています。
サンプル001■React + TypeScript: Scaling Up with Reducer and Context 01
リデューサが役立つのは、イベントハンドラを短く簡潔に保てることです。ただし、このコードではリデューサに備わる状態(tasks
)とdispatch
関数は、ツリーにおけるトップレベルのアプリケーション(TaskApp
)コンポーネントからしか使えません。ツリー内の子コンポーネントが状態を読み取ったり変更するには、状態および変更関数のイベントハンドラはつぎのようにプロパティとして渡さなければならないのです(「コンポーネントにプロパティを渡す」参照)。アプリケーションが大きくなってくると、把握しづらくなるでしょう。
export default function TaskApp() {
return (
<>
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}
export const TaskList: FC<TaskListProps> = ({
tasks,
onChangeTask,
onDeleteTask
}) => {
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Task task={task} onChange={onChangeTask} onDelete={onDeleteTask} />
</li>
))}
</ul>
);
};
前掲サンプル001くらいのコンポーネント数やツリー階層なら、とくに支障はありません。けれど、コンポーネント数が増え、深い階層の子コンポーネントにプロパティを渡さなければならなくなると、かなり手間にもなり得ます。
そこで、コンテクストの出番です。状態(tasks
)とdispatch
関数をコンテクストに置いてしまえば、プロパティのバケツリレーが避けられます(「React + TypeScript: コンテクストでデータを深い階層に渡す」参照)。TaskApp
下のツリーの子コンポーネントは、プロパティでなくコンテクストから状態を読み取り、dispatch
関数が呼び出せるのです。
リデューサとコンテクストを組み合わせるつぎの3つの手順をご説明しましょう。
- コンテクストをつくる。
- 状態と
dispatch
関数をコンテクストに加える。 - ツリーの中から(階層は問わず)コンテクストを使う。
手順1: コンテクストをつくる
ルートモジュールsrc/App.tsx
のコンポーネントTaskApp
は、つぎのようにuseReducer
フックで状態(task
)とdispatch
関数を得ていました。
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
}
新たに定めるのは、以下のようなコンテクストを提供するモジュールsrc/TasksContext.ts
です。ツリーの下層に渡すコンテクストをふたつつくります(「手順1: コンテクストを作成する」参照)。コンテクストの値はTaskApp
が与えますのでcreateContext
に渡すデフォルト値はnull
で構いません。
-
TasksContext
: 状態となるタスクリスト(tasks
)。 -
TasksDispatchContext
: 状態変更のアクションを送るdispatch
関数。
コンテクストは他のモジュールから使える(import
できる)ように、それぞれexport
してください。
import { createContext } from "react";
import type { Dispatch } from "react";
import type { TaskType } from "./App";
import type { ActionType } from "./tasksReducer";
export const TasksContext = createContext<TaskType[] | null>(null);
export const TasksDispatchContext = createContext<Dispatch<ActionType> | null>(
null
);
なお、リデューサ(src/tasksReducer.ts
)に定める型ActionType
も、コンテクストモジュール(src/TasksContext.ts
)で使うため、export
しました。
// type ActionType =
export type ActionType =
| { type: "added"; id: number; text: string }
| { type: "changed"; task: TaskType }
| { type: "deleted"; id: number };
手順2: 状態とdispatch関数をコンテクストに加える
ふたつのコンテクストをimport
するのはルートモジュールsrc/App.tsx
です。TaskApp
コンポーネントが返すJSXは、ふたつのコンテクストプロバイダProvider
で包んでください。value
プロパティに与えるのは、それぞれuseReducer
から得て下層のツリー全体に提供する状態tasks
と関数dispatch
です(「手順3: コンテクストを提供する」参照)。
import { TasksContext, TasksDispatchContext } from "./TasksContext";
export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
// <>
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
</TasksDispatchContext.Provider>
</TasksContext.Provider>
/* </> */
);
}
手順3: ツリーの中からコンテクストを使う
これで、状態もそれを変更するイベントハンドラも、プロパティでツリーの下層に渡す必要はありません。まず、ルートモジュールsrc/App.tsx
です。タスクを追加するAddTask
コンポーネントのプロパティからイベントハンドラ(onAddTask
)は除きましょう。
// let nextId = 3;
export default function TaskApp() {
/* const handleAddTask = (text: string) => {
dispatch({
type: "added",
id: nextId++,
text: text
});
}; */
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{/* <AddTask onAddTask={handleAddTask} /> */}
<AddTask />
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
タスク追加のモジュールsrc/AddTask.tsx
がimport
するのは、コンテクストTasksDispatchContext
です。useContext
でdispatch
関数が得られるので、追加のアクション(type: 'added'
)は直接送ってしまいます。なお、コンテクストTasksDispatchContext
から取得する値はnull
の可能性があるので、TypeScriptではその判別を加えなければなりません。AddTask
コンポーネントが親から受け取るプロパティはなくなりました。
// import { useState } from 'react';
import { useContext, useState } from 'react';
import { TasksDispatchContext } from './TasksContext';
/* type Props = {
onAddTask: (text: string) => void;
}; */
let nextId = 3;
// export const AddTask: FC<Props> = ({ onAddTask }) => {
export const AddTask: FC = () => {
const dispatch = useContext(TasksDispatchContext);
return (
<>
<button
onClick={() => {
setText('');
if (!dispatch) return;
// onAddTask(text);
dispatch({
type: 'added',
id: nextId++,
text: text
});
}}
>
Add
</button>
</>
);
};
つぎに、TaskList
コンポーネントに関わる修正です。ルートモジュールsrc/App.tsx
で、状態(tasks
)とふたつのイベントハンドラ(onChangeTask
およびonDeleteTask
)をコンポーネントに渡すプロパティから除きます。これで、TaskList
コンポーネントが受け取るプロパティもなくなりました。
export default function TaskApp() {
/* const handleChangeTask = (task: TaskType) => {
dispatch({
type: "changed",
task: task
});
};
const handleDeleteTask = (taskId: number) => {
dispatch({
type: "deleted",
id: taskId
});
}; */
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{/* <TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/> */}
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}
src/TaskList.tsx
モジュールは、ふたつのコンテクストTasksContext
とTasksDispatchContext
をimport
します。TaskList
コンポーネントがuseContext
でTasksContext
から取得するのは状態tasks
です(TasksDispatchContext
は、このあと同じモジュール内のTask
コンポーネントで使います)。コンテクストTasksContext
から得た値がnull
でない判別も加えてください。
// import { useState } from 'react';
import { useContext, useState } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext';
/* type TaskListProps = {
tasks: TaskType[];
onChangeTask: (text: TaskType) => void;
onDeleteTask: (taskId: number) => void;
}; */
/* export const TaskList: FC<TaskListProps> = ({
tasks,
onChangeTask,
onDeleteTask
}) => { */
export const TaskList: FC = () => {
const tasks = useContext(TasksContext);
return (
<ul>
{/* {tasks.map((task) => ( */}
{tasks &&
tasks.map((task) => (
<li key={task.id}>
{/* <Task task={task} onChange={onChangeTask} onDelete={onDeleteTask} /> */}
<Task task={task} />
</li>
))}
</ul>
);
};
Task
コンポーネントは、個別のタスク(task
)は親のTaskList
から受け取ります。でも、コンテクストTasksDispatchContext
でdispatch
関数が得られるので、プロパティからイベントハンドラ(onChange
とonDelete
)は外しましょう。イベントに応じたアクションは、dispatch
関数で送ってしまえるのです。
type TaskProps = {
/* onChange: (text: TaskType) => void;
onDelete: (taskId: number) => void; */
};
// const Task: FC<TaskProps> = ({ task, onChange, onDelete }) => {
const Task: FC<TaskProps> = ({ task }) => {
const dispatch = useContext(TasksDispatchContext);
if (isEditing) {
taskContent = (
<>
<input
value={task.text}
onChange={({ target: { value } }) => {
/* onChange({
...task,
text: value
}); */
if (dispatch)
dispatch({ type: 'changed', task: { ...task, text: value } });
}}
/>
</>
);
} else {
}
return (
<label>
<input
type="checkbox"
checked={task.done}
onChange={({ target: { checked } }) => {
/* onChange({
...task,
done: checked
}); */
if (dispatch) {
dispatch({ type: 'changed', task: { ...task, done: checked } });
}
}}
/>
{/* <button onClick={() => onDelete(task.id)}>Delete</button> */}
<button
onClick={() => {
if (dispatch) {
dispatch({ type: 'deleted', id: task.id });
}
}}
>
Delete
</button>
</label>
);
};
3つの手順を終えたのが、つぎのサンプル002です。状態はトップレベルのTaskApp
コンポーネントが保持し、useReducer
により管理されていることは変わっていません。けれど、状態(tasks
)とdispatch
は下層のどのコンポーネントからでも、コンテクストのimport
とuseContext
により使えるようになったのです。
サンプル002■React + TypeScript: Scaling Up with Reducer and Context 02
リデューサとコンテキストをひとつのモジュールにまとめる
リデューサとコンテキストを組み合わせることはできました。さらに、ふたつをひとつのモジュールにまとめると、コンポーネントがよりすっきりします。今はふたつのコンテクストをつくっているだけのモジュールsrc/TasksContext.tsx
に、ロジックを移してゆくのです。
まず、コンテクストプロバイダは新たなコンポーネントTasksProvider
として、src/TasksContext.tsx
につぎのように定めましょう(拡張子はtsx
に改めました)。ここでご注目いただきたいのは、children
プロパティを受け取り、戻り値のJSXに子ノードとして加えていることです(「コンポーネントにプロパティを渡す」)。なお、useReducer
第2引数の初期値(initialTasks
)は、TaskApp
コンポーネントから移しました。
// import { createContext } from 'react';
import { createContext, useReducer } from 'react';
// import type { Dispatch, FC } from 'react';
import type { Dispatch, FC, ReactNode, Reducer } from 'react';
type Props = {
children: ReactNode;
};
const initialTasks: TaskType[] = [
];
export const TasksProvider: FC<Props> = ({ children }) => {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
};
すると、ルートモジュールsrc/App.tsx
は、import
したTasksProvider
コンポーネントでTaskApp
が返すJSXを包むだけです。リデューサ(tasksReducer
)もふたつのコンテクスト(TasksContext
とTasksDispatchContext
)ももはや使いません。これで、ツリーの子ノードはどこからでもコンテクストが得られるのです。
// import { useReducer } from 'react';
// import { TasksContext, TasksDispatchContext } from './TasksContext';
import { TasksProvider } from './TasksContext';
// import { tasksReducer } from './tasksReducer';
// export type TaskType = { id: number; text: string; done: boolean };
/* 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 }
]; */
export default function TaskApp() {
// const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
/* <TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}> */
<TasksProvider>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
{/* </TasksDispatchContext.Provider>
</TasksContext.Provider> */}
</TasksProvider>
);
}
リデューサのロジックは、そっくりsrc/TasksContext.tsx
に移してください(src/tasksReducer.ts
は削除)。
// import { createContext } from 'react';
import { createContext, useReducer } from 'react';
// import type { Dispatch, FC } from 'react';
import type { Dispatch, FC, Reducer } from 'react';
// import type { TaskType } from './App';
// import type { ActionType } from './tasksReducer';
export type TaskType = { id: number; text: string; done: boolean };
type ActionType =
;
// export const tasksReducer: Reducer<TaskType[], ActionType> = (
const tasksReducer: Reducer<TaskType[], ActionType> = (
tasks,
action
) => {
switch (action.type) {
}
};
型TaskType
をコンテクストモジュールsrc/TasksContext.tsx
に移しましたので、src/TaskList.tsx
のimport
パスは書き替えなければなりません。
// import type { TaskType } from './App';
import type { TaskType } from './TasksContext';
込み入った状態とその更新のロジックは、モジュールsrc/TasksContext.tsx
に移されました。おかげで、すっきりしたのがsrc/App.tsx
です。それぞれのモジュールのコード全体や動きについては、つぎのサンプル003でお確かめください。
サンプル003■React + TypeScript: Scaling Up with Reducer and Context 03
状態とdispatch
関数をカスタムフックから返す
もうひとつだけ手を加えましょう。状態とdispatch
関数は、モジュールsrc/TasksContext.tsx
に加える新たなカスタムフック(useTasksContext
)から、オブジェクトに含めて返すようにします。
// import { createContext, useReducer } from 'react';
import { createContext, useContext, useReducer } from 'react';
// export const TasksContext = createContext<TaskType[] | null>(null);
const TasksContext = createContext<TaskType[] | null>(null);
// export const TasksDispatchContext = createContext<Dispatch<ActionType> | null>(
const TasksDispatchContext = createContext<Dispatch<ActionType> | null>(null);
export const useTasksContext = () => {
const tasks = useContext(TasksContext);
const dispatch = useContext(TasksDispatchContext);
return { tasks, dispatch };
};
すると、状態(tasks
)とdispatch
を使うコンポーネントはこのフックから取得すればよく、コンテクストに触れる必要がありません。
// import { useContext, useState } from 'react';
import { useState } from 'react';
// import { TasksDispatchContext } from './TasksContext';
import { useTasksContext } from './TasksContext';
export const AddTask: FC = () => {
// const dispatch = useContext(TasksDispatchContext);
const { dispatch } = useTasksContext();
};
// import { useContext, useState } from 'react';
import { useState } from 'react';
// import { TasksContext, TasksDispatchContext } from './TasksContext';
import { useTasksContext } from './TasksContext';
const Task: FC<TaskProps> = ({ task }) => {
// const dispatch = useContext(TasksDispatchContext);
const { dispatch } = useTasksContext();
};
export const TaskList: FC = () => {
// const tasks = useContext(TasksContext);
const { tasks } = useTasksContext();
};
これで、状態とdispatch
関数は新たに加えたカスタムフック(useTasksContext
)が返すようになりました。コンテクストプロバイダ下のツリーに含まれるどの子コンポーネントも、フックにより状態を読み込んだり更新したりできるのです。モジュールごとの具体的なコードと動きについては、つぎのサンプル004をご覧ください。
サンプル004■React + TypeScript: Scaling Up with Reducer and Context 04
まとめ
この記事では、つぎのような項目についてご説明しました。
- リデューサとコンテクストを組み合わせると、ツリー内のどのコンポーネントからでも上層の状態が取得・変更できます。
- 状態と
dispatch
関数を下層のコンポーネントに提供する手順はつぎの3つです。- 状態と
dispatch
関数のふたつのコンテクストを作成してください。 - ふたつのコンテクストは、リデューサを用いるコンポーネントから提供します。
- それらのコンテクストを使用するのは、状態の取得・更新が必要な子コンポーネントです。
- 状態と
- 状態にかかわるロジックをひとつのモジュールにまとめると、さらにコンポーネントが整理できます。
- リデューサとコンテクストをまとめて、コンポーネントとして
export
できるのがコンテクストプロバイダ(前掲コードのTasksProvider
)です。 - 状態と
dispatch
関数をカスタムフックで返せば、各コンポーネントはコンテクストには触れる必要がありません。
- リデューサとコンテクストをまとめて、コンポーネントとして