概要
Zustand Test の 4 store のうち、ユーザーの確定値が入る settingsStore だけ を localStorage に永続化したい、というのは典型的な要件です。
profileStore は同期で再生成できるので不要、detailStore は派生値なので不要、uiStore のタブ状態は永続化したくない、という割り切りです。
そこで使うのが persist middleware の partialize オプションです。store の中から「保存する範囲」を関数で絞れます。
インストール
npm install zustand
何を保存して、何を保存しないか
最初に方針を決めておきます。Playground での割り当ては次のとおり。
| store | 永続化 | 理由 |
|---|---|---|
settingsStore | ✅ | ユーザーの確定値。再ロード時に復元したい |
profileStore | ❌ | settings から storeSync で再生成できる |
detailStore | ❌ | suggestedRate は派生値。再計算で済む |
uiStore | ❌ | タブの初期表示は固定値で良い |
settingsStore の中でも action や internal な flag は保存しない。値だけを保存するのが原則です。
persist + partialize の最小実装
persist を被せ、partialize で 保存対象のキーを返す 関数を渡します。name オプションで localStorage のキーを決めます。
// src/store/settingsStore.ts
import { create } from "zustand";
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
import type { FormData } from "@/schemas/formSchema";
const INITIAL_SETTINGS: FormData = {
name: "",
email: "",
rate: 0,
};
type SettingsState = FormData & {
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
reset: () => void;
};
export const useSettingsStore = create<SettingsState>()(
devtools(
persist(
subscribeWithSelector((set) => ({
...INITIAL_SETTINGS,
patchFormData: (partial) => set(partial, false, "patchFormData"),
setFormData: (values) => set(values, false, "setFormData"),
reset: () => set(INITIAL_SETTINGS, true, "reset"),
})),
{
name: "playground.settings",
version: 1,
partialize: (state) => ({
name: state.name,
email: state.email,
rate: state.rate,
}),
}
),
{ name: "SettingsStore" }
)
);
ポイントは 3 つ。
- middleware の 重ね順は
devtools(persist(subscribeWithSelector(...)))。一番外側に DevTools、次に永続化、内側に subscribe partializeは 保存したいキーだけ を返す。patchFormDataなどの関数は除外するnameは localStorage のキー。プロジェクト内で衝突しないプレフィックス を付ける
「途中入力」を保存しない選択肢
Playground では patchFormData でも store が即時更新されるため、1 文字ごとに localStorage への書き込み が走ります。これを避けたい場合は、partialize で「最後に確定された値だけ」を別フィールドに持つ設計にします。
type SettingsState = FormData & {
saved: FormData; // ← 確定済みの値だけここに入れる
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
reset: () => void;
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
...INITIAL_SETTINGS,
saved: INITIAL_SETTINGS,
patchFormData: (partial) => set(partial),
setFormData: (values) => set({ ...values, saved: values }),
reset: () => set({ ...INITIAL_SETTINGS, saved: INITIAL_SETTINGS }),
}),
{
name: "playground.settings",
version: 1,
partialize: (s) => ({ saved: s.saved }), // ← saved だけ保存
}
)
);
復元時に saved から現在値を組み立てる手間が増えますが、localStorage の書き込み頻度が「保存ボタン押下時のみ」 に下がります。
migration: スキーマが変わったら version を上げる
スキーマに rate を追加するなど 保存形式が変わった ときは、version を上げて migrate 関数を書きます。古い形式の値が残っていても安全に読み戻せます。
persist(
(set) => ({ /* ... */ }),
{
name: "playground.settings",
version: 2, // ← 旧 1 → 2 に上げた
partialize: (s) => ({
name: s.name,
email: s.email,
rate: s.rate,
}),
migrate: (persisted, fromVersion) => {
const obj = persisted as Record<string, unknown>;
if (fromVersion < 2) {
return {
name: String(obj.name ?? ""),
email: String(obj.email ?? ""),
rate: 0, // 旧値に rate がなければ 0
};
}
return obj as never;
},
}
)
migrate は persist が読み戻した直後・hydration の前 に呼ばれます。返したオブジェクトが partialize の形と一致している必要があります。
hydration mismatch を避ける
Next.js の Server Component と組み合わせると、サーバーは初期値、クライアントは localStorage の復元値、という 乖離 で hydration mismatch が発生しがちです。回避策の典型は次の 2 つ。
- クライアントでだけ store を参照する(
"use client"の境界を明示) - 復元完了まで描画を抑える(
onRehydrateStorageかuseStateのフラグで制御)
"use client";
import { useEffect, useState } from "react";
import { useSettingsStore } from "@/store/settingsStore";
export function HydratedNameDisplay() {
const [hydrated, setHydrated] = useState(false);
const name = useSettingsStore((s) => s.name);
useEffect(() => setHydrated(true), []);
if (!hydrated) return null; // SSR と揃えるため最初は描画しない
return <p>{name}</p>;
}
詳しくは RSC 境界編 を参照してください。
「全ストアをリセット」と persist の関係
reset は state を初期値に戻しますが、localStorage の値は自動で消えません。reset 後に同じ state が再保存されるため、結果的に「初期値が保存される」状態になります。
確実にクリアしたい場合は persist の API を使います。
useSettingsStore.persist.clearStorage();
「全ストアをリセット」ボタンの中で reset() の後に clearStorage() を呼ぶと、メモリと localStorage の両方を初期化できます。
ポイント
- 永続化したいのは 値だけ。
partializeで対象キーを明示し、関数は除外する - middleware は
devtools(persist(subscribeWithSelector(...)))の順に重ねる - 1 文字ごとの書き込みを避けたいなら、確定値だけ持つフィールド を切り出して
partializeで絞る - スキーマ変更時は
versionを上げてmigrateを書く。旧データが残っていても安全に読み戻せる - SSR と組み合わせるときは hydration mismatch に注意。クライアント境界 で参照するか復元完了まで描画を抑える
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 4 store の役割分担
- RSC 境界編 —
"use client"と hydration の境界 - devtools 命名規則編 — middleware を重ねるときの順序
- Zustand Test (Build) — 完成例と外部デモ