🐶

useStateとuseReducerって実は大きな違いがある

2021/10/02に公開
1

はじめに

都内でフロントエンドエンジニアとして開発をしているものです。
具体的な技術スタックは下記です

  • React.js
  • Next.js
  • TypeScript
  • GraphQL(Apollo)

今回は React.jsHooks であるuseStateuseReducerの違いについて色々述べていきたいと思います。

この記事を書こうと思ったきっかけ

今まで私はuseStateを利用して基本的に開発しており、useReducerを扱う機会がありませんでした。
そんな中ある複雑な画面をuseStateで実装すると品質を担保できず、バグを生んでしまったからです。(著者の実装力不足もあると思いますが。。)

しかし同じ画面をuseReducerで書き直すことで品質を保つことができ、世間で紹介されている useReducer とは違ったメリットが見えてきたので発信することを決めました。

React.js を学んでいく中でuseReducerは初学者とって扱いにくく、useStateでも同じ処理を実現できることから敬遠する方も多いのではないでしょうか?

そんな方々が

  • useStateuseReducer の違いがはっきりわかった
  • 実装する画面内容によってはuseReducerを採用してみよう

こんな心持ちになっていただけると幸いです。

この記事の対象者

  • React.jsTypeScriptの基本実装に慣れている
  • 単体テストにというワードを理解している
  • useStateuseReducerの大きな違いがいまいちわからない

今回作る機能の要件

useStateuseReducerを紹介するためにフォームを作っていきます。
具体的な要件は下記です

  • 連絡先登録フォーム
    • ユーザーは 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を利用した実装がこちらになります。

UseReducer.tsx
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>
  );
};

reducer.ts
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に基づいてreducerstateを更新しています。

useReducer の強みとは

ここまでuseStateuseReducerの実装比較を行なってきました。

useReduceraction,reducerを定義することから、下記のメリットがあると思っています。

  • state の変更ロジックをreducerに切り離すことができる
  • action,reducerを記載することになるのでロジックの明文化がuseStateよりもされている

しかし、これらのメリットはuseReducerの 1 番の強みではないと著者は思っています。
では、useReducerの 1 番の強みは何なのでしょうか?こちらを理解するために再度下記のコードを見てみましょう。

reducer.ts

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をよくみてみるとstateacitonを受け取る純粋関数であり、useReducerstateとは非依存です。

この非依存な純粋関数であることからstateに関するロジックの単体テストが書けるのです。
複雑なロジックや複雑な画面になればなるほど、単体テストを実施することで事前にバグを防げると著者は考えています。
そこで、state に関する単体テストをかけることはとても大きいことです。

具体的なテストコード:reducer.tsは下記です。

reducer.test.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は非依存な純粋関数であることから単体テスト可能ということがわかりましたね。

ロジックという観点のみで述べるとuseStateuseReducerはやっていることは同じです。
しかしテスト観点で述べると、useReducerは単体テストが可能で、useStateはロジックがsetStateに依存してしまう(stateに依存)のでどうしてもテストコードを書くことができません。

この差はとても大きいです。

まとめ

useReducerは公式ドキュメントで紹介されているように

state が複数の値にまたがるような複雑なロジックがある場合・前回の state に基づいて state を更新する場合

このようなケースで採用することにより、確かに力を発揮します。
しかし隠れた大きなメリットはstate に対して非依存な構成であり、単体テストが書きやすいことです。

少しでも state 設計が複雑になるのであればuseReducer、反対に画面構成が複雑でなければuseStateを採用した方が良いかと思います。

以上、テスト観点から見たuseReduceruseStateの違いについてでした。

Discussion

______

state が複数の値にまたがるような複雑なロジックがある場合・前回の state に基づいて state を更新する場合

このようなケースで採用することにより、確かに力を発揮します。
しかし隠れた大きなメリットはstate に対して非依存な構成であり、単体テストが書きやすいことです。

👍