【実装】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
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
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
変換ルールが増えてきたり、保存形式自体を変えるときは、
version と migrate オプションを使う方が見通しが良くなります。merge は毎回走るのに対し、migrate は保存されている version が古いときだけ走ります。persist の merge オプションは、localStorageから読み込んだ値とデフォルト値をマージする関数です。ここでレガシー値の変換を行うことで、既存ユーザーのデータを壊さずに移行できます。
layout.tsxの変更
// Before: Provider地獄
<AuthSessionProvider>
<LanguageProvider>
<CharacterProvider>{children}</CharacterProvider>
</LanguageProvider>
</AuthSessionProvider>
// After: Providerが不要に
<AuthSessionProvider>
{children}
</AuthSessionProvider>移行チェックリスト
- zustand storeファイルを作成(persist設定、レガシーマイグレーション含む)
- 各コンポーネントのimportをuseContext → useXxxStore に置換
- layout.tsxからProvider wrapperを削除
- 旧Contextファイルを削除
- ビルド・動作確認(localStorage既存値の互換性確認含む)
まとめ
- zustandはProvider不要で、import して使うだけ → Provider地獄解消
- persist ミドルウェアで localStorage永続化がワンライナー
- merge関数でレガシー値のマイグレーションを宣言的に記述できる
- storeにはUIに必要な最小限のstate(slug等)だけ持ち、派生データはhookで計算する
重要
Next.js で persist を使うと、hydration エラーに必ずぶつかります。サーバー側では localStorage がないので初期値で HTML が作られ、クライアントでは保存値で描画されるためです。対処は 2 つで、マウント後にだけ値を使うか、
skipHydration: true にして useEffect で rehydrate() を呼ぶかです。参考リンク
更新履歴
- コード例に入っていたプロダクト固有の名前を汎用な形に差し替え。Next.js で persist を使ったときの hydration 問題と対処を追加(この記事の構成では必ず踏むため)。merge と migrate の使い分けを補足。コードブロックにファイルパスのキャプションを追加し、callout の色指定を他記事と揃えた。参考リンクの節を新設し、関連記事へのリンクを追加。「この記事でわかること」を概要プロパティへ移動。


