KAKEHASHI Tech Blog

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

zustand v5へのアップデートと reselectの導入

こんにちは、カケハシのAI在庫管理チームでフロントエンドエンジニアをしている Nokogiri です。今回は、AI在庫で利用している zustand をv4系からv5系にアップデートした際に、reselect を導入した理由と、その経緯について説明します。

zustand導入の経緯

zustand導入の経緯や活用法に関しては以下の記事をご覧ください。

v5で何が変わったか?

変更点はいくつかあります。

  • デフォルトのエクスポートの削除
  • 非推奨の機能の削除
  • React 18 を最低限必要なバージョンにする
  • React.useSyncExternalStore にピュアに依存するようになる

などなど

正式な情報についてはv4 -> v5マイグレーションガイド をご確認ください

v5アップデートでプロダクションコードに影響があったもの

今回は変更点の中でも Requiring stable selector outputs に関してプロダクトコードの変更が必要になりました。

Requiring stable selector outputs とは?

selectorの計算結果が安定する必要がある という意味です。

例えば以下の例ではselectorが常に新しい配列を返し計算結果が安定しません。その結果無限ループが発生しReactのコンポーネントが再レンダリングする可能性があります。

const [searchValue, setSearchValue] = useStore((state) => [
  state.searchValue,
  state.setSearchValue,
])
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

もともと zustand では use-sync-external-store というReact 18より前のバージョンでも useSyncExternalStore を利用できるようにするためのライブラリが利用されていました。今回 zustand v5で React 18未満のサポートを辞めたこともあり、内部の状態管理にuseSyncExternalStore を利用するようになっています。

参考:v5のPRの該当箇所

useSyncExternalStore を利用する場合 Reactのコンポーネントが再レンダリングをするかどうかは selectorの戻り値を Object.is で比較します。先述の例のように毎回新しい配列を返すと無限ループが発生するということです。

弊社のプロダクトコードではこの変更によって無限ループが発生するようになっていました。 保存ボタン押下時のリクエスト作成するための selector で画面内の入力フォームの状態をかき集めてサーバーに送信する値を作成する処理でした。

selectors.ts

// リクエストパラメータの組み立て
export const selectRequestParam = (state) => {
  const email = state.formValues.email;
  const items = state.formValues.list.map((item) => ({
    id: item.id,
    price: calculatePrice(item.price, item.discount),
    date: formatDate(item.date)
  }));
  const taxRate = items.length > 10 ? 5 : 10
  return {
    email,
    items,
    taxRate
  };
};

SubmitButton.tsx

//useAppStoreフックを使って、リクエストパラメータを組み立てるために選択された値(emailやlist)を取得しています。
const param = useAppStore(selectRequestParam)

上記の例では selector が常に新しいオブジェクトを返すことになります。

無限ループを回避するために

公式のマイグレーションガイドでは useShallow を利用した浅い参照を行う方法や

// v5
import { useShallow } from 'zustand/shallow'

const [searchValue, setSearchValue] = useStore(
  useShallow((state) => [state.searchValue, state.setSearchValue]),
)

zustand のcreateの代わりに 'zustand/traditional' の createWithEqualityFn を使う方法が紹介されています。

// v5
import { createWithEqualityFn as create } from 'zustand/traditional'

チーム内で話し合った時に、'zustand/traditional' を利用するのは後ろ向きな選択なので避けたく、useShallow の使い方を誤ると必要なタイミングで再レンダリングが発生しなくなるかもしれないという結論になりました。

案① selectorはstateの値を返すためだけに利用する

selectorが安定した値を返すには、シンプルにselectorが返す値にビジネスロジックを含めないという案です。

selectors.ts

const selectEmail = state => state.formValues.email;
const selectList = state => state.formValues.list;

SubmitButton.tsx

const email = useAppStore(selectEmail)
const list = useAppStore(selectList)

// コンポーネント側に記述するとごちゃつくので別途関数を準備してパラメータを組み立てるようにする
const items = state.formValues.list.map((item) => ({
  id: item.id,
  price: calculatePrice(item.price, item.discount),
  date: formatDate(item.date)
}));
const taxRate = items.length > 10 ? 5 : 10

const param = {
  email,
  items,
  taxRate
}
...
return (<Button type="submit">送信</Button>)

案①はシンプルですが、ビジネスロジックがコンポーネントに露出するため、メンテナンス性が低くなる可能性があります。

案② reselect を利用した selector のメモ化

「複雑なビジネスロジックはコンポーネントに記述するのではなく、selectorに押し込めてテストしやすくする」というプロダクトの設計方針があり、案①だとそれが難しくなるのでは?という懸念がありました。

Redux では selector の中で Array.prototype.filterArray.prototype.map などを使って新しい配列を返す場合など、同じ入力が渡された場合に結果の再計算を回避するためにメモ化を行うことがガイドに記載されています。 Reduxのselectorのメモ化による最適化

Reduxでは Reselect を使ってselectorのメモ化をできるようにしています。

zustandのissue でも 無限ループを回避する手段の一つとしてreselect を使う例が挙がっていました。

Reselect とは?

selector 関数をメモ化することができるライブラリで、ReduxToolkit にはデフォルトで同梱されています。 Reduxには依存しておらず reselect 自体を独立して利用することが可能です。

https://github.com/reduxjs/reselect#readme

以下簡単なサンプルです。

// reselectから createSelector をimport
import { createSelector } from "reselect";

const data = { a: 1, b: 2 };

const selectA = (state) => state.a;
const selectB = (state) => state.b;

const calc = createSelector(
  [selectA, selectB], // selector関数を配列で受け取ることができます。
  (a, b) => { // selector関数の実行結果を引数として受け取れます。
    console.log("called");
    return a + b;
  },
);

console.log(calc(data)); 
console.log(calc(data));
console.log(calc(data));

実行結果は以下の通りで、calc 関数は3度呼ばれますが、計算結果をメモ化するため called が呼ばれるのは一度です。

called
3
3
3

プロダクションコードでどのように使うか

先ほどのリクエストパラメータの組み立てであれば以下のようになります。

selectors.ts

export const selectRequestParam = createSelector(
  [(state) => state.formValues.email, (state) => state.formValues.list],
  (email, list) => {
    const items = state.formValues.list.map((item) => ({
      id: item.id,
      price: calculatePrice(item.price, item.discount),
      date: formatDate(item.date),
    }));
    const taxRate = items.length > 10 ? 5 : 10;
    return {
      email,
      items,
      taxRate,
    };
  },
);

呼び出し側の SubmitButton.tsx に変更はありません。

const param = useAppStore(selectRequestParam)

reselect を採用

チームで案①、案②を検討した結果、案②の reselect を採用することにしました。 先述したとおり案①の場合 selector の実装はシンプルになるのですが、コンポーネント側にビジネスロジックが露出してしまうことが懸念されます。 結果として、ビジネスロジックをコンポーネントから切り離し、テスト可能で保守性の高いコードを書くために、案②のreselectを採用しました。

まとめと感想

  • zustandをv4からv5にアップデートする過程で、プロダクションコードに変更が必要になりました。
  • selectorの戻り値を安定させるために reselect を導入しました。その結果無限ループが発生しないようになりました。

zustandのバージョンアップ時に対応した内容について紹介しました。他の開発者の皆さんにも役立つことを願っています!