🐈‍⬛

軽量なグローバル状態管理ライブラリ「zustand」

2023/08/08に公開

はじめに

SWRTanstack Queryといったライブラリが現れ、サーバーのデータをクライアントで管理することが少なくなり、グローバルでの状態管理も少なくなりました。
ただそれでも、モーダルの開閉、トースターの表示、ダークモードなど、グローバルで管理したい状態は、まだまだ多くあると思います。

Reactは、useContext(以下 Context)を用いてグローバルな状態管理を実現できますが、状態が少ないうちは良いのですが、それが多くなってきた時に、Providerタワー(参考: https://zenn.dev/uhyo/articles/provider-tower-to-recoil)が建設され、Provider同士の依存関係を管理するコストが高まったり、コード量が多かったりなど、規模が大きくなると気になる点がいくつかあると思います。(その他にもパフォーマンスが良くないと言われることもあります)

そのため、グローバルでの状態管理を別のライブラリに任せることで、これらの問題を解消したいというニーズが出てくると思います。

自分は、まだContextで特に辛さを感じてないですが、将来的に辛くなる時がありそうだなと思い、zustandを触ってみたので、それをまとめたいと思います。

zustandの基本的な使い方

https://github.com/pmndrs/zustand

以下のバージョンで試しています。

"dependencies": {
   "next": "13.4.10",
   "react": "18.2.0",
   "react-dom": "18.2.0",
   "typescript": "5.1.6",
   "zustand": "^4.3.9"
 }

以下のコードで、状態管理のためのstoreの作成とコンポーネントからの参照が可能です。
ContextやReduxの使用経験がある方からすると、コード量がかなり少なく感じるのではないでしょうか。

// store
import { create } from 'zustand';

type State = {
  bears: number;
};

type Action = {
  increaseBear: (by: number) => void;
};

export const useStore = create<State & Action>()((set) => ({
  bears: 0,
  increaseBear: (by) => set((state) => ({ bears: state.bears + by })),
}));

// component
import { useStore } from '@/store/zustand';

export const Zustand = () => {
  const bears = useStore((state) => state.bears);
  const increaseBear = useStore((state) => state.increaseBear);

  return (
    <div>
      <div>🐻: {bears}</div>
      <button onClick={() => increaseBear(1)}>+ 1</button>
    </div>
  );
};

上記が、基本的な使い方になります。

middleware

次にzustandはライブラリが用意しているmiddlewareを使用することが出来るので、それも紹介します。(middlewareは自作することもできます)

devtools

devtoolsは、Redux DevTools でzustandの状態を確認できるようにするためのmiddlewareです。

実際のコードを見てみましょう。
先ほど作成したstoreに、 zustand/middleware からimportした devtoolsを追加します。

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

type State = {
  bears: number;
};

type Action = {
  increaseBear: (by: number) => void;
};

export const useStore = create<State & Action>()(
  devtools((set) => ({
    bears: 0,
    increaseBear: (by) => set((state) => ({ bears: state.bears + by }), false, 'increaseBear'),
  })),
);

たったこれだけでRedux DevTools でzustandの状態を確認できるようになります。

immer

immer を使うことで、破壊的なメソッドを使用しても、安全にイミュータブルにstateを更新できます。
これによって、とてもシンプルにstate更新の処理を書くことができます。(redux-toolkitも内部でimmerを使っているので同じ書き方が可能です)

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

type State = {
  bears: number;
};

type Action = {
  increaseBear: (by: number) => void;
};

export const useStore = create<State & Action>()(
  immer(
    devtools((set) => ({
      bears: 0,
      increaseBear: (by) => set((state) => (state.bears = state.bears + by), false, 'increaseBear'),
    })),
  ),
);

persist

persistを使うことで、localStorageなどで簡単にデータを永続化させることができます。
persistはドキュメントに詳しく載っているので、とても参考になりました

import { create } from 'zustand';
import { useState, useEffect } from 'react';
import { persist } from 'zustand/middleware';

type State = {
  bears: number;
};

type Action = {
  increaseBear: (by: number) => void;
};

export const useBearsStore = create<State & Action>()(
  persist(
    (set, get) => ({
      bears: 0,
      increaseBear: () => set(() => ({ bears: get().bears + 1 })),
    }),
    {
      name: 'bear-storage', // ユニークな名前
    },
  ),
);

Next.jsで使用する場合は、上記だけだとText content does not match server-rendered HTMLというエラーが発生します。

これはSSRでサーバーでレンダリングされたHTMLとブラウザのローカルストレージのデータを使ってクライアントでレンダリングしたHTMLが異なるからです。

次のようにして回避することができます(公式で紹介されているものです)

export const useStore = <T, F>(
  store: (callback: (state: T) => unknown) => unknown,
  callback: (state: T) => F,
) => {
  const result = store(callback) as F;
  const [data, setData] = useState<F>();

  useEffect(() => {
    setData(result);
  }, [result]);

  return data;
};
import { useBearsStore, useStore } from '@/store/zustand';
import styles from '../../styles/Home.module.css';

export const Zustand = () => {
  const bears = useStore(useBearsStore, (state) => state.bears);
  const increaseBear = useBearsStore((state) => state.increaseBear);

  return (
    <div className={styles.container}>
      <div>🐻: {bears}</div>
      <button onClick={() => increaseBear(1)}>+ 1</button>
    </div>
  );
};

おわりに

zustandの基本的な使い方とmiddlewareを紹介させていただきました。
軽量なライブラリですが、middlewareを使うことでContextではできないことが簡単に実現できるので、Contextに辛さを感じた時にzustandを検討しても良さそうです。

こちらにzustandを使う上で知っておいた方が良さそうなことが色々書いてあるので、時間がある時に見てみようと思います。
https://github.com/pmndrs/zustand/tree/main/docs/guides

ちなみに、バンドルサイズはこんな感じです。
ライブラリ単体なので、例えば、reduxだとtoolkitを入れるとなると全然変わりそうです。
zustand

redux

recoil

xstate

株式会社スタメン

Discussion