TypeScriptにおけるuseReducer・useContextによるグローバルStateの実装方法
はじめに
ReactによるContextAPIを用いたグローバルStateの仕様・実装方法について学習したため、復習と応用を兼ねてTypeScriptによる書き換えを行いました。
その際に、React単体のみの実装と結構、実装方法や記述が異なり苦戦したため、備忘録として記事にさせていただきます。
※Todoアプリの実装例となります
グローバルStateの実装
早速、実装内容を見ていきましょう。
まず、createContext
でコンテキストを作成します。
(比較のためReactの記述から記載してます)
Reactでの実装の場合は、下記のようにcreateContext
を呼び出すだけでOKです。
import { createContext } from "react";
const TodoContext = createContext();
一方で、TypeScriptの場合、createContext
に初期値(defaultValue)を設定しないとundefined
が渡されてエラーとなります。
(React.createContext)
解決策
contextAPI
と連携させるために、useReducer
を内包したカスタムフックを作成し、それと同時に、createContext
のdefaultValue
となるオブジェクトも作成し、それを渡すように実装します。
(「どういうこと?!」となると思うので、実際のコードを見てみましょう)
useReducerを内容したカスタムフックの作成
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の初期値の生成
const defaultTodoReducer: ReturnType<typeof useTodoReducer> = {
todos: [],
dispatch: () => {},
};
ポイントとなるのは、ReturnType <typeof useTodoReducer>
型で実装することです。
この部分は非常に重要です。
理由としては、ここで生成した値、つまりcreateContext
の引数とContext.Provider
のvalueの型が必ず一致する必要があるためです。
そのため、カスタムフックとは別に、createContext
の初期値を生成する必要があるのです。
(これも実際に見た方が早いと思うので、次の項を参照してみてください)
グローバルContextの作成
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の作成は完了です。
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