React + TypeScript: リデューサとコンテクストで拡張性を高める

React公式サイトのドキュメントが2023年3月16日に改訂されました(「[Introducing react.dev](https://react.dev/blog/2023/03/16/introducing-react-dev)」参照)。本稿は、基本解説の「[Scaling Up with Reducer and Context](https://react.dev/learn/scaling-up-with-reducer-and-context)」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。

なお、本シリーズ解説の他の記事については「[React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ](https://qiita.com/FumioNonaka/items/d70585bd3c0a6d4edc24)」をご参照ください。

コンポーネントの状態更新のロジックを統合するのがリデューサです。また、コンテクストを用いれば、情報をコンポーネントツリー階層深くの子に渡せます。このふたつを組み合わせることにより、複雑な画面の状態も管理しやすくなるでしょう。


# リデューサとコンテクストを組み合わせる
つぎのサンプル001は、「[React + TypeScript: 状態のロジックをリデューサに切り出す](https://qiita.com/FumioNonaka/items/6e7541567b38727d4f6c)」の[サンプル002](https://qiita.com/FumioNonaka/items/6e7541567b38727d4f6c#%E6%89%8B%E9%A0%863-%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%8B%E3%82%89%E3%83%AA%E3%83%87%E3%83%A5%E3%83%BC%E3%82%B5%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%99%E3%82%8B)と基本的に同じ内容のコードです。モジュール`src/tasksReducer.ts`のリデューサ関数`tasksReducer`が、すべての状態更新ロジックを担っています。

#### サンプル001■React + TypeScript: Scaling Up with Reducer and Context 01
https://stackblitz.com/edit/react-typescript-scaling-up-with-reducer-and-context-01?file=src%2FApp.tsx

リデューサが役立つのは、イベントハンドラを短く簡潔に保てることです。ただし、このコードでは**リデューサに備わる状態(`tasks`)と`dispatch`関数は、ツリーにおけるトップレベルのアプリケーション(`TaskApp`)コンポーネントからしか使えません**。ツリー内の子コンポーネントが状態を読み取ったり変更するには、状態および変更関数のイベントハンドラはつぎのようにプロパティとして渡さなければならないのです(「[コンポーネントにプロパティを渡す](https://qiita.com/FumioNonaka/items/2ba530a906bf97480f80#%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AB%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%82%92%E6%B8%A1%E3%81%99passing-props-to-a-component)」参照)。アプリケーションが大きくなってくると、把握しづらくなるでしょう。

```react:src/App.tsx
export default function TaskApp() {

	return (
		<>

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

```react:src/TaskList.tsx
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: コンテクストでデータを深い階層に渡す](https://qiita.com/FumioNonaka/items/babd1168d259416872ed)」参照)。**`TaskApp`下のツリーの子コンポーネントは、プロパティでなくコンテクストから状態を読み取り、`dispatch`関数が呼び出せるのです**。

リデューサとコンテクストを組み合わせるつぎの3つの手順をご説明しましょう。

1. コンテクストを**つくる**。
2. 状態と`dispatch`関数をコンテクストに**加える**。
3. ツリーの中から(階層は問わず)コンテクストを**使う**。

## 手順1: コンテクストをつくる
ルートモジュール`src/App.tsx`のコンポーネント`TaskApp`は、つぎのように`useReducer`フックで状態(`task`)と`dispatch`関数を得ていました。

```react:src/App.tsx
export default function TaskApp() {
	const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

}
```

新たに定めるのは、以下のようなコンテクストを提供するモジュール`src/TasksContext.ts`です。ツリーの下層に渡すコンテクストをふたつつくります(「[手順1: コンテクストを作成する](https://qiita.com/FumioNonaka/items/babd1168d259416872ed#%E6%89%8B%E9%A0%861-%E3%82%B3%E3%83%B3%E3%83%86%E3%82%AF%E3%82%B9%E3%83%88%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)」参照)。コンテクストの値は`TaskApp`が与えますので`createContext`に渡すデフォルト値は`null`で構いません。

* `TasksContext`: 状態となるタスクリスト(`tasks`)。
* `TasksDispatchContext`: 状態変更のアクションを送る`dispatch`関数。

コンテクストは他のモジュールから使える(`import`できる)ように、それぞれ`export`してください。

```react:src/TasksContext.ts
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`しました。

```react:src/tasksReducer.ts
// 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: コンテクストを提供する](https://qiita.com/FumioNonaka/items/babd1168d259416872ed#%E6%89%8B%E9%A0%863-%E3%82%B3%E3%83%B3%E3%83%86%E3%82%AF%E3%82%B9%E3%83%88%E3%82%92%E6%8F%90%E4%BE%9B%E3%81%99%E3%82%8B)」参照)。

```react:src/App.tsx
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`)は除きましょう。

```react:src/App.tsx
// 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`コンポーネントが親から受け取るプロパティはなくなりました。

```react:src/AddTask.tsx
// 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`コンポーネントが受け取るプロパティもなくなりました。

```react:src/App.tsx
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`でない判別も加えてください。

```react:src/TaskList.tsx
// 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`関数で送ってしまえるのです。

```react:src/TaskList.tsx
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
https://stackblitz.com/edit/react-typescript-scaling-up-with-reducer-and-context-02?file=src%2FApp.tsx


# リデューサとコンテキストをひとつのモジュールにまとめる
リデューサとコンテキストを組み合わせることはできました。さらに、ふたつをひとつのモジュールにまとめると、コンポーネントがよりすっきりします。今はふたつのコンテクストをつくっているだけのモジュール`src/TasksContext.tsx`に、ロジックを移してゆくのです。

まず、コンテクストプロバイダは新たなコンポーネント`TasksProvider`として、`src/TasksContext.tsx`につぎのように定めましょう(拡張子は`tsx`に改めました)。ここでご注目いただきたいのは、`children`プロパティを受け取り、戻り値のJSXに子ノードとして加えていることです(「[コンポーネントにプロパティを渡す](https://qiita.com/FumioNonaka/items/2ba530a906bf97480f80#%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AB%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3%E3%82%92%E6%B8%A1%E3%81%99passing-props-to-a-component)」)。なお、`useReducer`第2引数の初期値(`initialTasks`)は、`TaskApp`コンポーネントから移しました。

```react:src/TasksContext.tsx
// 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`)ももはや使いません。これで、ツリーの子ノードはどこからでもコンテクストが得られるのです。

```react:src/App.tsx
// 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`は削除)。

```react:src/TasksContext.tsx
// 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`パスは書き替えなければなりません。

```react:src/TaskList.tsx
// 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
https://stackblitz.com/edit/react-typescript-scaling-up-with-reducer-and-context-03?file=src%2FApp.tsx

## 状態と`dispatch`関数をカスタムフックから返す
もうひとつだけ手を加えましょう。状態と`dispatch`関数は、モジュール`src/TasksContext.tsx`に加える新たな[カスタムフック](https://react.dev/learn/reusing-logic-with-custom-hooks)(`useTasksContext`)から、オブジェクトに含めて返すようにします。

```react:src/TasksContext.tsx
// 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`を使うコンポーネントはこのフックから取得すればよく、コンテクストに触れる必要がありません。

```react:src/AddTask.tsx
// 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();

};
```

```react:src/TaskList.tsx
// 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
https://stackblitz.com/edit/react-typescript-scaling-up-with-reducer-and-context-04?file=src%2FApp.tsx


# まとめ
この記事では、つぎのような項目についてご説明しました。

* リデューサとコンテクストを組み合わせると、ツリー内のどのコンポーネントからでも上層の状態が取得・変更できます。
* 状態と`dispatch`関数を下層のコンポーネントに提供する手順はつぎの3つです。
    1. 状態と`dispatch`関数のふたつのコンテクストを作成してください。
    2. ふたつのコンテクストは、リデューサを用いるコンポーネントから提供します。
    3. それらのコンテクストを使用するのは、状態の取得・更新が必要な子コンポーネントです。
* 状態にかかわるロジックをひとつのモジュールにまとめると、さらにコンポーネントが整理できます。
    - リデューサとコンテクストをまとめて、コンポーネントとして`export`できるのがコンテクストプロバイダ(前掲コードの`TasksProvider`)です。
    - 状態と`dispatch`関数をカスタムフックで返せば、各コンポーネントはコンテクストには触れる必要がありません。