KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

Webフロントエンドの複雑な状態同士の依存をzustandを使ってリアーキテクチャする

この記事は秋の技術特集 2024の 7 記事目です。

カケハシのAI在庫管理チームでフロントエンドエンジニアをしているNokogiri です。今回はAI在庫の入庫ダイアログを zustand を使ってリアーキテクチャした事例を元に取り入れたプラクティスを紹介したいと思います。

イントロ

AI在庫では、ユーザーの入力を伴うフロントエンド部分で多くのケースに React Hook Form を利用しています。

React Hook Form は、入力フォームの状態管理やバリデーションを簡単に実装でき、パフォーマンスにも優れた素晴らしいライブラリです。 しかし、ユーザーの操作に応じてインタラクティブに変化する UI では、状態管理が複雑化し、コードの可読性が低下することがあります。その結果、バグが発生し、予測しにくい動作を引き起こすことも少なくありません。

そこで今回は、 zustand を導入し、状態管理の依存関係をシンプルかつ明確にし、コードの可読性を向上させることで、今後の機能拡張時に不具合を防ぐためのリアーキテクチャを実施します。

現状の問題点

入庫ダイアログ

こちらは入庫ダイアログと呼ばれるダイアログで、ユーザーである薬剤師が薬局で医薬品を受領した際に操作する画面です。

手数料と容器代はユーザーが入力可能ですが、ダイアログを開いたときに初期値として値が入っている必要があります。 この初期値は入庫区分と入庫元の選択内容によって変わります。 ユーザーが直接入力可能な値ですが、入庫区分や入庫元を更新した際には、入力された値を破棄して上書きする必要があります。

このように、特定の値を更新した際に副作用として他の値を更新する必要がある場合、React Hook Form で実装すると、状態の更新処理が表示するコンポーネント側で行われるため、状態管理が複雑化し、コードの見通しが悪くなります。この結果、バグの原因となる可能性もあり、メンテナンスが困難になることが考えられます。

またネストの深いコンポーネント内で状態の参照が必要になるため React Hook Form の useFormContext を利用しつつ watch を多用していました。このことから不用意なコンポーネントの再レンダリングが発生してしまいました。

以下は不用意な再レンダリングが走ってしまっている例です。 不要な再レンダリング

この問題を解決するために、zustandを使って状態管理のリアーキテクチャを行いました。

zustandの紹介

zustand はReactアプリケーションで利用可能な状態管理ライブラリの1つです。軽量でFlux/Reduxの流れを汲んでいます。

使い方

ここでは、シンプルなカウンターの例を示します。

create 関数は、(set) => (管理する状態とそれを更新する関数) を引数に取ることで、利用可能なHooksを生成します。

import React from 'react';
import create from 'zustand';

type State = {
  count: number;
  increment: () => void;
};

const useStore = create<State>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({
      count: state.count + 1,
    })),
}));

function Counter() {
  const count = useStore((state) => state.count);
  return <div>Count: {count}</div>;
}

function IncrementButton() {
  const increment = useStore((state) => state.increment);
  return (
    <button type="button" onClick={() => increment()}>
      +
    </button>
  );
}

なぜ zustandを選んだのか?

今回、zustand を採用した理由は以下の2つです。

  • ダイアログ内の状態を一元管理しつつ、それ以外の状態とは疎結合にしたい。
  • 状態を更新する関数内で、別の状態を参照する必要が多くなることが予想されたため。

チーム内でこれらの要件を満たす状態管理ライブラリを検討した結果、zustand と jotai の2つが候補に挙がりました。

リアーキテクチャプロジェクトを始める前に、zustandとjotaiの両方で小さなサンプルプロジェクトを作成し、どちらが自分たちに合っているかを話し合いました。その結果、今回はzustandが最適であると判断しました。

実際にリアーキテクチャを行う上でやったこと

ここからは zustand を使ってどのようにリアーキテクチャを行っていったかサンプルを交えて紹介します。

更新に必要な状態同士の参照を actions に定義する

例えばある特定のドロップダウンの選択肢を選んだ場合に、その他のフォームの入力値を初期化したり選択した値に応じた値を反映する仕様があったとします。 従来の実装であればドロップダウンの onChange に併せて複数の状態を更新する実装がコンポーネント側に必要になります。

以下従来の React Hook Form を使った実装です。

function ReasonSelect() {
    const { setValue, watch } = useFormContext<FieldValues>();
    const handleChange = (value: string) => {
        // ここで入庫区分の状態を更新しつつ
        setValue('formValues.reason', value);
        // その他依存する状態の更新が必要になる
        if (value === 'external') {
            setValue('formValues.feeAmount', null);
            // その他必要な状態の更新
        }
    }

    return (
      <HStack spacing={1}>
        <Typography variant="body" fontWeight="bold">
            入庫区分
        </Typography>
        <Select<StoreStockReason>
            size="sm"
            options={OPTIONS}
            onChange={updateStoreStockReason}
            defaultValue={selectedOption}
        />
      </HStack>
    )
}

AI在庫というプロダクト全体としてこのような更新時に他の状態を更新するケースが多いプロダクトになっており、 様々なコンポーネントで状態の更新が発生し全体として状態がどうなるのか予測しづらい実装になっていました。

zustandを使った実装では以下の通りになります。 コンポーネント側ではStore側で定義した関数(updateStoreStockReason)を呼び出すだけになっており、更新時に他の状態を更新する処理は store の actions の中でのみ記述するようにしています。 こうすることで状態の更新が actions のみに記述されどの状態がどのタイミングで更新されるのか一箇所で管理できるようになります。

以下 zustand を使った場合の実装。

function ReasonSelect() {
    const { updateStoreStockReason } = useStoreStockDialogStore(selectStoreStockDialogActions);
    return (
      <HStack spacing={1}>
        <Typography variant="body" fontWeight="bold">
            入庫区分
        </Typography>
        <Select<StoreStockReason>
            size="sm"
            options={OPTIONS}
            onChange={updateStoreStockReason}
            defaultValue={selectedOption}
        />
      </HStack>
    )
}
const useStoreStockDialogStore = create<StoreStockDialogStore>()(
  devtools(
    immer((set) => ({
      ...,
      actions: {
        updateStoreStockReason: (reason) => {
          set((state) => {
            // 選択された reason
            state.storeStockInfoFormValues.storeStockReason = reason;

            // 副作用として他の状態の更新も必要
            state.storeStockInfoFormValues.counterPartyId = INITIAL_FORM_VALUES.counterPartyId;
            state.storeStockInfoFormValues.counterPartyName = INITIAL_FORM_VALUES.counterPartyName;
            state.stockOperationConfig.counterParty = null;
          });
        },
      },
    })),
  ),
);

コンポーネント内にビジネスロジックを記述する場合 Testing-Library など結合テストで確認する必要があり実行コストが高くなるというデメリットもあります。 可能な限り actions のような純粋な関数にロジックを記述することで単体テストをしやすくするというメリットがあります。

selectors を store の側で一元管理する

storeを参照する selector関数を selectors.ts として store のファイルの隣に配置しています。 またコンポーネント側からはこの selector を利用せずに無名関数で state にアクセスすることを禁止しました。

そうすることで コンポーネントは state のデータ構造を知る必要がなく、データ構造を変更した場合の修正対象も selectors.ts のみに限定できます。

このプラクティスは実は zustand ではなく Redux の公式サイトに記載されています。 Define Selectors Alongside Reducers

zustand でもこのプラクティスが有用だと判断し採用しています。

selector関数を利用せずに無名関数でアクセスする例

FeeAmountコンポーネント

const feeAmount = useStoreStockDialogStore((state: StoreStockDialogStore) => state.formValues.feeAmount)
<BaseNumberInput value={feeAmount} />

selectors.tsにある selectFeeAmount を利用する例

FeeAmountコンポーネント

import { selectFeeAmount } from './selectors'
const feeAmount = useStoreStockDialogStore(selectFeeAmount);
<BaseNumberInput value={feeAmount} />

selectors.ts

export const selectFeeAmount = (state: StoreStockDialogStore) =>
  state.storeStockInfoFormValues.feeAmount;

データ構造のリファクタリングに強いというメリットだけでなく、先述した 更新に必要な状態同士の参照をactionsに定義する という施策と併せることでデータ構造とコンポーネントを疎結合にできるというメリットもあります。

useShallowをつかった配列への浅い参照

Reactではリストを表現する場合、親でリストのループを回し子要素にリストのアイテムを渡す実装が一般的です。 子供がリストアイテムの一部を更新すると親のコンポーネントが更新され子供すべてが再レンダリングします。

医薬品をループで回し、医薬品名を子供のコンポーネントで更新する例です。

const medicines = useStore(state => state.medicines)
const handleChangeMedicineName = useStore(state => state.handleChangeMedicineName)
<ul>
  {medicines.map((medicine) => (
    <li key={medicine.id}>
      <input 
        type="text" 
        value={medicine.name} 
        onChange={(e) => handleChangeMedicineName(medicine.id, e.target.value)} 
      />
    </li>
  ))}
</ul>

zustandではselectorの戻り値を Object.is で比較し更新された場合、再レンダリングが行われます。 上記の例では子供で親のリストの一部を更新しているため、リストを参照しているul 全体が再レンダリングされます。

zustand には useShallow というAPIがあります。このAPIを利用することで selector が Object.is ではなく浅い参照を行うことで同じ結果を返すことができます。

AI在庫の医薬品情報の一覧を表示する例です。 親側で医薬品のidだけを浅く参照することで子供のコンポーネントでリストの一部を更新された場合でも再レンダリングを防ぐことができます。

const selectMedicineIds = (state: StoreStockDialogStore) =>
  state.medicineInfoList.map((i) => i.medicineId);

function MedicineList() {
  // 親要素では更新の対象にならない 医薬品の id だけを取得し子供に渡す
  const ids = useStoreStockDialogStore(useShallow(selectMedicineIds));

  return (
    <ul>
      {ids.map((id) => (
        <MedicineListItem key={id} medicineId={id} />
      ))}
    </ul>
  );
}

function MedicineListItem({ medicineId }: Props) {
  // 子要素では medicineId を利用して医薬品情報を取得する
  const name = useStoreStockDialogStore((state) => selectMedicineName(state, medicineId));
  const handleChangeMedicineName = useStore(state => state.handleChangeMedicineName)
  <li>
    <input 
      type="text" 
      value={name} 
      onChange={(e) => handleChangeMedicineName(medicineId, e.target.value)} 
    />
  </li>
}

上記の例のように実装することで子供(MedicineListItem)でリストの一部を更新した場合でもリスト全体の再レンダリングを防ぐことができます。

リスト全体の再レンダリングを防ぐ例

Stateの参照は状態が必要な末端のコンポーネントで行う

先述の通り、zustandでは selector の戻り値を Object.is で比較します。そのため、状態を参照する末端のコンポーネントごとに selector を使用することで、再レンダリングの範囲を最小限に抑えることができます。

この再レンダリングの範囲を小さくするアプローチを取った結果、自然とコンポーネントが小さくなり、複数の責務を持つコンポーネントが作りづらくなるというメリットがありました

❌ 複数の状態をまとめて扱う実装例

この例では、nameを更新すると、priceやquantityも再レンダリングされてしまいます。

function Medicine() {
  const medicine = useStoreStockDialogStore(selectMedicine);
  <div>
    <input 
      type="text" 
      value={medicine.name} 
    />
    <input 
      type="text" 
      value={medicine.price} 
    />
    <input 
      type="text" 
      value={medicine.quantity} 
    />
  </div>
}

✅ 末端のコンポーネントで状態を参照する例

function Medicine() {
  <div>
    <MedicineName />
    <MedicinePrice />
    <MedicineQuantity />
  </div>
}

function MedicineName() {
  const medicineName = useStoreStockDialogStore(selectMedicineName);
  return (
    <input 
      type="text" 
      value={medicine.name} 
    />
  )
}

function MedicinePrice() {
  const medicinePrice = useStoreStockDialogStore(selectMedicinePrice);
  return (
    <input 
      type="text" 
      value={medicine.price} 
    />
  )
}

function MedicineQuantity() {
  const medicineQuantity = useStoreStockDialogStore(selectMedicineQuantity);
  return (
    <input 
      type="text" 
      value={medicine.quantity} 
    />
  )
}

クライアントバリデーションの実装

従来の実装では React Hook Form と zod を組み合わせてバリデーションを実装していました。

const form = useForm({
  resolver: zodResolver(schema),
});

今回状態管理を React Hook Form から zustand に変更するためバリデーションを自前で実装する必要がありました。 zod には safeParse というAPIがあり作成した Schema で parse した場合にエラーになるかどうかのチェックができます。

stringSchema.safeParse(12);
// => { success: false; error: ZodError }

今回はこのAPIを selector で利用することで、State の入力値が不正な場合にエラーメッセージを返す selector を作成しました。

const selectStoreStockInfoErrors = (state: StoreStockDialogStore) => {
  const result = storeStockInfoFormSchema.safeParse(state.storeStockInfoFormValues);
  if (result.success) return undefined;
  // result.error.format() を実行することで Schema で定義した要素に対するエラーメッセージを取得できます。
  return result.error.format(); 
};

// 手数料の入力値にエラーがあればメッセージを返す selector
const selectFeeAmountErrorMessage = (state: StoreStockDialogStore) =>
  selectStoreStockInfoErrors(state)?.feeAmount?._errors[0];

immer を利用してコードを安全に更新する

zustand では middleware としてimmer を利用することができます。 immer の最大のメリットは、元の状態をミュータブルな形で記述しながら、実際にはイミュータブルな(不変の)状態更新を実現できることです。

以下は zustand と immer を組み合わせた例です。

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

type State = {
  count: number
}

type Actions = {
  increment: (qty: number) => void
  decrement: (qty: number) => void
}

export const useCountStore = create<State & Actions>()(
  immer((set) => ({
    count: 0,
    increment: (qty: number) =>
      set((state) => {
        state.count += qty
      }),
    decrement: (qty: number) =>
      set((state) => {
        state.count -= qty
      }),
  })),
)

今回実際のコードでもネストの深いオブジェクトの一部を変更する場合や、Arrayの破壊的なメソッドが使いたい場合などに役に立ちました。

Arrayを破壊的に変更する実装の例

addLotListItem: (medicineId) => {
  set((state) => {
    const medicine = getMedicine(medicineId, state);
    medicine.formValues.lots.push({ ...INITIAL_LOT_VALUE, id: uuid() });
  });
},
deleteLotListItem: (medicineId, lotId) => {
  set((state) => {
    const medicine = getMedicine(medicineId, state);
    if (medicine.formValues.lots.length === 1) {
      state.medicineInfoList = state.medicineInfoList.filter(
        (medicineInfo) => medicineInfo.medicineId !== medicineId,
      );
      return;
    }
    const lotIndex = medicine.formValues.lots.findIndex((lot) => lot.id === lotId);
    medicine.formValues.lots.splice(lotIndex, 1);
  });
},

Redux Devtools を使って状態を可視化する

zustand は Redux DevTools を利用することができます。

middlewareとして Storeに組み込むことで利用可能になります。

export const useStoreStockDialogStore = create<StoreStockDialogStore>()(
  devtools(
    immer((set) => ({
      ...
    })),
  ),
);

実際に画面上でも状態が更新された時の差分をみることができ開発中に重宝しました。

devtool

Testing-Library を使った自動テストの追加

今回リアーキテクチャするに当たってフロントエンドでの結合レベルでの自動テストを追加しました。 元々関数に対する単体テストは存在しましたが、結合レベルでの自動テストが存在せず開発者自身も挙動を理解できていませんでした。

React Testing Library を利用してダイアログの振る舞いに関するテストを追加しました。

以下は実際のテストケースの一例です。

describe('入庫情報', () => {
  setupFixtureForStoringStockDialog();
  it('StoringStockDialog-integ-26', async () => {
    const mock = [
      mockForStockOperationDefaultSettingsQuery({ feeAmount: 0, containerAmount: 0 }),
      mockForExternalCounterpartySelectOptionsExternalCounterpartySelectV2Query(),
    ];
    server.use(...mock);

    const { user, dialogElement, noteInput } = await 事前準備();
    const dialog = within(dialogElement);

    // 入庫区分を法人間入庫に変更する
    await storeStockDialogUtils.selectReason(
      user,
      dialogElement,
      storeStockDialogUtils.STORE_STOCK_REASON.externalTrade,
    );

    // 区分を変更したときに…
    // > 入庫元が空になること(法人間入庫用の入庫元セレクタに変わること)
    const externalTradeCounterPartySelect = await dialog.findByTestId(
      'external-counterparty-select-v2',
    );
    expect(externalTradeCounterPartySelect.textContent).toBe('選択してください');

    // > 入庫対象日と備考が変わらないこと
    const datePickerInput = (await dialog.findByRole('textbox', {
      name: '入庫対象日',
    })) as HTMLInputElement;
    expect(datePickerInput).toHaveValue('2021/12/31(金)');
    expect(noteInput).toHaveValue('あいうえお');

    // > 手数料・容器代がログインしている薬局の法人間設定になっていること
    const changedFeeAmountInput = dialog.getByRole('spinbutton', { name: '手数料' });
    expect(changedFeeAmountInput).toHaveValue('0');
    const changedContainerAmountInput = dialog.getByRole('spinbutton', { name: '容器代' });
    expect(changedContainerAmountInput).toHaveValue('0');

    // >  入庫情報エリアに info アイコンとポップオーバーがあること
    const infoIcon = within(dialog.getByTestId('storing-origin-counter-party-form')).getByRole(
      'button',
      { name: 'infoPopover' },
    );
    await user.click(infoIcon);
    const popoverText = dialog.getByText(
      '選択した取引先の設定を適用します。未入力の場合はデフォルト設定を適用します。',
    );
    expect(popoverText).toBeInTheDocument();

    // > 取引先を登録するリンクが表示されること
    const linkText = dialog.getByRole('link', { name: '取引先を登録する' });
    expect(linkText).toBeInTheDocument();

    // > 法人間取引先が選択肢に表示されていること
    const counterpartyCombobox = await dialog.findByRole('combobox', { name: '入庫元' });
    await user.click(counterpartyCombobox);
    expect(screen.getByRole('option', { name: 'aa' })).toBeInTheDocument();
    expect(screen.getByRole('option', { name: '株式会社表示なし' })).toBeInTheDocument();
  });
});

以下が追加したテストの一覧の一部です。

テストケース一覧

最初はチームメンバーも結合レベルの自動テストの書き方に慣れておらず、実装に時間が掛かっていましたが何度もテストを書き続けることで練度が上がりテストを書くスピードも上がりました。 また自動テストを追加したことで、開発者自身がコードの品質に自信を持つことができるようになりました。

まとめと感想

  • ダイアログの状態管理を React Hook Form から zustand へ移行したことで状態管理をしやすくなり、複雑な仕様もコードで表現できるようになりました。
  • コンポーネントとStoreで関心を分離したり、コンポーネント小さく保つことで、コードの可読性があがりテストしやすくなりました。
  • zustand の middleware(immer, devtools) を使うことで開発プロセスを改善しました。
  • 結合レベルでのテストを導入したことで開発者のコード品質への自信が向上しました。

以下はリアーキテクチャ後のダイアログです。 再レンダリングの範囲が必要最小限になったことが確認できます。

再レンダリングが限定的な例

zustand や 結合レベルの自動テストの導入をしながら開発を進めることで思ったよりスピードが出せないのではないか?メンバーがテスト追加に疲れるのでは?という不安がありました。 途中からメンバー自身から「テストないと不安」という声も上がるようになり、導入してよかったなと考えています。

zustand を導入した結果 selectors や actions がかなり肥大化しました。これは zustand のデメリットではなく元々仕様が複雑というのが問題なので今後の改善で仕様を含めて整理できるとよいなと考えています。

今回のAI在庫の入庫ダイアログのリアーキテクチャの例が、zustand を普段使っている開発者やこれから導入しようと思っている開発者の参考になれば幸いです。