useStateとuseReducerって実は大きな違いがある
はじめに
都内でフロントエンドエンジニアとして開発をしているものです。
具体的な技術スタックは下記です
- React.js
- Next.js
- TypeScript
- GraphQL(Apollo)
今回は React.js の Hooks であるuseStateとuseReducerの違いについて色々述べていきたいと思います。
この記事を書こうと思ったきっかけ
今まで私はuseStateを利用して基本的に開発しており、useReducerを扱う機会がありませんでした。
そんな中ある複雑な画面をuseStateで実装すると品質を担保できず、バグを生んでしまったからです。(著者の実装力不足もあると思いますが。。)
しかし同じ画面をuseReducerで書き直すことで品質を保つことができ、世間で紹介されている useReducer とは違ったメリットが見えてきたので発信することを決めました。
React.js を学んでいく中でuseReducerは初学者とって扱いにくく、useStateでも同じ処理を実現できることから敬遠する方も多いのではないでしょうか?
そんな方々が
-
useStateとuseReducerの違いがはっきりわかった - 実装する画面内容によっては
useReducerを採用してみよう
こんな心持ちになっていただけると幸いです。
この記事の対象者
-
React.jsとTypeScriptの基本実装に慣れている -
単体テストにというワードを理解している -
useStateとuseReducerの大きな違いがいまいちわからない
今回作る機能の要件
useStateとuseReducerを紹介するためにフォームを作っていきます。
具体的な要件は下記です
- 連絡先登録フォーム
- ユーザーは 5 件まで連絡先を追加することができる
- 連絡先の設定は下記が存在
- 閲覧モード
- メールアドレスを編集不可
- 編集ボタンを押下することで編集モードに切り替え
- 編集モード
- メールアドレスの編集が可能
- メールアドレスの保存が可能
- 保存を押下したら閲覧モードとなる
- メールアドレスの削除が可能
- 閲覧モード
成果物
今回の成果物はcodesandboxにおいています。
記事でも一部紹介していますが、こちらで実際の動作を確認してみてください
下準備
- 準備として
閲覧モードと編集モードのコンポーネントを作ります
import React from "react";
type Props = {
index: number;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSave: () => void;
onDelete: () => void;
};
export const EditNotificationDestination: React.FC<Props> = ({
index,
value,
onChange,
onSave,
onDelete,
}) => {
return (
<div>
<span>通知先{index}</span>
<input value={value} onChange={onChange} />
<button onClick={onSave}>保存</button>
<button onClick={onDelete}>削除</button>
</div>
);
};
import React from "react";
type Props = {
value: string;
onClick: () => void;
};
export const ViewNotificationDestination: React.FC<Props> = ({
value,
onClick,
}) => {
return (
<div>
<span>{value}</span>
<button onClick={onClick}>編集</button>
</div>
);
};
useState での実装
まずはuseStateです。
React.js を学び始めたらまず覚える Hooks だと思います。
こちらを利用してロジックを組んだのが下記です。
import React, { useState } from "react";
import { EditNotificationDestination } from "../components/EditNotificationDestination";
import { ViewNotificationDestination } from "../components/ViewNotificationDestination";
import { DisplayMode, NotificationDestination } from "./UseReducer";
export const UseState = () => {
const [notifications, setNotifications] = useState<NotificationDestination[]>(
[]
);
const isNotificationMaximum = notifications.length >= 5;
const changeNotificationDisplayMode = (
index: number,
displayMode: DisplayMode
) => {
const newNotifications = notifications.map((o, mIndex) => {
return index === mIndex ? { displayMode, email: o.email } : o;
});
setNotifications(newNotifications);
};
const addNewNotification = () => {
setNotifications([...notifications, { displayMode: "edit", email: "" }]);
};
const updateNotificationEmail = (
index: number,
e: React.ChangeEvent<HTMLInputElement>
) => {
const newNotifications = notifications.map((o, mIndex) => {
return index === mIndex
? { displayMode: o.displayMode, email: e.target.value }
: o;
});
setNotifications(newNotifications);
};
const deleteNotification = (index: number) => {
const notificationsExcludedIndex = notifications.filter(
(o, fIndex) => index !== fIndex
);
setNotifications(notificationsExcludedIndex);
};
return (
<div className="App">
{notifications.map((o, mIndex) => (
<div key={mIndex}>
{o.displayMode === "edit" ? (
<EditNotificationDestination
index={mIndex + 1}
value={o.email}
onChange={(e) => updateNotificationEmail(mIndex, e)}
onDelete={() => deleteNotification(mIndex)}
onSave={() => changeNotificationDisplayMode(mIndex, "view")}
/>
) : (
<ViewNotificationDestination
value={o.email}
onClick={() => changeNotificationDisplayMode(mIndex, "edit")}
/>
)}
</div>
))}
{!isNotificationMaximum ? (
<button onClick={addNewNotification}>追加</button>
) : null}
</div>
);
};
よく見る実装だと思います。
これを把握した上でuseReducerを見てみましょう。
useReducer での実装
次にuseReducerを利用した実装がこちらになります。
import { EditNotificationDestination } from "../components/EditNotificationDestination";
import { ViewNotificationDestination } from "../components/ViewNotificationDestination";
import { useReducer } from "react";
import { reducer } from "../reducer/reducer";
export type DisplayMode = "view" | "edit";
export type NotificationDestination = {
displayMode: DisplayMode;
email: string;
};
const initialState: NotificationDestination[] = [];
export const UseReducer = () => {
const [notifications, dispatch] = useReducer(reducer, initialState);
const isNotificationMaximum = notifications.length >= 5;
return (
<div className="App">
{notifications.map((o, mIndex) => (
<div key={mIndex}>
{o.displayMode === "edit" ? (
<EditNotificationDestination
index={mIndex + 1}
value={o.email}
onChange={(e) =>
dispatch({
type: "updateNotification",
payload: { index: mIndex, value: e.target.value },
})
}
onDelete={() =>
dispatch({
type: "deleteNotification",
payload: { index: mIndex },
})
}
onSave={() =>
dispatch({
type: "changeDisplayMode",
payload: { index: mIndex, displayMode: "view" },
})
}
/>
) : (
<ViewNotificationDestination
value={o.email}
onClick={() =>
dispatch({
type: "changeDisplayMode",
payload: { index: mIndex, displayMode: "edit" },
})
}
/>
)}
</div>
))}
{!isNotificationMaximum ? (
<button onClick={() => dispatch({ type: "newNotification" })}>
追加
</button>
) : null}
</div>
);
};
import { NotificationDestination, DisplayMode } from "../pages/UseReducer";
type Actions =
| {
type: "changeDisplayMode";
payload: { index: number; displayMode: DisplayMode };
}
| { type: "newNotification" }
| { type: "updateNotification"; payload: { index: number; value: string } }
| { type: "fetchAllNotification"; payload: { values: string[] } }
| { type: "deleteNotification"; payload: { index: number } };
export const reducer = (
state: NotificationDestination[],
action: Actions
): NotificationDestination[] => {
switch (action.type) {
case "changeDisplayMode": {
const { index, displayMode } = action.payload;
return state.map((o, mIndex) => {
return mIndex === index
? { displayMode: displayMode, email: o.email }
: o;
});
}
case "newNotification": {
return [...state, { displayMode: "edit", email: "" }];
}
case "updateNotification": {
const { index, value } = action.payload;
return state.map((o, mIndex) => {
return mIndex === index
? { displayMode: o.displayMode, email: value }
: o;
});
}
case "fetchAllNotification": {
const { values } = action.payload;
const newState: NotificationDestination[] = values.map((o) => {
return {
displayMode: "view",
email: o,
};
});
return newState;
}
case "deleteNotification": {
const newState: NotificationDestination[] = state.filter(
(o, index) => action.payload.index !== index
);
return newState;
}
}
};
useReducerの基本的な実装方法については公式ドキュメントを参照してください。
実装内容を見て分かるようにように特定のactionに基づいてreducerでstateを更新しています。
useReducer の強みとは
ここまでuseStateとuseReducerの実装比較を行なってきました。
useReducerはaction,reducerを定義することから、下記のメリットがあると思っています。
- state の変更ロジックを
reducerに切り離すことができる -
action,reducerを記載することになるのでロジックの明文化がuseStateよりもされている
しかし、これらのメリットはuseReducerの 1 番の強みではないと著者は思っています。
では、useReducerの 1 番の強みは何なのでしょうか?こちらを理解するために再度下記のコードを見てみましょう。
export const reducer = (
state: NotificationDestination[],
action: Actions
): NotificationDestination[] => {
switch (action.type) {
case "changeDisplayMode": {
const { index, displayMode } = action.payload;
return state.map((o, mIndex) => {
return mIndex === index
? { displayMode: displayMode, email: o.email }
: o;
});
}
// 以下省略
}
};
reducer.tsをよくみてみるとstateとacitonを受け取る純粋関数であり、useReducerのstateとは非依存です。
この非依存な純粋関数であることからstateに関するロジックの単体テストが書けるのです。
複雑なロジックや複雑な画面になればなるほど、単体テストを実施することで事前にバグを防げると著者は考えています。
そこで、state に関する単体テストをかけることはとても大きいことです。
具体的なテストコード:reducer.tsは下記です。
import { reducer } from "./reducer";
import { NotificationDestination } from "../pages/UseReducer";
describe("reducerのテスト", () => {
let notifications: NotificationDestination[] = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
];
beforeEach(() => {
notifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
];
});
describe("changeDisplayMode", () => {
it("指定した順番の画面状態が変わる", () => {
const changedNotifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "edit", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
];
expect(
reducer(notifications, {
type: "changeDisplayMode",
payload: { index: 1, displayMode: "edit" },
})
).toEqual(changedNotifications);
});
it("指定した順番が存在しない場合、状態は変わらない", () => {
const changedNotifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
];
expect(
reducer(notifications, {
type: "changeDisplayMode",
payload: { index: -1, displayMode: "edit" },
})
).toEqual(changedNotifications);
});
});
describe("newNotification", () => {
it("連絡先の新規入力項目が追加される", () => {
const addedNotifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
{ displayMode: "edit", email: "" },
];
expect(
reducer(notifications, {
type: "newNotification",
})
).toEqual(addedNotifications);
});
});
describe("updateNotification", () => {
it("指定した順番のメールアドレスが変更される", () => {
const updatedNotifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03_updated@example.com" },
];
expect(
reducer(notifications, {
type: "updateNotification",
payload: { index: 2, value: "dummy03_updated@example.com" },
})
).toEqual(updatedNotifications);
});
it("指定した順番が存在しない場合、状態は変わらない", () => {
const nonUpdatedNotifications = [
{ displayMode: "view", email: "dummy01@example.com" },
{ displayMode: "view", email: "dummy02@example.com" },
{ displayMode: "view", email: "dummy03@example.com" },
];
expect(
reducer(notifications, {
type: "updateNotification",
payload: { index: -1, value: "dummy01_updated@example.com" },
})
).toEqual(nonUpdatedNotifications);
});
});
// 以下省略
});
});
このようにreducerは非依存な純粋関数であることから単体テスト可能ということがわかりましたね。
ロジックという観点のみで述べるとuseStateとuseReducerはやっていることは同じです。
しかしテスト観点で述べると、useReducerは単体テストが可能で、useStateはロジックがsetStateに依存してしまう(stateに依存)のでどうしてもテストコードを書くことができません。
この差はとても大きいです。
まとめ
useReducerは公式ドキュメントで紹介されているように
state が複数の値にまたがるような複雑なロジックがある場合・前回の state に基づいて state を更新する場合
このようなケースで採用することにより、確かに力を発揮します。
しかし隠れた大きなメリットはstate に対して非依存な構成であり、単体テストが書きやすいことです。
少しでも state 設計が複雑になるのであればuseReducer、反対に画面構成が複雑でなければuseStateを採用した方が良いかと思います。
以上、テスト観点から見たuseReducerとuseStateの違いについてでした。
Discussion
👍