はじめに
フックAPIリファレンスでuseReducer
の存在は知っていましたが、実際に使ったことがなかったので、使い所を調べて使ってみた話をします。
useReducerとは
useState
の代用品
役割は、useState
と大体おんなじ感じってことですかね。
現在の state を dispatch メソッドとペアにして返します(もし Redux に馴染みがあれば、これがどう動作するのかはご存じでしょう)。
useReducer が useState より好ましいのは、複数の値にまたがる複雑な state ロジックがある場合
複数階層にまたがって更新を発生させるようなコンポーネントではパフォーマンスの最適化にもなります。
useState
まみれになったりするの嫌だなぁと思ったことあります。
それを1つのオブジェクトというかRedux
のStore
のように、管理できるような感じってことですかね。
Todoアプリのサンプルを作成
Todoアプリのサンプルを作成しました。
ファイル構成はこんな感じです。
-
src/components/pages/Todos/index.tsx
- Todoの追加フォーム、リストデータの表示
-
src/stores/TodosStore/index.ts
-
Todos
コンポーネントからuseStore
を呼び出している。 -
useState
を使って、loading
とtodos
を管理 - Todoの追加用と削除用のメソッドを提供
-
画面の動きはこんな感じです。
バックエンドは用意していないので、それっぽく見せるために、Todoの追加時、削除時に、ダミーのfetch
を使って、3秒間、ロードが発生するようにしています。
Todo追加時の状態変更の流れは以下の3ステップです。
-
loading
をtrue
に更新 -
todos
に1件追加更新 -
loading
をfalse
に更新
頭の中のイメージ的には、ステップが1つ少ないです。
-
loading
をtrue
に更新 -
todos
に1件追加とloading
をfalse
に更新
今回は、state
が2つなので、それほど複雑ではありませんが、useReducer
を使って、Redux
のStore
のように1つのオブジェクトのように管理してみます。
useReducerでリファクタリング
useState
を使っているところを、useReducer
に変更
-const [loading, setLoading] = useState(initialState.loading);
-const [todos, setTodos] = useState(initialState.todos);
+const [state, dispatch] = useReducer(reducer, initialState);
reducer
はこんな感じで定義しておきます。
const reducer = (state: TodosStore.State, action: TodosStore.Action) => {
switch (action.type) {
case "fetch":
return fetchAction(state);
case "add":
return addAction(state, action);
case "del":
return delAction(state, action);
default:
return { ...initialState };
}
};
useReducer
で受けったdispatch
は、dispatch({ type: "fetch" });
という感じで呼び出すと、reducer
関数の"fetch"
のcase
が実行されるイメージです。
reducer
は、元のstate
を各処理によって、変更を加えた新しいstate
を返すように記述します。
各処理はこんな感じで定義しておきます。
const fetchAction = (state: TodosStore.State): TodosStore.State => {
return { ...state, loading: true };
};
const addAction = (
state: TodosStore.State,
{ data: { name } }: TodosStore.AddAction
): TodosStore.State => {
return {
...state,
loading: false,
todos: [
...state.todos,
{
id: new Date().getTime(),
name,
},
],
};
};
const delAction = (
state: TodosStore.State,
{ data: { id } }: TodosStore.DelAction
): TodosStore.State => {
const todos = state.todos.filter((todo) => todo.id !== id);
return {
...state,
loading: false,
todos,
};
};
Todos
コンポーネント側から呼び出している、Todo
追加と削除の関数内で、dispatch
を使ったstate
の更新方法に修正します。
+ const dispatchAddTodo = (name: string) => {
+ dispatch({ type: "fetch" });
+
+ return dummyFetch().then(() => dispatch({ type: "add", data: { name } }));
+ };
+
+ const dispatchDelTodo = (id: number) => {
+ dispatch({ type: "fetch" });
+
+ return dummyFetch().then(() => dispatch({ type: "del", data: { id } }));
+ };
const store: TodosStore.Store = {
state,
dispatchAddTodo,
dispatchDelTodo,
};
return store;
}
動きに変化はありませんが、頭の中のイメージ通りのステップ数になったので、レンダリング回数が減りました。
というわけで、リファクタリング完了!
おわりに
ベストプラクティスは分かっていませんが、自分なりにRedux
を意識して、Store
、Reducer
、Action
用にファイルを分けて、簡潔に実装できたのではないかと思います、
今回は、1ファイルに全ての機能を実装していますが、実際は、ファイルが分かれたりして、でも同じstate
を参照した状態でアクションを実行したいことがあると思います。
その時は、useContext
を使えば、各ファイルから同じstate
を参照できるような仕組みにもできると思います。(props
で頑張って渡す方法でも...)
state
に直接変更を加えるわけではなく、Store
側でアクションを定義して、そちら側でstate
を変更する仕組みの方が、安全性があると思いますし、何より、たくさん管理したい状態がある程、1箇所にまとまっていて、そこにアクションを追加していく方が、分かりやすいのかなと思いました。