😀

TypeScriptにおけるuseReducer・useContextによるグローバルStateの実装方法

2022/12/26に公開

はじめに

ReactによるContextAPIを用いたグローバルStateの仕様・実装方法について学習したため、復習と応用を兼ねてTypeScriptによる書き換えを行いました。

その際に、React単体のみの実装と結構、実装方法や記述が異なり苦戦したため、備忘録として記事にさせていただきます。
※Todoアプリの実装例となります

グローバルStateの実装

早速、実装内容を見ていきましょう。
まず、createContextでコンテキストを作成します。
(比較のためReactの記述から記載してます)

Reactでの実装の場合は、下記のようにcreateContextを呼び出すだけでOKです。

sample.jsx
import { createContext } from "react";

const TodoContext = createContext();

一方で、TypeScriptの場合、createContextに初期値(defaultValue)を設定しないとundefinedが渡されてエラーとなります。
(React.createContext)

解決策

contextAPIと連携させるために、useReducerを内包したカスタムフックを作成し、それと同時に、createContextdefaultValueとなるオブジェクトも作成し、それを渡すように実装します。
(「どういうこと?!」となると思うので、実際のコードを見てみましょう)

useReducerを内容したカスタムフックの作成

src/lib/TodoReducer.ts
import { useReducer } from "react";
import { TodoList } from "../types/TodoList";

const todosList: TodoList[] = [
	{
		id: 1,
		content: "店予約する",
		editing: false,
	},
	{
		id: 2,
		content: "卵買う",
		editing: false,
	},
	{
		id: 3,
		content: "郵便出す",
		editing: false,
	},
];

type Action = {
	type: string;
	todo: TodoList;
};

const reducer = (prevTodos: TodoList[], { type, todo }: Action) => {
	switch (type) {
		// Todo登録
		case "todo/add":
			return [...prevTodos, todo];

		// Todo削除
		case "todo/delete":
			return prevTodos.filter((_todo) => _todo.id !== todo.id);

		// Todo更新
		case "todo/update":
			return prevTodos.map((_todo) => {
				return _todo.id === todo.id ? { ..._todo, ...todo } : { ..._todo };
			});

		default:
			return prevTodos;
	}
};

const useTodoReducer = () => {
	const [todos, dispatch] = useReducer(reducer, todosList);

	return { todos, dispatch };
};

// createContextの初期値用オブジェクト
const defaultTodoReducer: ReturnType<typeof useTodoReducer> = {
	todos: [],
	dispatch: () => {},
};

export { defaultTodoReducer };
export default useTodoReducer;

順に解説しますが、以下の変数については説明不要かと思いますので割愛させていただきます。

  • todosList
  • Action
  • reducer

まずは、useTodoReducer関数ですが、これは、useReducerを実装し、State(todos)Dispatch(更新関数)を返すカスタムフックです。

このカスタムフックが作成できたら、defaultTodoReducerですが、これがcreateContextに渡す初期値です。

createContextの初期値の生成

src/lib/TodoReducer.ts
const defaultTodoReducer: ReturnType<typeof useTodoReducer> = {
	todos: [],
	dispatch: () => {},
};

ポイントとなるのは、ReturnType <typeof useTodoReducer>型で実装することです。

この部分は非常に重要です。
理由としては、ここで生成した値、つまりcreateContextの引数とContext.Providerのvalueの型が必ず一致する必要があるためです。

そのため、カスタムフックとは別に、createContextの初期値を生成する必要があるのです。
(これも実際に見た方が早いと思うので、次の項を参照してみてください)

グローバルContextの作成

TodoContext.tsx
import { useContext, createContext } from "react";
import useTodoReducer, { defaultTodoReducer } from "../lib/TodoRedcer";

// createContextのdefaultValue
const { todos, dispatch } = defaultTodoReducer;

const TodoContext = createContext(todos);
const TodoDispatchContext = createContext(dispatch);

type Props = {
	children: JSX.Element[];
};

const TodoProvider = ({ children }: Props) => {
    // カスタムフックの返り値
	const { todos, dispatch } = useTodoReducer();

	return (
		<TodoContext.Provider value={todos}>
			<TodoDispatchContext.Provider value={dispatch}>
				{children}
			</TodoDispatchContext.Provider>
		</TodoContext.Provider>
	);
};

/**
 * Todoリストを登録しているコンテキストを返す関数
 *
 * @returns TodoContext
 */
const useTodoContext = () => useContext(TodoContext);

/**
 * 更新関数を登録しているコンテキストを返す関数
 *
 * @returns TodoDispatchContext
 */
const useDispatchTodoContext = () => useContext(TodoDispatchContext);

export { useTodoContext, useDispatchTodoContext };
export default TodoProvider;

解説すると、まずcreateContextでContextの作成をしますが、ここで先ほど生成した初期値を渡します。

次に関数コンポーネント内で、カスタムフックの返り値を分割代入で、定義します。
(わざわざ分割代入しなくても、ContextもProviderも1つ作成して、Providerのvalueで関数実行したらいいのでは・・・?と思う方もいるかと思いますが、それについては後述します)

次にコンポーネントの返り値ですが、React単体での実装と同様、Providerを作成し、value属性State(todos)更新関数(dispatch)を設定します。

そして、useContextで作成したContextを使えるようにします。
私の場合は、Contextを作成したファイル内で、useContextを返す関数を定義し、それらをexportするよう実装しています。

あとはコンポーネントで以下のようにProviderで囲むとグローバルStateの作成は完了です。

Todo.tsx
import TodoProvider from "../context/TodoContext";
import Form from "./Form";
import List from "./List";

/**
 * Todoリストの全機能を実装するコンポーネント
 *
 * @returns
 */
const Todo = () => {
	return (
		<TodoProvider>
			<List />
			<Form />
		</TodoProvider>
	);
};

export default Todo;

また、useContextで取り扱う値については、以下のように使用してます。

import { useTodoContext, useDispatchTodoContext } from "../context/TodoContext";

const todos = useTodoContext();
const dispatch = useDispatchTodoContext();

最後に先ほどの「ContextとProviderを1つ作成した方がいいのでは・・・」という問いについて回答します。
結論を言うとtodos(State)dispatch(更新関数)Contextを分けているのは、不必要な再レンダリングを防止するためです。

若干、本筋とは逸れますが、コンポーネントでStateを使用していなくて、更新関数のみ使用する場合、Contextを分けないと再レンダリングが発生します。

理由としては、ContextにStateを持たせているので、更新関数しかなくても更新のトリガーが働き際レンダリングが起きるためです。
これはReactの仕様によるものなので、パフォーマンスまで意識するなら、可能な限り、Stateと更新関数は分けた方がいいかと思います。

以上で、TypeScriptによるグローバルStateの実装は完了です。

参考文献

Discussion