🍛

useReducer+Typescriptに慣れよう

2022/10/29に公開約10,200字4件のコメント

はじめに

これまで React+Typescript での開発において、state 管理を useState で主に扱っていました。

それはなぜか?

React で最初に useState を学ぶから!!!

その後に useReducer を学ぶものの、「useState で state 管理できるからいいじゃん」と考えることを放棄しちゃいました。。。

結構いろんな記事で useReducer と useState の違いを見ていると、
「useState より useReducer の方が良くね?」と思ったので、ここ数か月で useReducer を試しています。

今回、useReducer+Typescript に関して、どのように記述をしていけばよいか手順をまとめました。

想定読者

  • React がなんとなく書けるレベル以上の方
  • Typescript が基礎レベル以上の方 (型についてわかれば十分)
  • React の useReducer に少しでも抵抗を持っている方

利用環境

環境の作成方法

以下のコマンドで作成した環境

npx create-next-app sample --typescript

各種バージョン

  • next: 12.3.1
  • react: 18.2.0
  • typescript: 4.8.4
  • VSCode: 1.72.2

※ 多分、近いバージョンなら大丈夫かと。。。

なぜ useReducer を使うのか

いろんな記事があり、勉強させていただきました。
私が書くよりも良い記事がいっぱいあるので、いくつか参考にした記事を張っておきます。

https://zenn.dev/astrologian/articles/bf071dfb70123d

https://zenn.dev/tis1116/articles/8b04672a0221bb

また、私も以下の記事を書いているので、読んでみてください。

https://zenn.dev/sorye/articles/difference-between-usestate-and-usereducer

今回実装するテーマ

堅苦しいテーマだと勉強はつまらないので、楽しいテーマにしたいなと思いました。
なので、「カレー専門店の在庫管理」にしましょう!
(私がカレー好きなので)

実装する内容

カレー屋「SPICE Pro」では、以下のメニューを扱ってます。

  • カレーライス
  • カツカレー
  • チーズカレー
  • チーズカツカレー

これらのメニューを作るのに使う材料は以下の通りです。

  • カレーライス
  • とんかつ
  • チーズ

各メニューを作るためには、それぞれ以下の材料が必要です。

メニュー カレーライス とんかつ チーズ
カレーライス - -
カツカレー -
チーズカレー -
チーズカツカレー

これを React の useReducer を用いて 在庫管理しましょう!

実装手順

1. 管理したい状態の型を定義しよう!

まずは、状態管理をしたい 状態(State)の型を定義していきます。
今回のテーマである「在庫管理」から考えると、
管理したい状態は、「カレーの材料の在庫数」ですね。

これを型定義するだけです。

reducer.ts
type State = {
  curryRice: number;
  porkCutlet: number; // 「とんかつ」の英訳
  cheese: number;
};

もし余裕があれば、追加で在庫切れによるメニューの売り切れ状態なども一緒に状態管理をしたい場合に、どのように状態を定義するかを考えてみてください。

解答例
reducer.ts
type State = {
  curryRice: number;
  porkCutlet: number; // 「とんかつ」の英訳
  cheese: number;
  soldOutCurryRice: boolean; // カレーが売り切れたかどうか
  soldOutPorkCutletCurry: boolean;
  soldOutCheeseCurry: boolean;
  soldOutCheesePorkCutletCurry: boolean;
}

キー名のつけ方が下手ですみません。。。
ここら辺、センス出ますよね。精進します。

2. 状態の更新内容について概要(型)を定義しよう!

ここでは、状態が変化するタイミングで、どのようなアクションが行われるのかを定義します。

一般的に状態が変化する際には、何かのアクションが入ってきます。

状態遷移の例

今回の例でいうと、カレー屋の在庫が変化するタイミングはいつでしょうか。
少し考えてみましょう。

在庫が変化するタイミングは?(解答例)
  • 材料を入荷したとき → 在庫が増える
  • 各メニューの注文が入ってお客さんに料理を提供したとき → 在庫が減る

大体は同じようなのをイメージしたんじゃないでしょうか?
他にも思いついた方はすばらしいです!ぜひ教えてください。

この変化タイミングのことを以降では「アクション(Action)」と呼びます。

少し前で定義した State と今回出した Action を組み合わせて以下のような「状態遷移図」を作成します。

この状態遷移図のアクションの中で、入荷の時だけ各材料の数量を渡しています。
これは、「入荷する」というアクションだけだと、次の状態である「在庫数」がどういう形になるかわからないからです。

このようにアクションを起こす時に渡す必要のある情報もあることを認識したうえで、
どのようなアクションがあるかの定義をします。

reducer.ts
type Action =
  | {
      type: "arrival"; // 入荷
      payload: {
        curryRice: number;
        porkCutlet: number;
        cheese: number;
      };
    }
  | {
      type: "orderCurryRice"; // カレーライスの注文
    }
  | {
      type: "orderPorkCutletCurry"; // カツカレーの注文
    }
  | {
      type: "orderCheeseCurry"; // チーズカレーの注文
    }
  | {
      type: "orderCheesePorkCutletCurry"; // チーズカツカレーの注文
    };

入荷のときのみ、「いくつ入荷するのか」という情報が必要でした。
そのため、追加で渡す情報を「payload」というキーに入れています。

具体的に各アクションは以下の構成になります。

{
  type: "アクション名",
  payload: {/* 更新に用いるデータ */}  // payloadは必要な場合のみ記載
}

今回、各メニューのオーダー(例えば、カレーライスの注文) はアクション名だけを定義し、payload は渡していません。
これは、各メニューに関するオーダーは一品ずつオーダーされると仮定をしているからです。
ここは、先に作成した状態遷移図を見てください。

ただこの状態では、カレーライスが 3 つ注文された場合、"orderCurryRice"というアクションを 3 回繰り返し実行しないといけないですね。
繰り返し同じことを実行するのはできる限り避けたいので、どうすれば「カレーライス 3 つ」のオーダーに効率良く対応できますか?考えてみてください。

解答例

カレーライスの注文のアクション定義部分のみ修正した結果を以下に記載します。

    {
      type: "orderCurryRice", // カレーライスの注文
+     payload: {num: number} // 注文数をpayloadで受け取る
    }

こう定義すれば、payload.num に注文数 3 を入れることができるようになりますね。
実際の具体的な処理はこの後の reducer で実装していきますので、ここではあくまでアクションの定義までです。

3. 状態の変化を実装しよう!

これまでは、状態やアクションの定義をしてきました。
ここからやっと状態変化の処理の実装に入っていきます。

まず最初に、おまじないと思って、以下の関数定義を必ず書いてください。

reducer.ts
export function reducer (state: State, action: Action): State {}

ここでのポイントは、引数と戻り値の型を必ず指定することです。
型を指定しておくことで、アクションの実装漏れや戻り値の不適合などを VSCode が検知してエラーを出してくれます。


「値を return しなさい」というエラー

また、引数と戻り値について、簡単に説明します。

  • 引数の state: 現状の状態
  • 引数の action: 現状の状態に対して行う操作
  • 戻り値: 変化後の状態

これらの関係を図で表すと、少し前で見た以下の図になります。

逆に関数を用いると、電気などの具体例が以下のように表現できます。

電気がつく = reducer(電気が消えている, スイッチを入れる);
おしゃべりな人 = reducer(おとなしい人, 酒を飲む);

関数で実装する内容がイメージできてきましたか?
イメージができたら、実際に中身を書いていきましょう。

reducer.ts
export function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "arrival":
      return {
        curryRice: state.curryRice + action.payload.curryRice,
        porkCutlet: state.porkCutlet + action.payload.porkCutlet,
        cheese: state.cheese + action.payload.cheese,
      };
    case "orderCurryRice":
      return { ...state, curryRice: state.curryRice - 1 };
    case "orderPorkCutletCurry":
      return {
        ...state,
        curryRice: state.curryRice - 1,
        porkCutlet: state.porkCutlet - 1,
      };
    case "orderCheeseCurry":
      return {
        ...state,
        curryRice: state.curryRice - 1,
        cheese: state.cheese - 1,
      };
    case "orderCheesePorkCutletCurry":
      return {
        curryRice: state.curryRice - 1,
        porkCutlet: state.porkCutlet - 1,
        cheese: state.cheese - 1,
      };
  }
}

私の場合は switch 文を使ってアクション名(action.type)で分岐させて、それぞれで次の状態を return しています。

抜き出して説明すると、「カレーライスの注文」のアクションが発生した場合は、
現状の在庫のうち「カレーライス」が一個減るというのを実装しています。

reducer.ts
    case "orderCurryRice":
      return { ...state, curryRice: state.curryRice - 1 };

他も同様に、前の状態からどのように状態が変化するかを考えれば書けますね!

もし、VSCode でエラーがずっと出ている場合は、以下を確認してみてください。

  1. すべてのアクションの分岐を作成しているか
  2. すべてのアクションで次の状態を State 型通りに return できているか

ここまでで下準備が完了です!

4. ページに実際に状態管理を適用しよう!

今回は、簡単なサンプルページを作って検証してみます。

Next.js を使っている場合、index.tsx に以下のコンポーネントを記載ください。

index.tsx
import type { NextPage } from "next";

const Home: NextPage = () => {
  return (
    <div style={{ margin: "30px", padding: "30px", border: "solid" }}>
      <h1>在庫管理</h1>
      <h2>各種在庫</h2>
      <ul>
        <li>カレールー: 個</li>
        <li>とんかつ: 個</li>
        <li>チーズ: 個</li>
      </ul>

      <h2>操作</h2>
      <div style={{ margin: "10px" }}>
        <button>入荷</button>
      </div>
      <div style={{ margin: "10px" }}>
        <button>注文: カレーライス</button>
        <button>注文: カツカレー</button>
        <button>注文: チーズカレー</button>
        <button>注文: チーズカツカレー</button>
      </div>
    </div>
  );
};

export default Home;

以下のような画面が出ます。
今回は css などで躓いてほしくないため、div のところに少しだけ style を指定する形としています。

ここに useReducer を加えていきます。
初期値の在庫は initialState に事前に定義して useReducer の引数に渡しています。

index.tsx
  import type { NextPage } from "next";
+ import { useReducer } from "react";
+ import { reducer } from "../lib/reducer";

+ const initialState = {
+   curryRice: 20,
+   porkCutlet: 10,
+   cheese: 10,
+ };
+
  const Home: NextPage = () => {
+   const [state, dispatch] = useReducer(reducer, initialState);
+
    return (

初期状態が state に格納されるため、これを表示していきましょう。

index.tsx
-        <li>カレールー: 個</li>
+        <li>カレールー: {state.curryRice}</li>
-        <li>とんかつ: 個</li>
+        <li>とんかつ: {state.porkCutlet}</li>
-        <li>チーズ: 個</li>
+        <li>チーズ: {state.cheese}</li>

初期値が表示されましたね。
次に dispatch を使って、状態を更新できるようにします。
まずは、ボタンのクリック時に呼ぶ関数を作成します。

index.tsx
-import { useReducer } from "react";
+import { MouseEvent, useReducer } from "react";
index.tsx
   const [state, dispatch] = useReducer(reducer, initialState);
+
+  const onClickArrival = (e: MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    dispatch({
+      type: "arrival",
+      payload: { curryRice: 10, porkCutlet: 5, cheese: 5 },
+    });
+  };
+
+  const onClickOrderCurryRice = (e: MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    dispatch({ type: "orderCurryRice" });
+  };
+
+  const onClickOrderPorkCutletCurry = (e: MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    dispatch({ type: "orderPorkCutletCurry" });
+  };
+
+  const onClickOrderCheeseCurry = (e: MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    dispatch({ type: "orderCheeseCurry" });
+  };
+
+  const onClickOrderCheesePorkCutletCurry = (
+    e: MouseEvent<HTMLButtonElement>
+  ) => {
+    e.preventDefault();
+    dispatch({ type: "orderCheesePorkCutletCurry" });
+  };
+
   return (

作成した関数をそれぞれのボタンに割り当てていきます。

index.tsx
       <div style={{ margin: "10px" }}>
-        <button>入荷</button>
+        <button onClick={onClickArrival}>入荷</button>
       </div>
       <div style={{ margin: "10px" }}>
-        <button>注文: カレーライス</button>
+        <button onClick={onClickOrderCurryRice}>注文: カレーライス</button>
-        <button>注文: カツカレー</button>
+        <button onClick={onClickOrderPorkCutletCurry}>注文: カツカレー</button>
-        <button>注文: チーズカレー</button>
+        <button onClick={onClickOrderCheeseCurry}>注文: チーズカレー</button>
-        <button>注文: チーズカツカレー</button>
+        <button onClick={onClickOrderCheesePorkCutletCurry}>注文: チーズカツカレー</button>
       </div>

実装はこれで終了です。

ただし、まだまだ在庫管理システムとして利用できるだけの機能は備えていません。
以下のような、機能など取り入れたいですね。

  • 入荷の個数を各材料ごとに毎回変更したい
  • 在庫数不足で作れないメニューは売り切れと表示したい
  • 在庫の推移から発注する材料数を最適化したい

ここら辺はぜひ皆さんもチャレンジしてみてください!

さいごに

useReducer+Typescript で状態管理を実装してみました。
多くの React の参考動画が Javascript で実装されていることが多く、
なかなか Typescript でどのように進めるべきか難しいですよね。

私もプログラムを書き始めて浅いので、より良い書き方などあればコメントください。

Discussion

良記事ありがとうございます!
筆者様が

「useState より useReducer の方が良くね?」

とおもう理由をお聞かせ願えると嬉しいです!(本文中では言及されていなかった気がしたので...)

コメントいただきありがとうございます!
useReducerのほうが良いと思った理由は別記事でしっかりまとめるようにします。

簡単に私は以下の理由で、useReducerが良いと思いました。

  1. useStateが実装できる状態管理はuseReducerでも実装できる (同等以上)
  2. useStateの更新関数は次の状態を直接入力するため、入れる値を間違えると想定外の動作をする可能性があるが、useReducerだとアクションの選択(type)と更新に用いる値(payload)を指定することで、reducer関数が次の状態を計算してくれるため、想定外の状態になりにくい
  3. useReducer + Typescriptだと、実行するアクション(type)の候補をVSCodeが表示してくれる
  4. useReducerでは更新関数をreducerとして分離することができ、更新関数自体のテストが可能になる

2.については、以下の記事をご一読いただけるとuseReducerで実装している内容がご理解いただけるかもしれません。

https://zenn.dev/sorye/articles/difference-between-usestate-and-usereducer

なるほど!勉強になりました😀
個人的には useState状態を意識して状態を扱いたいとき(?)useReducerAction(=動作)を意識して状態を扱いたいときに使うといいのかな、とか思ったりしました...

個人的には useStateは状態を意識して状態を扱いたいとき(?)、 useReducer はAction(=動作)を意識して状態を扱いたいときに使うといいのかな、とか思ったりしました...

ここら辺は私もいろいろと調べましたが、なかなか「これ」という情報がないですよね...

私は管理する状態がシンプルかつ1つの関数コンポーネント内で完結する場合は、useStateのほうが記述量が少なくなるので、私はuseStateを使ってます。
これに当てはまらない場合は、間違った状態を入れないようにuseReducerを使うようにし始めました。

もう少し開発や調査を進めてみて、より良い考え方があれば、記載しますね。

ログインするとコメントできます