ReactでuseReducerを使おう!!!できれば、immerもセットで
📅 August 09, 2021
•⏱️9 min read
こんにちは。 皆さんReactのuseReducer使ってますか? 私は、正直使っていませんでした。正直、初めてuseReducerの存在を知った時は、「Reduxの劣化版じゃん!なんだこの役立たずは」と思っておりました。 しかし、最近ではもっと早く使っておくべきだったと激しく後悔しております。 今日はそんなuseReducerを軽く紹介してみます。
useReducerを使うメリット
Reactの公式ドキュメントによると、以下のように説明されておりました。
通常、useReducer が useState より好ましいのは、複数の値にまたがる複雑な state ロジックがある場合や、前の state に基づいて次の state を決める必要がある場合です。また、useReducer を使えばコールバックの代わりに dispatch を下位コンポーネントに渡せるようになるため、複数階層にまたがって更新を発生させるようなコンポーネントではパフォーマンスの最適化にもなります。
この説明がすべてだと思います。初期の実装段階で、ややこしいなと思う状態管理をuseStateで扱うと、将来的な改修や機能追加でほぼ間違いなくカオスになるので、コーディング量はやや増えますが、useReducerを使った方が将来幸せになれると思います。
ただ、useReducerを使わず、useState + カスタムフックで似たようなことはできるので、実際にコードを書いて比較してみます。
作るもの Todoアプリ
よくあるTodoアプリです。ただ、機能が少なすぎると「useStateでよくね?」ってなりそうなので、todoのタスクに優先度を調整できるような機能をつけています。
まずは、useStateだけの場合
こんな感じです。 コンポーネントの中に、todoのステート変更のロジックも書かれていて、見通しも悪く将来辛くなりそうですね。 (優先度変更部分がかなりイケてないですが、immerの良さを最後にお伝えしたくこんなイケてない実装にしております。)
// src/components/Todo.tsx
import { useState } from "react";
type Priority = "height" | "mid" | `low`;
type Todo = {
id: number;
title: string;
isDone: boolean;
priority: Priority;
};
const priorityLabel = {
height: "高い",
mid: "中",
low: "低い",
} as const;
const todoTemplate = (id: number, title: string): Todo => ({
id,
title,
isDone: false,
priority: "mid",
});
const prioritys: Priority[] = ["height", "mid", "low"];
export default function Todo() {
const initTodos: Todo[] = [
{ id: 0, title: "走る", isDone: false, priority: "mid" },
];
const [inputValue, setInputValue] = useState("");
const [todos, setTodos] = useState<Todo[]>(initTodos);
// 登録ボタンを押されたときの処理。空文字の場合は登録しない
const onAddButtonClick = () => {
if (!inputValue) return;
const nextId = Math.max(...todos.map((todo) => todo.id)) + 1;
setTodos((prev) => [...prev, todoTemplate(nextId, inputValue)]);
};
// 削除時の処理
const onDeleteButtonClick = (id: number) => {
setTodos((prev) => {
const nextTodos = prev.filter((todo) => todo.id !== id);
return nextTodos;
});
};
// プライオリティ変更
const onPriorityButtonCick = (id: number, diff: number) => {
setTodos((prev) => {
const nextTodos = prev.map((todo) => {
if (todo.id === id) {
return {
...todo,
priority:
prioritys?.[prioritys.indexOf(todo.priority) + diff] ??
todo.priority,
};
}
return todo;
});
return nextTodos;
});
};
return (
<>
<h1>TodoアプリでuseReducerを使う</h1>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
></input>
<button onClick={onAddButtonClick}>登録</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.isDone}></input>
<label>{`${todo.title} --- 優先度: ${
priorityLabel[todo.priority]
}`}</label>
<button onClick={() => onDeleteButtonClick(todo.id)}>削除</button>
<button onClick={() => onPriorityButtonCick(todo.id, -1)}>
優先度UP
</button>
<button onClick={() => onPriorityButtonCick(todo.id, 1)}>
優先度DOWN
</button>
</li>
))}
</ul>
</>
);
}
カスタムフックに切り出すパターン
カスタムフックとして、todoのステート変更の処理を別ファイルに切り出しました。 だいぶスッキリしました。ただ、カスタムフックの内部にまんべんなく、ステート変更のロジックが広がっていて、処理が複雑になると読むのが大変になりそうですね。
// src/components/Todo.tsx
import { useState } from "react";
import { useTodos } from "../hooks/useTodos";
export type Priority = "height" | "mid" | `low`;
export type Todo = {
id: number;
title: string;
isDone: boolean;
priority: Priority;
};
const priorityLabel = {
height: "高い",
mid: "中",
low: "低い",
} as const;
export const todoTemplate = (id: number, title: string): Todo => ({
id,
title,
isDone: false,
priority: "mid",
});
export const prioritys: Priority[] = ["height", "mid", "low"];
export default function Todo() {
const initTodos: Todo[] = [
{ id: 0, title: "走る", isDone: false, priority: "mid" },
];
const [inputValue, setInputValue] = useState("");
const { todos, onAddButtonClick, onDeleteButtonClick, onPriorityButtonCick } =
useTodos(initTodos);
return (
<>
<h1>TodoアプリでuseReducerを使う</h1>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
></input>
<button onClick={() => onAddButtonClick(inputValue)}>登録</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.isDone}></input>
<label>{`${todo.title} --- 優先度: ${
priorityLabel[todo.priority]
}`}</label>
<button onClick={() => onDeleteButtonClick(todo.id)}>削除</button>
<button onClick={() => onPriorityButtonCick(todo.id, -1)}>
優先度UP
</button>
<button onClick={() => onPriorityButtonCick(todo.id, 1)}>
優先度DOWN
</button>
</li>
))}
</ul>
</>
);
}
// src/hooks/useTodos.ts
import { useState } from "react";
import { prioritys, Todo, todoTemplate } from "../components/Todo";
export const useTodos = (initTodos: Todo[]) => {
const [todos, setTodos] = useState<Todo[]>(initTodos);
// 登録ボタンを押されたときの処理。空文字の場合は登録しない
const onAddButtonClick = (title: string) => {
if (!title) return;
const nextId = Math.max(...todos.map((todo) => todo.id)) + 1;
setTodos((prev) => [...prev, todoTemplate(nextId, title)]);
};
// 削除時の処理
const onDeleteButtonClick = (id: number) => {
setTodos((prev) => {
const nextTodos = prev.filter((todo) => todo.id !== id);
return nextTodos;
});
};
// プライオリティ変更
const onPriorityButtonCick = (id: number, diff: number) => {
setTodos((prev) => {
const nextTodos = prev.map((todo) => {
if (todo.id === id) {
return {
...todo,
priority:
prioritys?.[prioritys.indexOf(todo.priority) + diff] ??
todo.priority,
};
}
return todo;
});
return nextTodos;
});
};
return { todos, onAddButtonClick, onDeleteButtonClick, onPriorityButtonCick };
};
useReducerを使うパターン
(src/components/Todo.tsx
のコンポーネント側は変更がないので省略します。)
useReducerを使うパターンは、todoのステート偏光のロジックがreducerの中に集約されています。
更にスッキリしましたが、残念ながらuseStateとカスタムフックのパターンよりコード行数が増えています。
また、優先度の変更を行う場合、stateをイミュータブルとして扱う必要があるため、前のtodoを一度コピーして、新しい優先度で上書きするという処理が非常に読みづらいですね。
import { useReducer } from "react";
import { prioritys, Todo, todoTemplate } from "../components/Todo";
type TodoAction =
| { type: "addTodo"; payload: string }
| { type: "deleteTodo"; payload: number }
| { type: "changePriority"; payload: { id: number; diff: number } };
const todoReducer = (state: Todo[], action: TodoAction) => {
switch (action.type) {
case "addTodo":
const nextId = Math.max(...state.map((todo) => todo.id)) + 1;
return [...state, todoTemplate(nextId, action.payload)];
case "deleteTodo":
return [...state.filter((todo) => action.payload !== todo.id)];
case "changePriority":
const nextTodos = state.map((todo) => {
if (todo.id === action.payload.id) {
return {
...todo,
priority:
prioritys?.[
prioritys.indexOf(todo.priority) + action.payload.diff
] ?? todo.priority,
};
}
return todo;
});
return nextTodos;
default:
return state;
}
};
export const useTodos = (initTodos: Todo[]) => {
const [todos, dispatch] = useReducer(todoReducer, initTodos);
const onAddButtonClick = (title: string) => {
dispatch({ type: "addTodo", payload: title });
};
const onDeleteButtonClick = (id: number) => {
dispatch({ type: "deleteTodo", payload: id });
};
const onPriorityButtonCick = (id: number, diff: number) => {
dispatch({ type: "changePriority", payload: { id, diff } });
};
return { todos, onAddButtonClick, onDeleteButtonClick, onPriorityButtonCick };
};
useReducerとimmerを使うパターン
(src/components/Todo.tsx
のコンポーネント側は変更がないので省略します。)
immerを導入することにより、useReducer単体で使うパターンのデメリットの
- 記述量が増える - stateをコピーするのが面倒(ネストが増えると特に)
が解消された気がします。immerとはイミュータブルなオブジェクトをあたかもミュータブルであるかのように扱える凄いやつです。(説明間違ってたらすません。詳しくはドキュメント見てください Introduction to Immer | Immer
個人的には、優先度変更の処理がだいぶ読みやすくなったのでは?と思っております。
import { useReducer } from "react";
import { prioritys, Todo, todoTemplate } from "../components/Todo";
import produce from "immer";
type TodoAction =
| { type: "addTodo"; payload: string }
| { type: "deleteTodo"; payload: number }
| { type: "changePriority"; payload: { id: number; diff: number } };
const todoReducer = produce((state: Todo[], action: TodoAction) => {
switch (action.type) {
case "addTodo":
const nextId = Math.max(...state.map((todo) => todo.id)) + 1;
state.push(todoTemplate(nextId, action.payload));
break;
case "deleteTodo":
return [...state.filter((todo) => action.payload !== todo.id)];
case "changePriority":
const todo = state.find((todo) => todo.id === action.payload.id);
if (!todo) return state
todo.priority =
prioritys?.[prioritys.indexOf(todo.priority) + action.payload.diff] ??
todo.priority;
break;
default:
return state;
}
});
export const useTodos = (initTodos: Todo[]) => {
const [todos, dispatch] = useReducer(todoReducer, initTodos);
const onAddButtonClick = (title: string) => {
dispatch({ type: "addTodo", payload: title });
};
const onDeleteButtonClick = (id: number) => {
dispatch({ type: "deleteTodo", payload: id });
};
const onPriorityButtonCick = (id: number, diff: number) => {
dispatch({ type: "changePriority", payload: { id, diff } });
};
return { todos, onAddButtonClick, onDeleteButtonClick, onPriorityButtonCick };
};
比較してみた感想
おそらくここまで読んでくださった方の中には、「useStateとカスタムフック切り出しだけでよくね?」 useReducer使うことで、逆に見づらくね?って思われる方もいるかと思います。
ただ、実際の業務では、カスタムフックの中にもuseEffect
による副作用の処理や、他にも入り組んだ処理が色々と入ってくる場合が多いかとおもいます。
そういった場合は、ステート変更のロジックをreducer
に集約して、カスタムフックの中では、必要なタイミングでディスパッチするとした方が見通しが良いのではないかと思っています。
ただ、useReducerも使用経験が少ないので、これはこれで新たなツラミを生むかもしれませんが・・・。 その時はその時で、また新たな方法を模索しようかと思います。
それではまたー。