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/traditionalのcreateWithEqualityFnを使います。後者は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にwindowやlocalStorageがありません。server HTMLの初期値とbrowser hydration後の値が違うと表示不一致になります。Next.jsなどでは公式のZustand integration guideに沿い、request間でglobal storeを共有しないことも重要です。
保存形式をversion管理する
state構造を変えると、利用者のstorageには古い形式が残ります。上の例ではcustom storageが検証とmigrationの両方を担当します。v3を追加する場合は、version別schema、migrateToLatest、version、partialize、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は
traditionalAPIへ分離 setStateのreplace: 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を理解することが、安定した導入につながります。
参考リソース
- Zustand公式ドキュメント
- Zustand 5 migration guide
- useShallow
- Persisting store data
- Next.js integration guide