Zustand 5入門 - 最小Store、Selector、Persistと移行の注意点

中級 | 8分 で読める | 2026.04.23

公式ドキュメント

ZustandはReact componentから利用できる外部storeを、小さなAPIで作るstate management libraryです。Zustand 5ではReact 18とTypeScript 4.5がminimumになり、selectorの安定性、custom equality function、persistの初期保存などに移行上の変更があります。

まずStoreを小さく作る

記事情報: 2026年4月23日初出。Zustand公式documentとv5 migration guideを2026年7月25日に再確認しています。

最小構成ではcreateへstate creatorを渡します。stateと、そのstateを変更するactionを同じ型にまとめられます。

import { create } from "zustand";

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

export const useCounterStore = create<CounterStore>()((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({
      count: state.count + 1,
    })),
  reset: () => set({ count: 0 }),
}));

componentでは必要な値だけをselectorで購読します。

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>count: {count}</button>;
}

useCounterStore()でstore全体を購読すると、関係のないfield変更でもcomponentが再renderされます。最初から巨大なstoreを作らず、更新頻度とlifecycleが近いstateをまとめます。

1つのcomponent内だけで使う入力値はuseState、serverから取得するcacheはdata fetching libraryなど、責務に合う手段を選びます。

Selectorとreferenceの安定性

複数の値をobjectやarrayとして返すselectorは、呼び出すたびに新しいreferenceを作ります。Zustand 5ではReactの既定動作に合わせ、selectorの出力が不安定だと無限loopにつながる場合があります。

// 毎回新しいarrayを返す
const [count, increment] = useCounterStore((state) => [
  state.count,
  state.increment,
]);

複数値をまとめたい場合はuseShallowでshallow comparisonした安定した出力を使えます。

import { useShallow } from "zustand/shallow";

const [count, increment] = useCounterStore(
  useShallow((state) => [state.count, state.increment]),
);

単一値を個別selectorで読むほうが明確なら、無理にobjectへまとめる必要はありません。またfallback functionをselector内で毎回生成しないよう、module scopeに定義します。

const NOOP = () => {};

const action = useMainStore(
  (state) => state.action ?? NOOP,
);

Custom equality function

Zustand 5の通常のcreateはcustom equality functionを受け取りません。v4で第2引数にshallowなどを渡していた場合は、useShallowへ移すか、zustand/traditionalcreateWithEqualityFnを使います。後者はuse-sync-external-storeをpeer dependencyとして必要とするため、既存挙動を維持する必要がなければ通常のcreateとselectorの見直しを優先します。

persistで保存する

persist middlewareはstateをlocalStorageなどへ保存し、起動時に復元します。createJSONStorageはJSONをparseしますが、保存値の形をruntimeでは検証しません。TypeScriptの型注釈やasも、壊れた値、古い値、利用者が書き換えた値を実行時には防げません。

次の例は、保存形式v1のdarkModeをv2のthemeへ移行します。custom storageがversion別に検証・変換するため、Zustandへ返る値は常にv2です。

npm install zod
import { z } from "zod";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { PersistStorage } from "zustand/middleware";

type PreferencesStore = {
  theme: "light" | "dark";
  setTheme: (theme: "light" | "dark") => void;
};

type StoredPreferencesV2 = Pick<PreferencesStore, "theme">;

const storedEnvelopeSchema = z.discriminatedUnion("version", [
  z
    .object({
      version: z.literal(1),
      state: z
        .object({
          darkMode: z.boolean(),
        })
        .strict(),
    })
    .strict(),
  z
    .object({
      version: z.literal(2),
      state: z
        .object({
          theme: z.enum(["light", "dark"]),
        })
        .strict(),
    })
    .strict(),
]);

type StoredEnvelope = z.infer<typeof storedEnvelopeSchema>;
type LatestEnvelope = {
  state: StoredPreferencesV2;
  version: 2;
};

function migrateToLatest(
  envelope: StoredEnvelope,
): LatestEnvelope {
  if (envelope.version === 1) {
    return {
      state: {
        theme: envelope.state.darkMode ? "dark" : "light",
      },
      version: 2,
    };
  }

  return envelope;
}

function discardInvalidValue(name: string): null {
  try {
    localStorage.removeItem(name);
  } catch {
    // storageへaccessできない場合も初期stateへ戻す
  }
  return null;
}

const preferencesStorage: PersistStorage<StoredPreferencesV2> = {
  getItem(name) {
    try {
      const rawValue = localStorage.getItem(name);
      if (rawValue === null) return null;

      const parsed: unknown = JSON.parse(rawValue);
      const result = storedEnvelopeSchema.safeParse(parsed);

      return result.success
        ? migrateToLatest(result.data)
        : discardInvalidValue(name);
    } catch {
      return discardInvalidValue(name);
    }
  },
  setItem(name, value) {
    localStorage.setItem(name, JSON.stringify(value));
  },
  removeItem(name) {
    localStorage.removeItem(name);
  },
};

export const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      theme: "light",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "preferences",
      version: 2,
      storage: preferencesStorage,
      partialize: (state) => ({ theme: state.theme }),
    },
  ),
);

保存対象は必要最小限にします。access token、password、個人情報を、便利さだけを理由にWeb Storageへ保存しないでください。partializeが返す形、最新版の型、schemaを一致させます。

v1を読み込んだとき、getItemはメモリ上でv2へ変換しますが、その場ではlocalStorageへ書き戻しません。読み取り中の予期しないwriteを避け、次にstoreが通常更新されたときにv2形式で保存させます。不正値だけは安全のため削除して初期stateへ戻します。

Hydrationを理解する

persisted stateをstorageから読み、現在のstateへmergeする処理がhydrationです。localStorageのような同期storageではstore作成時に同期的に行われます。AsyncStorageやIndexedDBなど非同期storageではmicrotaskで後から完了するため、最初のrender時には初期値のままの場合があります。

UIが保存値へ依存するなら、usePreferencesStore.persist.hasHydrated()onFinishHydrationで完了状態を監視します。この例ではhydration flagをstoreへ書き込まないため、復元直後のflag更新によるstorageへの書き戻しも発生させません。

SSRではserverにwindowlocalStorageがありません。server HTMLの初期値とbrowser hydration後の値が違うと表示不一致になります。Next.jsなどでは公式のZustand integration guideに沿い、request間でglobal storeを共有しないことも重要です。

保存形式をversion管理する

state構造を変えると、利用者のstorageには古い形式が残ります。上の例ではcustom storageが検証とmigrationの両方を担当します。v3を追加する場合は、version別schema、migrateToLatestversionpartialize、fixture testを同時に更新します。

この例ではversionがない値や未知のversionを不正値として破棄します。さらに古い形式を残す必要がある場合は、その形式を識別できるschemaと明示的な変換を追加してください。

Zustandのmigrate optionへmigrationを任せる方法もあります。その場合は、custom storageが旧versionを最新版schemaで先に拒否しない設計にしてください。1つの例でcustom storage migrationとbuilt-in migrateを混在させず、どちらが旧形式を受け付けて最新版へ変換するかを決めます。

Zustand 5の主な破壊的変更

公式migration guideは、まず最新のv4へ上げてdeprecated warningを解消してからv5へ移ることを勧めています。

主な変更は次のとおりです。

  • default exportを削除
  • deprecated APIを削除
  • React 18以上、TypeScript 4.5以上
  • UMD/SystemJSとES5 supportを削除
  • selector出力に安定したreferenceが必要
  • custom equality functionはtraditional APIへ分離
  • setStatereplace: trueで完全なstateが必要
  • persistがstore作成時に初期stateを自動保存しない

replace: trueはactionを含むstate全体を置換します。空objectや部分stateを渡すとstoreの形が壊れるため、v5の型は完全なstateを要求します。

通常は部分更新で足りるため、replaceを使う箇所だけを検索してください。replace: trueを使う場合はactionを含む完全なstateを渡します。

Server Componentsでの注意

React Server Componentからclient storeをuser間共有のdata置き場として使わないでください。module scopeのsingleton storeをserverで変更すると、requestを越えてstateが混ざる可能性があります。Zustandのhookはclient componentで利用し、requestごとのstoreが必要なら公式Next.js guideのfactory patternでscopeを限定します。

選定判断

Zustandは、componentをまたぐclient stateを小さなstoreとselectorで管理したい場合に向きます。Redux-styleの厳密なevent log、複雑なworkflow、server cacheを必要とする場合は、ほかのlibraryや併用を検討します。

download数やbundle sizeだけで選ばず、stateの所有者、保存期間、SSR、debugging、test方法を決めてください。Zustand 5では特にselectorのreferenceとpersist hydrationを理解することが、安定した導入につながります。

参考リソース

← 一覧に戻る
PR
PR
PR
PR