🍛

useReducer + useContext + Typescriptに慣れよう

2022/11/04に公開2

概要

以下の記事で「useReducer + Typescript」について記事を書きました。

https://zenn.dev/sorye/articles/usereducer-practice

今回はその第 2 弾として、useReducer での状態管理を子のコンポーネントでも使えるようにするために、
useContext と組み合わせた場合にどのようにプログラムを書けば良いかをご説明します。

想定読者

  • React がなんとなく書けるレベル以上の方
  • Typescript が基礎レベル以上の方 (型についてわかれば十分)
  • React の useReducer と useContext の使い方がなんとなくでもわかっている方
  • useContext と useReducer を組み合わせる際に Typescript でどう書くと良いのか悩んでいる方

もし、useReducer について、どのように書けばいいかまだわからない方は、
概要に張りましたリンクの記事を読んでいただければと思います。

利用環境

環境の作成方法

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

npx create-next-app sample --typescript

各種バージョン

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

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

今回実装するテーマ

前回同様、「カレー専門店『SPICE Pro』の在庫管理」を題材に進めます。

実装する内容

今回は少しコード量を減らすために、前回と少し内容を変えます。

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

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

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

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

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

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

材料の在庫管理をするとともに、在庫切れで作れなくなったメニューを「売り切れ」と表示しましょう!

想定のフォルダ構成

今回使用するフォルダ構成は以下の通りです。

sample
└─src
    ├─components
    │      buttonGroup.tsx
    │      inventory.tsx
    │      menu.tsx
    ├─lib
    │      provider.tsx
    │      inventoryReducer.ts
    └─pages
           index.tsx

※ 変更をするファイルや追加するファイルのみ記載してます。その他はテンプレのままです。

ページの実装

やはり、実際に画面があると何をしたいかイメージがわきやすいと思います。
まずは、複数のコンポーネントを組み合わせて、実際の管理画面を表示してみましょう。
※ここでは、useReducer や useContext は扱いません。実装をしない方はざっくりと読み飛ばしてください。

在庫表示コンポーネント

まずは、在庫表示コンポーネント(inventory.tsx)を作成します。
以下のコードをコピーして利用してください。

src/components/inventory.tsx
export default function Inventory() {
  return (
    <>
      <h1>在庫管理</h1>
      <h2>各種在庫</h2>
      <ul>
        <li>カレールー: 個</li>
        <li>とんかつ: 個</li>
        <li>チーズ: 個</li>
      </ul>
    </>
  );
}

これだけを表示すると以下のような感じになります。

このコンポーネントは現在在庫の表示だけに利用します。

メニュー表コンポーネント

次に、メニュー表(menu.tsx)を作成します。
以下のコードをコピーして利用してください。

src/components/menu.tsx
export default function Menu() {
  return (
    <>
      <h2>メニュー表</h2>
      <ul>
        <li>
          カレーライス <strong>売り切れ</strong>
        </li>
        <li>
          カツカレー <strong>売り切れ</strong>
        </li>
        <li>
          チーズカレー <strong>売り切れ</strong>
        </li>
      </ul>
    </>
  );
}

これだけを表示すると以下のような感じになります。

ここでは、メニュー表の表示とそれぞれのメニューの売り切れ表示もします。
今は、何も状態を実装していないので、全て売り切れが表示されるようになっています。

入荷・注文用のボタングループコンポーネント

次に、入荷時や出荷時に在庫やメニューの売り切れ状態を更新するための、ボタングループコンポーネント (buttonGroup.tsx)を作成します。
以下のコードをコピーして利用してください。

src/components/buttonGroup.tsx
export default function ButtonGroup() {
  return (
    <>
      <h2>操作</h2>
      <div style={{ margin: "10px" }}>
        <button>入荷</button>
      </div>
      <div style={{ margin: "10px" }}>
        <button>注文: カレーライス</button>
        <button>注文: カツカレー</button>
        <button>注文: チーズカレー</button>
      </div>
    </>
  );
}

これだけを表示すると以下のような感じになります。

今はボタンをクリックしても何もおきませんが、
完成時には、ボタンクリックとともに在庫の更新とメニューの売り切れ状態の更新を行います。

ページ全体

最後に、これまで作成したコンポーネントを組み合わせて一つのページを作りましょう!
以下のコードをコピーして利用してください。

src/pages/index.tsx
import ButtonGroup from "../components/buttonGroup";
import Inventory from "../components/inventory";
import Menu from "../components/menu";

export default function Test() {
  return (
    <div
      style={{
        margin: "30px",
        padding: "20px",
        border: "solid",
        width: "600px",
      }}
    >
      <h1>在庫管理システム</h1>
      <div style={{ margin: "30px" }}>
        <Inventory />
        <Menu />
        <ButtonGroup />
      </div>
    </div>
  );
}

ここまで実装すると、次のようなページになります。

とても質素ですが、今回の主題は useContext+useReducer なので、お許しを!

useReducer の準備

今回は useContext との連携を中心に書いていきたいので、
useReducer の部分はジャンジャン進めていきます。

細かい進め方はくどいですが、以下のリンクをご参照ください。

https://zenn.dev/sorye/articles/usereducer-practice

状態の定義

今回管理する状態は、3 種類の在庫の状況と 3 種類のメニューの売り切れ状況になります。
これらを一つの状態として定義していきましょう。

src/lib/inventoryReducer.ts
type State = {
  curryRice: number; // カレーライスの在庫数
  porkCutlet: number; // とんかつの在庫数
  cheese: number; // チーズの在庫数
  soldOutCurryRice: boolean; // カレーライスの売り切れ状態
  soldOutPorkCutletCurry: boolean; // カツカレーの売り切れ状態
  soldOutCheeseCurry: boolean; // チーズカレーの売り切れ状態
};

アクションの定義

また、次に状態が変化する際のアクションについて定義します。
今回実装するアクションは、「入荷」と「各メニューの注文」で全てです。
これを State を定義したファイルと同じファイルに定義していきましょう。

src/lib/inventoryReducer.ts
type Action =
  | {
      type: "arrival"; // 入荷
      payload: {
        curryRice: number; // 入荷するカレーライスの数
        porkCutlet: number; // 入荷するとんかつの数
        cheese: number; // 入荷するチーズの数
      };
    }
  | {
      type: "orderCurryRice"; // カレーライスの注文
    }
  | {
      type: "orderPorkCutletCurry"; // カツカレーの注文
    }
  | {
      type: "orderCheeseCurry"; // チーズカレーの注文
    };

アクションの処理の実装

では、Action の中身を実装していきましょう。
reducer 関数を定義して前の状態とアクションから次の状態を定義します。
詳細説明は省略します。

src/lib/inventoryReducer.ts
const reducer = (state: State, action: Action): State => {
  const next: State = { ...state };

  switch (action.type) {
    case "arrival":
      next.curryRice += action.payload.curryRice;
      next.porkCutlet += action.payload.porkCutlet;
      next.cheese += action.payload.cheese;

      break;
    case "orderCurryRice":
      if (!state.soldOutCurryRice) next.curryRice -= 1;

      break;
    case "orderPorkCutletCurry":
      if (!state.soldOutPorkCutletCurry) {
        next.curryRice -= 1;
        next.porkCutlet -= 1;
      }

      break;
    case "orderCheeseCurry":
      if (!state.soldOutCheeseCurry) {
        next.curryRice -= 1;
        next.cheese -= 1;
      }

      break;
  }

  next.soldOutCurryRice = next.curryRice === 0;
  next.soldOutPorkCutletCurry = next.soldOutCurryRice || next.porkCutlet === 0;
  next.soldOutCheeseCurry = next.soldOutCurryRice || next.cheese === 0;

  return next;
};

あまりコーディングには自身がないので、汚いかもしれません。。。
良かったら、どうすればより良いかコメントいただけると幸いです。

useReducer を内包したカスタムフックの実装

ここから、useContext と連携させるために、私が習慣で実装している内容です。
よかったら、参考にしてください。

まずは、useReducer のカスタムフックを作成します。
これまで実装してきたファイルに引き続きコーディングします。

src/lib/inventoryReducer.ts
import { useReducer } from "react";

// これまで記載したStateやAction, reducerなど

export default function useInventoryReducer() {
  const [state, dispatch] = useReducer(reducer, {
    curryRice: 0,
    porkCutlet: 0,
    cheese: 0,
    soldOutCurryRice: true,
    soldOutPorkCutletCurry: true,
    soldOutCheeseCurry: true,
  });

  return { state, dispatch };
}

今回は、決められた初期値をここで入力し、State と Dispatch を返すカスタムフックを定義しています。

時には、引数で初期値の一部を受け取ったり、
追加で処理したい内容などを実装したりするためにカスタムフックを実装します。

createContext の初期値の生成

私は useReducer のカスタムフックを準備したら、
最後に createContext の初期値を生成するようにしています。

まずは、これまでと同じファイルに以下の記述をしてください。

src/lib/inventoryReducer.ts
export const defaultInventory: ReturnType<typeof useInventoryReducer> = {};

この変数の型は、前節で作成したカスタムフックの useInventoryReducer の戻り値の型になります。

まず、この定義だけしておけば、VSCode が戻り値に関するエラーを出してくれます。

このエラーから Quick Fix をクリックすると、「Add missing properties」が出てきます。

「Add missing properties」をクリックするだけで、createContext の引数を定義できてしまいます。
実際に実装される内容は以下の通りです。

src/lib/inventoryReducer.ts
export const defaultInventory: ReturnType<typeof useInventoryReducer> = {
   state: {
    curryRice: 0,
    porkCutlet: 0,
    cheese: 0,
    soldOutCurryRice: false,
    soldOutPorkCutletCurry: false,
    soldOutCheeseCurry: false,
  },
  dispatch: function (value: Action): void {
    throw new Error("Function not implemented.");
  },
};

このままでも動くと思うのですが、なんとなく状態の整合性が正しくないのと、
関数が throw するのが何となく嫌なので、簡単に以下のように書き換えます。

src/lib/inventoryReducer.ts
 export const defaultInventory: ReturnType<typeof useInventoryReducer> = {
   state: {
     curryRice: 0,
     porkCutlet: 0,
     cheese: 0,
-    soldOutCurryRice: false,
+    soldOutCurryRice: true,
-    soldOutPorkCutletCurry: false,
+    soldOutPorkCutletCurry: true,
-    soldOutCheeseCurry: false,
+    soldOutCheeseCurry: true,
   },
-  dispatch: function (value: Action): void {
-    throw new Error("Function not implemented.");
-  },
+  dispatch: () => {}
};

特に難しくないですね!
長かったですが、createContext のための useReducer 関連の準備はこれで完了です。

createContext の実装 (Provider)

ここまでの準備で createContext のための準備が完了しました。

これから、Typescript + createContext を実装していきますが、
初心者の頃はここで型の整合性を合わせるのがとても苦痛でした。。。
Typescript を辞めようかと思ったレベルです。。

実は、これまででこれを解消するための準備が終わってしまってます。

実際に、以下の実装を見てください。

src/lib/provider.tsx
import { createContext } from "react";
import useInventoryReducer, { defaultInventory } from "./inventoryReducer";

type Props = {
  children: JSX.Element;
};

export const InventoryContext = createContext(defaultInventory);

export default function InventoryProvider(props: Props) {
  return (
    <InventoryContext.Provider value={useInventoryReducer()}>
      {props.children}
    </InventoryContext.Provider>
  );
}

実装してもらうと、一切型関連のエラーがでませんね!

ちょっと前に defaultInventory を「ReturnType<typeof useInventoryReducer>」型で実装したと思います。
実は、ここがとても大事で、createContext の引数と InventoryContext.Provider の value の型が必ず一致するように実装してきたのです。

正直、この二つが型一致すればどういうやり方でも問題ないのですが、
私はいろいろと試した結果、現在のやり方が楽だなと感じてます。

作成した Provider を利用する

前節で作成した Provider ですが、どのように使えると思いますか?
子コンポーネントで使いたい親コンポーネントを、この Provider で囲ってあげるだけで、使えちゃうんです!

実際に index に適用してみましょう。

src/pages/index.tsx
 export default function Test() {
   return (
+    <InventoryProvider>
       <div
         // ...
       </div>
+    </InventoryProvider>
   );
 }

これで子コンポーネントで useReduer で定義した state と dispatch が使えます。
では、実際に子コンポーネントで使っていきましょう!

子コンポーネントから state と dispatch を利用する

子コンポーネントで state と dispatch を利用する場合は、
useContext(InventoryContext)を呼び出せば良いです。

特に、困る部分はないと思うので、どんどん変更した内容をお見せしますね。
解説は省略します。

在庫表示コンポーネントに状態を反映させる

src/components/inventory.tsx
+import { useContext } from "react";
+import { InventoryContext } from "../lib/provider";
+
 export default function Inventory() {
+  const { state } = useContext(InventoryContext);
+
   return (
     <>
       <h1>在庫管理</h1>
       <h2>各種在庫</h2>
       <ul>
-        <li>カレールー: 個</li>
+        <li>カレールー: {state.curryRice}</li>
-        <li>とんかつ: 個</li>
+        <li>とんかつ: {state.porkCutlet}</li>
-        <li>チーズ: 個</li>
+        <li>チーズ: {state.cheese}</li>
       </ul>
     </>
   );
 }

メニュー表コンポーネント

src/components/menu.tsx
+import { useContext } from "react";
+import { InventoryContext } from "../lib/provider";
+
 export default function Menu() {
+  const { state } = useContext(InventoryContext);
+
   return (
     <>
       <h2>メニュー表</h2>
       <ul>
         <li>
-          カレーライス <strong>売り切れ</strong>
+          カレーライス {state.soldOutCurryRice && <strong>売り切れ</strong>}
         </li>
         <li>
-          カツカレー <strong>売り切れ</strong>
+          カツカレー {state.soldOutPorkCutletCurry && <strong>売り切れ</strong>}
         </li>
         <li>
-          チーズカレー <strong>売り切れ</strong>
+          チーズカレー {state.soldOutCheeseCurry && <strong>売り切れ</strong>}
         </li>
       </ul>
     </>
   );
 }

ボタングループコンポーネント

src/components/buttonGroup.tsx
+import { MouseEvent, useContext } from "react";
+import { InventoryContext } from "../lib/provider";
+
 export default function ButtonGroup() {
+  const { dispatch } = useContext(InventoryContext);
+
+  const onClickArrival = (e: MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    dispatch({
+      type: "arrival",
+      payload: { curryRice: 3, porkCutlet: 1, cheese: 1 },
+    });
+  };
+
+  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" });
+  };
+
   return (
     <>
       <h2>操作</h2>
       <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>
       </div>
     </>
   );
 }

変更はここまでです!

完成したページ

実際に触ってみてください。
以下のように動作すると思います。

長丁場でしたが、お疲れさまでした!

まとめ

今回は、useReducer + useContext + Typescript の実装方法をご紹介しました。
何となく、「~に慣れよう」系で記事を増やしていくのもいいかもしれませんね。
記事が多くなったら、本にまとめて公開しようと思います。
(まだ第 2 弾なのに。。いつになることやら。。。)

説明を省略した箇所も度々あるため、不明点や間違いの指摘など、
何でもコメントいただけると幸いです。

Discussion

nap5nap5

何でもコメントいただけると幸いです。

本記事のデモとは異なりカウントアップダウンアプリですが、dispatchの部分はフック化してワークアラウンドしてみました。

以下のようにしている部分は

const next: State = { ...state };

immerとか使うといいかもです。

https://hswolff.com/blog/level-up-usereducer-with-immer/

デモコードです。
https://codesandbox.io/p/sandbox/long-dream-k54f2y?file=README.md

/reducerページがデモページになります。

簡単ですが、以上です。

Subaru OkiyaSubaru Okiya

コメント頂きありがとうございます!
コードも拝見し勉強させていただきますね。

immerとか使うといいかもです。

情報ありがとうございます!
指摘頂いた箇所はバグの原因になりやすいので、immerは使ったほうがいいですね。
早速、活用していきます!