メインコンテンツへスキップ
kt-tech.blog

【実装】React Context → zustand移行ガイド(persist + レガシー値マイグレーション)

実装5分で読めます

この記事でわかること

  • React Context を zustand に置き換えて Provider の入れ子を消す手順
  • persist ミドルウェアで localStorage に残す設定
  • localStorage に古い形式の値が残っているユーザーを壊さない merge 関数の書き方
  • Next.js で persist を使うと必ず起きる hydration 問題とその対処

React Context で状態管理をしたことがあること。zustand は初めてでも読めます。例は Next.js App Router です。

メモ
React ContextをzustandのpersistミドルウェアでlocalStorageに永続化する形に移行した際の手順と、レガシー値のマイグレーションパターンを紹介します。

はじめに

React Contextで管理していたキャラクター選択と言語設定をzustandに移行しました。Contextの問題点(Provider地獄、不要な再レンダリング)を解消しつつ、localStorageへの永続化も追加しています。

Before: React Context

TypeScript
const CharacterContext = createContext<CharacterContextType>(...);

export function CharacterProvider({ children }) {
  const [selected, setSelected] = useState(DEFAULT_SLUG);
  // localStorage読み込み、レガシー値変換...
  return (
    <CharacterContext.Provider value={{...}}>
      {children}
    </CharacterContext.Provider>
  );
}

// layout.tsx — Provider地獄
<AuthSessionProvider>
  <LanguageProvider>
    <CharacterProvider>
      {children}
    </CharacterProvider>
  </LanguageProvider>
</AuthSessionProvider>

After: zustand + persist

TypeScript
import { create } from "zustand";
import { persist } from "zustand/middleware";

interface CharacterStore {
  selectedSlug: string;
  setSelectedSlug: (slug: string) => void;
}

// 旧形式(色名)→ 新形式(slug)の対応表
const DEFAULT_SLUG = "char-a";
const LEGACY_SLUG_MAP: Record<string, string> = {
  blue: "char-b",
  red: "char-a",
};

export const useCharacterStore = create<CharacterStore>()(
  persist(
    (set) => ({
      selectedSlug: DEFAULT_SLUG,
      setSelectedSlug: (slug) => set({ selectedSlug: slug }),
    }),
    {
      name: "selectedCharacter", // localStorageキー
      merge: (persisted, current) => {
        const p = persisted as Partial<CharacterStore> | undefined;
        const raw = p?.selectedSlug ?? current.selectedSlug;
        return { ...current, selectedSlug: LEGACY_SLUG_MAP[raw] ?? raw };
      },
    },
  ),
);

レガシー値マイグレーションのポイント

注意
既存ユーザーの localStorage に古い形式の値("blue", "red")が残っている場合、merge 関数でマイグレーションします。Map で変換すれば let も不要で簡潔に書けます。
Tips
変換ルールが増えてきたり、保存形式自体を変えるときは、versionmigrate オプションを使う方が見通しが良くなります。merge毎回走るのに対し、migrate保存されている version が古いときだけ走ります。

persist の merge オプションは、localStorageから読み込んだ値とデフォルト値をマージする関数です。ここでレガシー値の変換を行うことで、既存ユーザーのデータを壊さずに移行できます。

layout.tsxの変更

TypeScript
// Before: Provider地獄
<AuthSessionProvider>
  <LanguageProvider>
    <CharacterProvider>{children}</CharacterProvider>
  </LanguageProvider>
</AuthSessionProvider>

// After: Providerが不要に
<AuthSessionProvider>
  {children}
</AuthSessionProvider>

移行チェックリスト

  1. zustand storeファイルを作成(persist設定、レガシーマイグレーション含む)
  2. 各コンポーネントのimportをuseContext → useXxxStore に置換
  3. layout.tsxからProvider wrapperを削除
  4. 旧Contextファイルを削除
  5. ビルド・動作確認(localStorage既存値の互換性確認含む)

まとめ

  • zustandはProvider不要で、import して使うだけ → Provider地獄解消
  • persist ミドルウェアで localStorage永続化がワンライナー
  • merge関数でレガシー値のマイグレーションを宣言的に記述できる
  • storeにはUIに必要な最小限のstate(slug等)だけ持ち、派生データはhookで計算する
重要
Next.js で persist を使うと、hydration エラーに必ずぶつかります。サーバー側では localStorage がないので初期値で HTML が作られ、クライアントでは保存値で描画されるためです。対処は 2 つで、マウント後にだけ値を使うか、skipHydration: true にして useEffectrehydrate() を呼ぶかです。

参考リンク

更新履歴

  1. コード例に入っていたプロダクト固有の名前を汎用な形に差し替え。Next.js で persist を使ったときの hydration 問題と対処を追加(この記事の構成では必ず踏むため)。merge と migrate の使い分けを補足。コードブロックにファイルパスのキャプションを追加し、callout の色指定を他記事と揃えた。参考リンクの節を新設し、関連記事へのリンクを追加。「この記事でわかること」を概要プロパティへ移動。

この記事のタグ