スキーマ変更で何が起きるか
persist middleware は store の状態を JSON として localStorage に保存します。アプリを更新してスキーマ(型・フィールド名)が変わっても、ブラウザの localStorage には旧バージョンのデータが残り続けます。
[初回リリース]
localStorage: { "theme": "dark", "lang": "ja" } ← v1 のデータ
[スキーマ変更(lang → language)]
store の型: { theme: string; language: string }
localStorage には "lang" のままのデータが残っている
[ユーザーがアクセス]
→ persist が旧データを復元しようとする
→ language が undefined になる(lang は新スキーマに存在しない)
→ 表示崩れ・undefined 参照エラーが発生しうる
この問題に対応するのが version と migrate です。
3 つの道具の役割
| オプション | 役割 |
|---|---|
partialize | 保存するフィールドを絞る。スキーマ変更の影響範囲を最小化する前提条件 |
version | 保存データのバージョン番号。現在の version と不一致なら旧データを処理対象にする |
migrate | 旧バージョンのデータを現在の形式に変換する関数 |
partialize — 保存対象を絞る
// ❌ partialize なしで全保存
create<Store>()(
persist(
(set) => ({
theme: "light",
sidebarCollapsed: false,
setTheme: (t) => set({ theme: t }), // action 関数まで保存対象になる
}),
{ name: "app" }
)
)
// ✅ 保存が必要なフィールドだけ指定する
create<Store>()(
persist(
(set) => ({
theme: "light",
sidebarCollapsed: false,
setTheme: (t) => set({ theme: t }),
}),
{
name: "app",
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
// setTheme は保存しない
}),
}
)
)
保存対象を絞ることで:
- バージョン管理が必要なフィールドの数が減る
- スキーマ変更の影響範囲が明確になる
- action 関数が JSON 化されて壊れるリスクを避けられる
version だけ上げる — 旧データをリセット
migrate を用意しない場合、version が不一致の旧データは破棄されストアはデフォルト値で初期化されます。
create<PreferenceStore>()(
persist(
(set) => ({
theme: "light",
language: "ja", // v1 では "lang" だったフィールドを "language" に変更
}),
{
name: "preference",
version: 2, // v1 → v2 に上げる(migrate なし)
// → v1 の旧データは破棄され、デフォルト値(theme: "light", language: "ja")で初期化
}
)
)
旧データをリセットしてよい場面:
- テーマ・言語設定など、消えても再設定すれば済む UI 設定
- データの移行ロジックよりシンプルさを優先したいとき
- 旧データの構造が壊れていて移行コストが高いとき
version + migrate — 旧データを新形式に変換
migrate 関数を用意すると、旧バージョンのデータを変換して新スキーマに移行できます。
type StoreV1 = {
theme: "light" | "dark";
lang: "ja" | "en"; // v1 のフィールド名
};
type StoreV2 = {
theme: "light" | "dark";
language: "ja" | "en"; // v2: lang → language に変更
};
create<StoreV2>()(
persist(
(set) => ({
theme: "light",
language: "ja",
setTheme: (t) => set({ theme: t }),
setLanguage: (l) => set({ language: l }),
}),
{
name: "preference",
version: 2,
migrate: (persistedState, fromVersion) => {
// fromVersion: localStorage に保存されていた version 番号
if (fromVersion === 1) {
const v1 = persistedState as StoreV1;
return {
theme: v1.theme,
language: v1.lang, // lang → language に変換
} satisfies Partial<StoreV2>;
}
return persistedState as Partial<StoreV2>;
},
}
)
)
migrate が必要な場面:
- カートの中身・下書きデータなど、消えると困るデータ
- フィールド名を変えたが、旧データのマッピングが可能なとき
- フィールドの型を変えたが、変換ロジックが書けるとき
複数バージョンを跨ぐ migrate
v1 → v3 のように複数バージョン跨ぐ場合は、段階的に変換します。
migrate: (persistedState, fromVersion) => {
let state = persistedState as Record<string, unknown>;
// v1 → v2 の変換
if (fromVersion < 2) {
state = {
...state,
language: (state as StoreV1).lang,
};
delete state.lang;
}
// v2 → v3 の変換
if (fromVersion < 3) {
state = {
...state,
colorScheme: state.theme === "dark" ? "dark" : "system", // theme → colorScheme に変更
};
delete state.theme;
}
return state as Partial<StoreV3>;
},
各ステップを独立させると、どのバージョンから来ても正しく移行できます。
旧データを捨てる判断基準
旧データをリセット(version だけ上げる)してよい条件:
□ 消えてもユーザーが再設定できる(テーマ・言語・UI 設定)
□ 旧データの構造が現在と大きく異なり、移行コストが高い
□ 旧データが存在するユーザーが少ない(ベータ・初期リリース直後)
□ 旧データを保持するよりリセットの方がバグリスクが低い
migrate を実装すべき条件:
□ 消えると困るユーザーデータ(カート・下書き・進捗)
□ フィールド名や型の変更で旧データのマッピングが書ける
□ データの継続性がユーザー体験に直結する
よくある誤用
version を上げずにスキーマを変更する
// v1 の保存データ: { "theme": "dark", "lang": "ja" }
// ❌ version を変えずに lang → language に変更
create<StoreV2>()(
persist(
(set) => ({ theme: "light", language: "ja" }),
{ name: "preference" } // version なし = 0 のまま
)
)
// → 旧データが復元されるが language フィールドがない
// → language が undefined になり表示崩れが起きる
// ✅ スキーマを変えたら必ず version を上げる
persist(..., { name: "preference", version: 2, migrate: ... })
migrate の戻り値の型が合っていない
// ❌ migrate が古いフィールドを含んだまま返す
migrate: (state, fromVersion) => {
if (fromVersion === 1) {
return { ...state as StoreV1, language: (state as StoreV1).lang };
// lang フィールドが残ったまま返している
}
},
// ✅ 不要なフィールドを除いて返す
migrate: (state, fromVersion) => {
if (fromVersion === 1) {
const { lang, ...rest } = state as StoreV1;
return { ...rest, language: lang } satisfies Partial<StoreV2>;
}
},
partialize なしで全体保存のまま version を上げ続ける
保存対象が広いほど、スキーマ変更が起きやすく migrate が複雑になります。最初から partialize で保存対象を絞ることで、バージョニングのコストを最小にできます。
// ✅ 保存が必要なフィールドだけに絞る → バージョニングが単純になる
partialize: (state) => ({
theme: state.theme,
language: state.language,
// cartItems など別ストアで管理するものはここに入れない
}),
localStorage / persist の基本 → localStorage / persist パターン
store 設計の全体像 → Zustand / store 設計パターン
persist の実装例 → zustand-persist