💥

zustandをもっと使いやすくするためのcustom hook戦略

2024/07/04に公開

はじめに

非同期状態管理(SWR/Tanstack Query)で大体は出来そうだなぁということでClient側での状態管理をそこまで深く考えてなかったのですが横断的に管理したいなぁという事象が例に漏れずでてきたので最初はContextAPIでやるかぁ🤔と思っていたのですが、拡張していくとprovider波動拳≒if波動拳になることも予想できたので他にないかなぁということで検討しており
結果としてzustandを使うことにしました。

providerでも沢山作るとこんな感じで波動拳打てるので。。。

調査していく中で公式や以下の非常にわかりやすく纏めてくださっている
記事がとても参考になりました🙇

https://zustand-demo.pmnd.rs/

https://zenn.dev/stmn_inc/articles/f1101cfa20dedc

公式に沿ったシンプルな形での導入でも良いのですが、ちょっと個人的には使い勝手が悪いなぁと思うことがあったのでGPTや師匠に相談しながらよりプロダクトにフィットする方法でのcustom hook戦略を考えたので備忘録も兼ねて以下に残しております。

結論

directory構造
store
├── slice
│   └── xxxxSlice.ts
│   └── xxxxSlice.ts
└── types
│    └── index.ts
└── index.ts
store/index.ts
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { createWithEqualityFn } from 'zustand/traditional';

import { createXXXXSlice } from './slice/xxxxSlice';
import { createXXXXSlice } from './slice/xxxxSlice';

import type { Store } from './types';

export const useStore = createWithEqualityFn<Store>()(
  devtools(
    persist(
      subscribeWithSelector(
        immer((...a) => ({
          ...createXXXXSlice(...a),
          ...createXXXXSlice(...a),
        }))
      ),
      {
        name: 'local-storage',
      }
    )
  )
);
store/types/index.ts
import { xxxxSlice } from '../slice/xxxxSlice';
import { xxxxSlice } from '../slice/xxxxSlice';

export type Store = xxxxSlice;

sampleとして時間を横断的に扱う場合の例としてsliceをdateSlice.tsとして定義しております。
(今回、App内で日付情報を局所的に同一状態のものとして持っておきたかったので)

dateSlice.ts
import { addMonths, subMonths } from 'date-fns';
import { StateCreator } from 'zustand';

type DateState = {
  date: Date;
};

type DateActions = {
  goToday: () => void;
  goNextMonth: () => void;
  goPrevMonth: () => void;
};

export type DateSlice = DateState & DateActions;

const initialState: DateState = {
  date: new Date(),
};

export const createDateSlice: StateCreator<
  DateSlice,
  [['zustand/immer', never]],
  [],
  DateSlice
> = (set) => ({
  ...initialState,
  goToday: () => set({ date: new Date() }),
  goNextMonth: () => set({ date: subMonths(initialState.date, 1) }),
  goPrevMonth: () => set({ date: addMonths(initialState.date, 1) }),
});

// selector
export const dateSelector = (state: DateSlice) => ({
  date: state.date,
  goToday: state.goToday,
  goNextMonth: state.goNextMonth,
  goPrevMonth: state.goPrevMonth,
});

custome hookとしてuseStoreをhooks内に定義

useStore.ts
import { shallow } from 'zustand/shallow';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { useStore as useZustandStore } from '../store';

const useStore = <U>(selector: (state: any) => U): U => {
  return useStoreWithEqualityFn(useZustandStore, selector, shallow);
};

export default useStore;
実際に使う場面の例
import useStore from '@/app/hooks/useStore';
import { dateSelector } from '@/app/store/slice/dateSlice';

// componentやそれぞれのhooks内から以下の様に参照が可能になります。
// zustandのuseStoreではなくWrapperとして作った方を使うようにします。
~~
const { date } = useStore(dateSelector);

型情報の参照について

解説

store/index.tsの解説は上記にも引用させて頂きましたが以下の記事がすごく丁寧にまとめてくださっており、そちらを参考にして頂いた方が早いので割愛させて頂きます。

※特にimmerdevtoolsなど

https://zenn.dev/stmn_inc/articles/f1101cfa20dedc

useStore.ts(custom版)について

このコードは、Zustandの状態管理ライブラリを使ってカスタムフックを定義し、選択した状態を効率的に取得するためのものです。以下に各部分の詳細な説明をします。

各importについて
import { shallow } from 'zustand/shallow';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { useStore as useZustandStore } from '../store';

shallow

Zustandのヘルパー関数で、浅い比較を行います。これにより、オブジェクトのプロパティが変わらない限り、再レンダリングを防ぐことができます。

useStoreWithEqualityFn

Zustandのフックで、選択された状態に基づいて比較関数を使って状態を取得します。
この場合、浅い比較関数(shallow)を使用します。

useStore as useZustandStore

他のモジュールからインポートしたZustandストアのフックを、useZustandStoreという別名で使用しております。


useStore部分
const useStore = <U>(selector: (state: any) => U): U => {
  return useStoreWithEqualityFn(useZustandStore, selector, shallow);
};

useStore関数

ジェネリック型<U>を受け取るカスタムフックで、selector関数を引数に取ります。
selector関数は、ストアの状態から特定の部分を選択するために使用されます。

useStoreWithEqualityFnの呼び出し

useStoreWithEqualityFnを使用して、useZustandStoreから状態を取得します。
selector関数を使用して必要な状態を選択し、shallow関数を使って浅い比較を行います。

コード解説のまとめ

このカスタムフックuseStoreは、Zustandのストアから特定の状態を効率的に取得するために設計されています。浅い比較(shallow)を使用することで、選択された状態が実際に変更されたときのみコンポーネントを再レンダリングするように最適化されています。これにより、パフォーマンスの向上が期待できます。

なぜ、この作りにしたかったのか?

  • フロントエンドとしてのcontext(≒domain)毎にsliceを作ることで無駄な参照を減らせる。
    • zustandを使う意義でもあります。
  • 公式にあるような形で状態・状態変更関数state.xxx.yyyという方法で取得したくなかった。
    • 参照までのメソッドチェーンが長くなることでコード量が増え、可読性が落ちるのが嫌だった。
  • selectorを引数に使うことで何(のcontext)を参照しているのかひと目でわかるようにしたかった。
    • context毎にuseXXXStoreを作成し、importして使う方法を最初は考えていましたが元になるwrapperは一つである方が楽だなぁと思い最終的にこの方法になりました。

メリット

コード解説部分にもありますが、zustandの特徴でもあるパフォーマンス部分を損なわず使い勝手もいい形で参照できるようになりました。

デメリット??

そもそもxxxSliceを沢山作るつもりはないので(大体をTanstack Queryの方に寄せることになりそうなので)
ちょっと大げさかな🤔と...思ったり、思わなかったりしてます。

まとめ

zustandは、はじめて触ったのですが扱いやすくてとても良さそうです。
そんなに複雑なことをzustand側ではさせず薄く使う場合の参考にしていただけると嬉しいです!!

今はこちらの発売が楽しみです(波動拳はこちらで打ちたいですw)

https://www.capcom-games.com/marvel-vs-capcom-fc/ja-jp/

引用

参考にさせて頂きました。
ありがとうございました🙇

Discussion