2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-06-07

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)コンポーネントからしか使えません。ツリー内の子コンポーネントが状態を読み取ったり変更するには、状態および変更関数のイベントハンドラはつぎのようにプロパティとして渡さなければならないのです(「コンポーネントにプロパティを渡す」参照)。アプリケーションが大きくなってくると、把握しづらくなるでしょう。

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

	return (
		<>

			<TaskList
				tasks={tasks}
				onChangeTask={handleChangeTask}
				onDeleteTask={handleDeleteTask}
			/>
		</>
	);
}
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: コンテクストでデータを深い階層に渡す」参照)。TaskApp下のツリーの子コンポーネントは、プロパティでなくコンテクストから状態を読み取り、dispatch関数が呼び出せるのです

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

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

手順1: コンテクストをつくる

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

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

}

新たに定めるのは、以下のようなコンテクストを提供するモジュールsrc/TasksContext.tsです。ツリーの下層に渡すコンテクストをふたつつくります(「手順1: コンテクストを作成する」参照)。コンテクストの値はTaskAppが与えますのでcreateContextに渡すデフォルト値はnullで構いません。

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

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

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しました。

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: コンテクストを提供する」参照)。

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)は除きましょう。

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.tsximportするのは、コンテクストTasksDispatchContextです。useContextdispatch関数が得られるので、追加のアクション(type: 'added')は直接送ってしまいます。なお、コンテクストTasksDispatchContextから取得する値はnullの可能性があるので、TypeScriptではその判別を加えなければなりません。AddTaskコンポーネントが親から受け取るプロパティはなくなりました。

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

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モジュールは、ふたつのコンテクストTasksContextTasksDispatchContextimportします。TaskListコンポーネントがuseContextTasksContextから取得するのは状態tasksです(TasksDispatchContextは、このあと同じモジュール内のTaskコンポーネントで使います)。コンテクストTasksContextから得た値がnullでない判別も加えてください。

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から受け取ります。でも、コンテクストTasksDispatchContextdispatch関数が得られるので、プロパティからイベントハンドラ(onChangeonDelete)は外しましょう。イベントに応じたアクションは、dispatch関数で送ってしまえるのです。

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は下層のどのコンポーネントからでも、コンテクストのimportuseContextにより使えるようになったのです。

サンプル002■React + TypeScript: Scaling Up with Reducer and Context 02

リデューサとコンテキストをひとつのモジュールにまとめる

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

まず、コンテクストプロバイダは新たなコンポーネントTasksProviderとして、src/TasksContext.tsxにつぎのように定めましょう(拡張子はtsxに改めました)。ここでご注目いただきたいのは、childrenプロパティを受け取り、戻り値のJSXに子ノードとして加えていることです(「コンポーネントにプロパティを渡す」)。なお、useReducer第2引数の初期値(initialTasks)は、TaskAppコンポーネントから移しました。

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)もふたつのコンテクスト(TasksContextTasksDispatchContext)ももはや使いません。これで、ツリーの子ノードはどこからでもコンテクストが得られるのです。

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は削除)。

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.tsximportパスは書き替えなければなりません。

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

状態とdispatch関数をカスタムフックから返す

もうひとつだけ手を加えましょう。状態とdispatch関数は、モジュールsrc/TasksContext.tsxに加える新たなカスタムフック(useTasksContext)から、オブジェクトに含めて返すようにします。

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

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

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

まとめ

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?