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
👍