TOOLS BOX/ガイド/Zustand persist のバージョニング
Concept

Zustand persist のバージョニング

persist middleware で永続化した store のスキーマを安全に変更するためのパターン。version / migrate / partialize の役割と使い分け、古い保存データの扱い方、壊れない永続化設計の考え方を整理する。

zustandpersistversioningmigratelocalStorageschemastate-management

どういう場面で使うか

  • ·persist した store のフィールド名・型・構造を変更するとき
  • ·新バージョンのアプリで旧 localStorage データが残っている状態を考慮するとき
  • ·旧データを捨ててよいか、移行して使い続けるかを判断したいとき

注意点 / Pitfalls

  • ·version を上げて migrate を用意しないと、旧バージョンの保存データはすべて破棄されデフォルト値にリセットされる
  • ·partialize なしで store 全体を保存すると、action 関数まで JSON 化しようとして想定外の動作になることがある
  • ·migrate 関数の戻り値の型が現在の store の型と一致していないと TypeScript エラーになる
  • ·version を上げずにフィールドを変更すると、古い型のデータが復元されて実行時エラーや表示崩れが起きる
  • ·migrate は fromVersion から現在の version まで段階的に変換する。複数バージョン跨ぎは each step で処理する

補足

persist を使うなら常に migrate が必要なわけではない。テーマや UI 設定など「消えても再設定すれば済む」データは version を上げて旧データをリセットする方がシンプル。カートや下書きなど「消えると困る」データには migrate で移行を実装する。

スキーマ変更で何が起きるか

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 参照エラーが発生しうる

この問題に対応するのが versionmigrate です。

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

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例