Zustand(ズースタンド)は、create でストアを作り、コンポーネントからフックとして呼ぶだけで使えるReactの状態管理ライブラリです。 Providerで包む必要がなく、セレクタで選んだ値が変わったときだけ再レンダリングされるので、少ないコードで無駄の少ない画面を作れます。

この記事では、Zustand 5.0.15を実際に動かし、セレクタ無し・セレクタ有り・useShallowの3通りでレンダー回数を数えた結果、v5で出やすい「Maximum update depth exceeded」の再現と直し方、同じカウンターをContext・Redux Toolkit・Zustandで書いた行数の比較、persist で保存されるJSONの中身、Vitestでストアを初期化する方法までを、出力つきでまとめます。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0 / Zustand 5.0.15 / Redux Toolkit 2.13.0 / React Redux 9.3.0 / TypeScript 7.0.2。実行はVitest 5.0.3とjsdom 30.1.1で行い、console.log の出力を掲載しています。

Zustandの早見表

やりたいこと 書き方 ポイント
ストアを作る create<State>()((set) => ({ ... })) TypeScriptでは create<State>()(...) の形で型を渡す
値を読む useStore((state) => state.count) セレクタで必要な値だけ選ぶ
値を更新する set({ text }) 渡したキーだけが上書きされる
前の値から更新する set((state) => ({ count: state.count + 1 })) 関数を渡す
複数の値をまとめて読む useStore(useShallow((s) => ({ a: s.a, b: s.b }))) オブジェクトや配列を返すなら useShallow
コンポーネントの外で読む・更新する useStore.getState() / useStore.setState() テストやイベント処理で使える
変更を監視する useStore.subscribe((state) => ...) 戻り値の関数で解除
localStorageに保存する persist(..., { name: 'キー' }) zustand/middleware からimport
保存する項目を絞る partialize: (s) => ({ theme: s.theme }) 関数や一時的な値を外す
保存データの形を変えた version を上げて migrate を書く 以前の形のデータを変換できる
テストで初期状態に戻す useStore.setState(useStore.getInitialState(), true) beforeEach で呼ぶ

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Zustandの使い方|persistとRedux・Contextとの違い」

準備:インストールと実行方法

mkdir zustand-demo && cd zustand-demo
npm init -y
npm pkg set type=module
npm i react react-dom zustand @reduxjs/toolkit react-redux
npm i -D vitest jsdom typescript @types/react @types/react-dom

Redux ToolkitとReact Reduxは、後半の比較のために入れています。Zustandだけ使うなら npm i zustand の1つで足ります。

この記事のコードは、Vitestのテストとして動かしています。ブラウザの代わりにjsdomを使う設定です。

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: { environment: 'jsdom', setupFiles: ['./src/setup.ts'] },
});
// React の act() をテスト環境で使うための設定
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
{
  "compilerOptions": {
    "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
    "jsx": "react-jsx", "strict": true, "noEmit": true, "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}

実行は次のコマンドです。--reporter=verbose を付けると、テストの中の console.log が表示されます。

npx vitest run --reporter=verbose

基本:createでストアを作り、setで更新する

状態(count、text)と、それを更新する関数(inc、setText)を、1つのオブジェクトにまとめて書きます。

import { create } from 'zustand';

type CounterState = {
  count: number;
  text: string;
  inc: () => void;
  setText: (text: string) => void;
};

export const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  text: '',
  inc: () => set((state) => ({ count: state.count + 1 })),
  setText: (text) => set({ text }),
}));
部品 役割
create ストアを作り、フック(useCounterStore)を返す
set 状態を更新する関数。渡したオブジェクトが今の状態に浅く合成される
set((state) => ...) 今の状態を受け取って、次の値を計算する形
useCounterStore((state) => state.count) コンポーネントで値を読む。引数の関数がセレクタ

set({ text }) と書いたとき、count や inc はそのまま残ります。スプレッド構文で全体をコピーする必要はありません。Providerで包む手順がないので、ストアのファイルをimportすればどのコンポーネントからでも使えます。

レンダー回数を数える:セレクタ無し・セレクタ有り・useShallow

値の読み方によって、再レンダリングの回数が変わります。3つのコンポーネントを並べて、text と count を更新したときの回数を数えました。

import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { test } from 'vitest';
import { useShallow } from 'zustand/react/shallow';
import { useCounterStore } from './store';

const renders = { noSelector: 0, selector: 0, shallow: 0 };

// 1. セレクタ無し:ストア全体を受け取る
function NoSelector() {
  const { count } = useCounterStore();
  renders.noSelector++;
  return <p>{count}</p>;
}

// 2. セレクタ有り:count だけを受け取る
function WithSelector() {
  const count = useCounterStore((state) => state.count);
  renders.selector++;
  return <p>{count}</p>;
}

// 3. useShallow:複数の値をオブジェクトで受け取る
function WithShallow() {
  const { count, inc } = useCounterStore(
    useShallow((state) => ({ count: state.count, inc: state.inc })),
  );
  renders.shallow++;
  return <button onClick={inc}>{count}</button>;
}

test('レンダー回数を数える', async () => {
  const root = createRoot(document.body.appendChild(document.createElement('div')));
  await act(async () => {
    root.render(<><NoSelector /><WithSelector /><WithShallow /></>);
  });
  console.log('初回表示後        ', JSON.stringify(renders));

  for (const text of ['a', 'ab', 'abc']) {
    await act(async () => useCounterStore.getState().setText(text));
  }
  console.log('text を3回更新後  ', JSON.stringify(renders));

  for (let i = 0; i < 2; i++) {
    await act(async () => useCounterStore.getState().inc());
  }
  console.log('count を2回更新後 ', JSON.stringify(renders));

  await act(async () => useCounterStore.getState().setText('abc'));
  console.log('text に同じ値を設定', JSON.stringify(renders));
});
初回表示後         {"noSelector":1,"selector":1,"shallow":1}
text を3回更新後   {"noSelector":4,"selector":1,"shallow":1}
count を2回更新後  {"noSelector":6,"selector":3,"shallow":3}
text に同じ値を設定 {"noSelector":7,"selector":3,"shallow":3}
読み方 textを3回更新 countを2回更新 同じ値を設定 合計
セレクタ無し useCounterStore() +3 +2 +1 7回
セレクタ有り (s) => s.count +0 +2 +0 3回
useShallow ({ count, inc }) +0 +2 +0 3回

3つのコンポーネントはどれも count しか表示していませんが、セレクタ無しでは、表示に使っていない text が変わるたびに再レンダリングされました。 set を呼ぶと状態のオブジェクトが新しく作られるので、ストア全体を受け取っているコンポーネントは毎回「変わった」と判定されます。最後の行のように、text に同じ値を設定しただけでも1回増えています。

セレクタ有りでは、選んだ値(count)が変わったときだけ再レンダリングされます。複数の値をまとめて受け取りたいときは、useShallow で包むと、オブジェクトの中身を1段だけ比べて「同じ」と判定してくれます。

Maximum update depth exceededを再現して直す

Zustand v5で出会いやすいのが、セレクタが毎回新しいオブジェクト(または配列)を返す書き方です。公式の移行ガイド(Migrating to v5)にも、セレクタが新しい参照を返すと無限ループになることがある、と書かれています。

import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { test, vi } from 'vitest';
import { useShallow } from 'zustand/react/shallow';
import { useCounterStore } from './store';

// セレクタが毎回新しいオブジェクトを返す
function Loop() {
  const { count, inc } = useCounterStore((state) => ({ count: state.count, inc: state.inc }));
  return <button onClick={inc}>{count}</button>;
}

// useShallow で包む
function Fixed() {
  const { count, inc } = useCounterStore(
    useShallow((state) => ({ count: state.count, inc: state.inc })),
  );
  return <button onClick={inc}>{count}</button>;
}

// 値ごとに分けて受け取る
function Split() {
  const count = useCounterStore((state) => state.count);
  const inc = useCounterStore((state) => state.inc);
  return <button onClick={inc}>{count}</button>;
}

async function mount(name: string, element: React.ReactNode) {
  const container = document.body.appendChild(document.createElement('div'));
  const root = createRoot(container);
  try {
    await act(async () => root.render(element));
    console.log(`${name}: 表示できた → ${container.innerHTML}`);
  } catch (e) {
    console.log(`${name}: ${(e as Error).message}`);
  }
}

test('新しいオブジェクトを返すセレクタ', async () => {
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
  await mount('Loop ', <Loop />);
  const warn = spy.mock.calls.map((c) => String(c[0]).split('\n')[0]);
  spy.mockRestore();
  console.log('console.error:', [...new Set(warn)].join(' / '));
  await mount('Fixed', <Fixed />);
  await mount('Split', <Split />);
});
Loop : 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.
console.error: The result of getSnapshot should be cached to avoid an infinite loop
Fixed: 表示できた → <button>0</button>
Split: 表示できた → <button>0</button>

Loop は表示の途中で止まり、Reactのエラーメッセージが出ました。(state) => ({ count: state.count, inc: state.inc }) は、呼ばれるたびに別のオブジェクトを作ります。Zustandは「前回と同じ結果か」を参照の比較で判定するので、毎回「変わった」ことになり、再レンダリングが止まらなくなります。コンソールに出ていた「The result of getSnapshot should be cached」も、同じ原因を指しています。

直し方 書き方 向いている場面
useShallowで包む useStore(useShallow((s) => ({ count: s.count, inc: s.inc }))) 複数の値を1回で受け取りたい
値ごとに分ける useStore((s) => s.count) と useStore((s) => s.inc) 値が2〜3個まで

セレクタの中で filter を使って新しい配列を作る形((s) => s.todos.filter((t) => !t.done))でも、同じメッセージで止まることを確かめました。「セレクタが返すのは、ストアに入っている値そのもの。新しく作るなら useShallow」と覚えておくと迷いません。useShallow は zustand/react/shallow からimportします。

Context・Redux Toolkit・Zustandで同じカウンターを書く

「ボタンを押すと数が増える」という同じ機能を、3つの方法で書いて比べます。どれもTypeScriptで、型も含めた完成形です。

Context

import { createContext, useCallback, useContext, useMemo, useState } from 'react';

type CounterValue = { count: number; inc: () => void };

const CounterContext = createContext<CounterValue | null>(null);

function CounterProvider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  const inc = useCallback(() => setCount((c) => c + 1), []);
  const value = useMemo(() => ({ count, inc }), [count, inc]);
  return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
}

function useCounter() {
  const value = useContext(CounterContext);
  if (!value) throw new Error('CounterProvider の内側で使ってください');
  return value;
}

function Counter() {
  const { count, inc } = useCounter();
  return <button onClick={inc}>count: {count}</button>;
}

export function App() {
  return (
    <CounterProvider>
      <Counter />
    </CounterProvider>
  );
}

Redux Toolkit

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useDispatch, useSelector } from 'react-redux';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    inc: (state) => {
      state.count += 1;
    },
  },
});

const store = configureStore({ reducer: { counter: counterSlice.reducer } });

type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

function Counter() {
  const count = useSelector((state: RootState) => state.counter.count);
  const dispatch = useDispatch<AppDispatch>();
  return <button onClick={() => dispatch(counterSlice.actions.inc())}>count: {count}</button>;
}

export function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

Zustand

import { create } from 'zustand';

type CounterState = { count: number; inc: () => void };

const useCounter = create<CounterState>()((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}));

function Counter() {
  const count = useCounter((state) => state.count);
  const inc = useCounter((state) => state.inc);
  return <button onClick={inc}>count: {count}</button>;
}

export function App() {
  return <Counter />;
}

3つとも同じテストで、ボタンを2回押して「count: 2」になることを確かめました。

import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { expect, test } from 'vitest';
import { App as ContextApp } from './counter-context';
import { App as RtkApp } from './counter-rtk';
import { App as ZustandApp } from './counter-zustand';

test.each([
  ['Context', ContextApp],
  ['Redux Toolkit', RtkApp],
  ['Zustand', ZustandApp],
])('%s のカウンターが動く', async (name, App) => {
  const container = document.body.appendChild(document.createElement('div'));
  await act(async () => createRoot(container).render(<App />));
  const button = container.querySelector('button')!;
  await act(async () => button.click());
  await act(async () => button.click());
  console.log(`${name}: ${button.textContent}`);
  expect(button.textContent).toBe('count: 2');
});
Context: count: 2
Redux Toolkit: count: 2
Zustand: count: 2

行数と部品の比較表

観点 Context Redux Toolkit Zustand
ファイルの行数(空行を除く) 25行(全31行) 26行(全31行) 14行(全18行)
追加のパッケージ なし @reduxjs/toolkit と react-redux zustand
Providerで包む 必要 必要 不要
状態の置き場所 Providerコンポーネントの useState createSlice と configureStore create の中
更新の書き方 setCount を包んだ関数 dispatch(actions.inc()) inc() を直接呼ぶ
型の準備 Contextの値の型 RootState と AppDispatch ストアの型1つ
必要な値だけを読む仕組み なし(valueが変わると利用側すべてが再レンダリング) useSelector セレクタ

使っていない値が変わったときの再レンダリング

もう1つ、数字に表れる違いがあります。「増やすボタン」は count を表示せず、inc だけを使うとしましょう。count が変わったとき、このボタンは再レンダリングされるでしょうか。

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { act, createContext, useContext, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider, useDispatch, useSelector } from 'react-redux';
import { test } from 'vitest';
import { create } from 'zustand';

const renders = { context: 0, redux: 0, zustand: 0 };

// --- Context:count と inc を1つの value で配る
const Ctx = createContext<{ count: number; inc: () => void }>({ count: 0, inc: () => {} });
let ctxInc = () => {};
function CtxProvider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, inc: () => setCount((c) => c + 1) }), [count]);
  ctxInc = value.inc;
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
function CtxButton() {
  const { inc } = useContext(Ctx); // inc しか使わない
  renders.context++;
  return <button onClick={inc}>+1</button>;
}

// --- Redux Toolkit
const slice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: { inc: (state) => { state.count += 1; } },
});
const store = configureStore({ reducer: { counter: slice.reducer } });
function ReduxButton() {
  const dispatch = useDispatch();
  renders.redux++;
  return <button onClick={() => dispatch(slice.actions.inc())}>+1</button>;
}
function ReduxCount() {
  return <span>{useSelector((s: ReturnType<typeof store.getState>) => s.counter.count)}</span>;
}

// --- Zustand
const useCounter = create<{ count: number; inc: () => void }>()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}));
function ZustandButton() {
  const inc = useCounter((s) => s.inc); // inc しか使わない
  renders.zustand++;
  return <button onClick={inc}>+1</button>;
}

test('count を使わないボタンのレンダー回数', async () => {
  const root = createRoot(document.body.appendChild(document.createElement('div')));
  await act(async () =>
    root.render(
      <>
        <CtxProvider><CtxButton /></CtxProvider>
        <Provider store={store}><ReduxButton /><ReduxCount /></Provider>
        <ZustandButton />
      </>,
    ),
  );
  console.log('初回表示後      ', JSON.stringify(renders));
  for (let i = 0; i < 3; i++) {
    await act(async () => ctxInc());
    await act(async () => { store.dispatch(slice.actions.inc()); });
    await act(async () => useCounter.getState().inc());
  }
  console.log('count を3回更新後', JSON.stringify(renders));
});
初回表示後       {"context":1,"redux":1,"zustand":1}
count を3回更新後 {"context":4,"redux":1,"zustand":1}

Contextでは、count と inc を1つのvalueにまとめて配っているため、count が変わるとvalueのオブジェクトが新しくなり、inc しか使っていないボタンも3回再レンダリングされました。Redux ToolkitとZustandでは、ボタンは最初の1回だけです。

Contextでも、値用と更新関数用にContextを2つに分ければ同じ結果にできます。Zustandは、その分け方を考えなくてもセレクタで自然に必要な分だけ読める、という点が使いやすさにつながっています。

どれを選ぶかの判断表

状況 選び方
テーマやログイン中のユーザーなど、変わる回数が少ない値を配りたい Context
複数の画面で共有し、よく更新される状態がある Zustand
sliceとdispatchという決まった書き方でチーム全体をそろえたい、既にReduxを使っている Redux Toolkit
1つのコンポーネントの中で完結する useState や useReducer
サーバーから取得したデータのキャッシュ TanStack Queryなどのデータ取得ライブラリ

persistでlocalStorageに保存する

persist は、ストアの状態をlocalStorageなどに保存し、次に開いたときに復元するミドルウェアです。create の中身を persist(...) で包み、第2引数にオプションを渡します。

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

type SettingsState = {
  theme: 'light' | 'dark';
  fontSize: number;
  draft: string; // 入力途中のメモ(保存しない)
  setTheme: (theme: 'light' | 'dark') => void;
  setDraft: (draft: string) => void;
};

export const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      fontSize: 16,
      draft: '',
      setTheme: (theme) => set({ theme }),
      setDraft: (draft) => set({ draft }),
    }),
    {
      name: 'settings', // localStorage のキー
      version: 1,
      // 保存する項目を選ぶ(draft と関数は保存しない)
      partialize: (state) => ({ theme: state.theme, fontSize: state.fontSize }),
      // 保存済みデータの version が違うときに呼ばれる
      migrate: (persisted, version) => {
        if (version === 0) {
          // v0 は { darkMode: boolean } という形だった
          const old = persisted as { darkMode?: boolean };
          return { theme: old.darkMode ? 'dark' : 'light', fontSize: 16 };
        }
        return persisted;
      },
    },
  ),
);

persistのオプション一覧

公式ドキュメント(persistのリファレンス)で確認できたオプションです。

オプション 必須 役割
name 必須 ストレージの中で使う、ほかと重ならないキー
storage 任意 保存先。既定は createJSONStorage(() => localStorage)
partialize 任意 保存する前に、状態から保存する項目を選ぶ関数
version 任意 保存データのバージョン番号
migrate 任意 バージョンが合わないときに、保存データを変換する関数
merge 任意 復元時に、保存データと今の状態を合成する方法。既定は浅い合成
onRehydrateStorage 任意 復元の前後に処理を挟む関数
skipHydration 任意 既定はfalse。trueにすると自動では復元しない

保存されるJSONの中身を見る

実際にlocalStorageへ何が入るのかを、更新のたびに表示しました。vi.resetModules() でストアのモジュールを読み込み直し、ページの再読み込みの代わりにしています。

import { beforeEach, test, vi } from 'vitest';

const show = (label: string) => console.log(label.padEnd(16), localStorage.getItem('settings'));
const pick = (s: { theme: string; fontSize: number; draft: string }) =>
  JSON.stringify({ theme: s.theme, fontSize: s.fontSize, draft: s.draft });

beforeEach(() => {
  localStorage.clear();
  vi.resetModules(); // ストアのモジュールを読み込み直す(ページの再読み込みの代わり)
});

test('保存されるJSONを見る', async () => {
  const { useSettingsStore } = await import('./settings');
  show('作成直後');
  useSettingsStore.getState().setDraft('書きかけ');
  show('draft を更新');
  useSettingsStore.getState().setTheme('dark');
  show('theme を更新');
});

test('再読み込みで復元される', async () => {
  localStorage.setItem('settings', '{"state":{"theme":"dark","fontSize":18},"version":1}');
  const { useSettingsStore } = await import('./settings');
  console.log('復元後の状態    ', pick(useSettingsStore.getState()));
});

test('v0 のデータを migrate で変換する', async () => {
  localStorage.setItem('settings', '{"state":{"darkMode":true},"version":0}');
  show('読み込み前');
  const { useSettingsStore } = await import('./settings');
  console.log('復元後の状態    ', pick(useSettingsStore.getState()));
  show('読み込み後');
});
作成直後             null
draft を更新        {"state":{"theme":"light","fontSize":16},"version":1}
theme を更新        {"state":{"theme":"dark","fontSize":16},"version":1}

ここから3つのことがわかります。

わかったこと 出力のどこか
ストアを作っただけでは保存されない。最初の更新で書き込まれる 「作成直後」が null
保存の形は {"state": {...}, "version": 数値} 2行目以降
partialize で選んだ theme と fontSize だけが入り、draft と関数は入らない state の中身

1つ目は、v5の移行ガイドに書かれている変更点(作成時に初期状態を保存しなくなった)と一致します。

再読み込みで復元される

localStorageに保存済みのデータを置いてからストアを読み込むと、その値で始まります。保存していない draft は初期値の空文字です。

復元後の状態     {"theme":"dark","fontSize":18,"draft":""}

versionとmigrateで、以前の形のデータを変換する

アプリを更新して保存データの形を変えたとき、利用者のブラウザには以前の形のデータが残っています。公式ドキュメントには、保存済みのバージョンが一致しないとそのデータは使われない、と書かれています。version を上げ、migrate で変換を書いておくと、以前のデータを引き継げます。

例として、version 0では { darkMode: true } という形だったデータを、version 1の { theme, fontSize } へ変換します(上の settings.ts の migrate)。

読み込み前            {"state":{"darkMode":true},"version":0}
復元後の状態     {"theme":"dark","fontSize":16,"draft":""}
読み込み後            {"state":{"theme":"dark","fontSize":16},"version":1}

darkMode: true が theme: "dark" に変換され、localStorageの中身もversion 1の形に書き換わりました。

場面 すること
項目を足しただけ(初期値がある) そのままで動く。保存データにない項目は初期値になる
項目の名前や型を変えた version を1つ上げ、migrate に変換を書く
保存したくない項目がある partialize で外す
キー名がほかのストアと重なりそう name をアプリ名つきにする(例:myapp-settings)

Vitestでストアをテストする

Zustandのストアはコンポーネントなしで動くので、getState() と setState() だけでテストを書けます。

ストアはテスト間で共有される

ストアはモジュールの中に1つだけ作られるため、同じファイルの中では前のテストの変更が残ります。初期化を入れずに書いた例です。

import { expect, test } from 'vitest';
import { useCounterStore } from './store';

test('inc で count が 1 増える', () => {
  useCounterStore.getState().inc();
  expect(useCounterStore.getState().count).toBe(1);
});

test('初期値は 0', () => {
  expect(useCounterStore.getState().count).toBe(0);
});
 ✓ src/noreset.test.ts > inc で count が 1 増える 2ms
 × src/noreset.test.ts > 初期値は 0 5ms
   → expected 1 to be +0 // Object.is equality

1つ目のテストで増やした count が、2つ目のテストに残っています。

beforeEachで初期状態に戻す

useStore.getInitialState() で作成時の状態を取り出し、setState の第2引数にtrueを渡して丸ごと置き換えると、初期状態に戻せます。

import { beforeEach, expect, test } from 'vitest';
import { useCounterStore } from './store';

// 各テストの前に、ストアを初期状態へ戻す
beforeEach(() => {
  useCounterStore.setState(useCounterStore.getInitialState(), true);
});

test('inc で count が 1 増える', () => {
  useCounterStore.getState().inc();
  expect(useCounterStore.getState().count).toBe(1);
});

test('初期値は 0', () => {
  expect(useCounterStore.getState().count).toBe(0);
});

test('setText で text が変わり、count はそのまま', () => {
  useCounterStore.getState().setText('メモ');
  expect(useCounterStore.getState()).toMatchObject({ count: 0, text: 'メモ' });
});

test('subscribe で変更を受け取れる', () => {
  const calls: number[] = [];
  const unsubscribe = useCounterStore.subscribe((state) => calls.push(state.count));
  useCounterStore.getState().inc();
  useCounterStore.getState().inc();
  unsubscribe();
  useCounterStore.getState().inc();
  expect(calls).toEqual([1, 2]);
});
 ✓ src/store.test.ts > inc で count が 1 増える 2ms
 ✓ src/store.test.ts > 初期値は 0 0ms
 ✓ src/store.test.ts > setText で text が変わり、count はそのまま 1ms
 ✓ src/store.test.ts > subscribe で変更を受け取れる 1ms
初期化の方法 書き方 向いている場面
ストアごとに戻す beforeEach で setState(getInitialState(), true) ストアが少ない。今回の方法
モジュールを読み込み直す vi.resetModules() のあと await import('./store') persistの復元など、作成時の動きを確かめたい
すべてのストアをまとめて戻す 公式のTestingガイドの方法(__mocks__/zustand.ts で create を包み、リセット用の関数を集めて afterEach で実行) ストアが多い

setState の第2引数を省くと合成になり、テストで足した項目が残ることがあります。初期化では第2引数にtrueを渡して置き換えるのがポイントです。

ストア設計の指針

指針 理由 例
ストアは関心ごとに分ける 1つのストアが大きくなると、どこで更新しているかを追いにくい useCartStore、useSettingsStore
更新の関数はストアの中に書く 更新の仕方が1か所に集まり、テストもしやすい inc、setTheme
セレクタは値を1つずつ選ぶ 選んだ値が変わったときだけ再レンダリングされる (s) => s.count
複数の値はuseShallowで包む 新しいオブジェクトを返すとループになる useShallow((s) => ({ a: s.a, b: s.b }))
計算で求まる値は保存しない 元の値とずれる心配がなくなる 合計はコンポーネント側で計算する
persistは保存する項目を絞る 一時的な値や大きなデータを保存しない partialize
保存データの形を変えたらversionを上げる 以前のデータを変換して引き継げる version と migrate
サーバーのデータは別の仕組みに任せる 再取得やキャッシュの管理が必要になる データ取得ライブラリと併用

つまずきやすい点と直し方

症状 原因 直し方
Maximum update depth exceeded セレクタが新しいオブジェクトや配列を返している useShallow で包むか、値ごとに分ける
関係ない値の更新で再レンダリングされる セレクタ無しでストア全体を受け取っている 必要な値だけ選ぶ
作成直後にlocalStorageが空 v5では最初の更新で保存される そのままでよい。必要なら作成後に setState を呼ぶ
保存データが復元されない version が保存時と違い、migrate がない migrate で変換を書く
テストの順番で結果が変わる ストアの状態がテスト間で残っている beforeEach で初期状態に戻す

よくある質問

Q. ZustandにProviderは必要ですか? A. 不要です。この記事のコードは、どれもProviderなしで動かしています。ストアのファイルをimportして、フックとして呼ぶだけで使えます。

Q. セレクタを書かずに useStore() で受け取ってもよいですか? A. 動作はしますが、ストアのどの値が変わっても再レンダリングされます。今回の計測では、表示に使っていない値を3回更新しただけで3回増えました。値を選んで受け取る形をおすすめします。

Q. v5にするには何が必要ですか? A. 公式の移行ガイドでは、React 18以上とTypeScript 4.5以上が必要とされています。あわせて、オブジェクトを返すセレクタを useShallow で包むこと、persistが作成時に初期状態を保存しなくなったことを確認しておくと、移行がスムーズです。

Q. persistの保存先をsessionStorageに変えられますか? A. 公式ドキュメントでは、storage オプションの既定が createJSONStorage(() => localStorage) と説明されています。この関数に渡す保存先を差し替える形になります。

まとめ

  • Zustandは create でストアを作り、フックとして呼ぶだけ。Providerは不要
  • セレクタで必要な値だけ選ぶと、その値が変わったときだけ再レンダリングされる(今回の計測で7回と3回の差)
  • オブジェクトや配列を返すセレクタは useShallow で包む。包まないとMaximum update depth exceededになる
  • 同じカウンターの行数はContext 25行・Redux Toolkit 26行・Zustand 14行(空行を除く)
  • persistは最初の更新で保存され、形は {"state": ..., "version": ...}。partialize で項目を絞る
  • 保存データの形を変えたら version を上げて migrate を書く
  • テストでは setState(getInitialState(), true) で初期状態に戻す

参考・出典

  • Zustand公式ドキュメント「persist」:https://zustand.docs.pmnd.rs/reference/middlewares/persist
  • Zustand公式ドキュメント「Migrating to v5」:https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5
  • Zustand公式ドキュメント「Testing」:https://zustand.docs.pmnd.rs/learn/guides/testing

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Zustandの使い方|persistとRedux・Contextとの違い」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?