TOOLS BOX/ガイド/localStorage / persist パターン
Concept

localStorage / persist パターン

ブラウザのローカルストレージを使った状態永続化のパターンを整理するガイド。localStorage に向くデータ / 向かないデータの判断基準、Zustand persist middleware の位置づけ、SSR 環境での hydration ずれの原因と対処、保存しすぎない考え方を説明する。

localStoragepersistzustandhydrationSSRstoragebrowserstate-management

どういう場面で使うか

  • ·ページリロード後もユーザーの設定・好みを維持したいとき(テーマ・言語・サイドバー開閉状態)
  • ·ショッピングカートの中身をログイン不要で保持したいとき
  • ·ユーザーが入力途中のデータを一時保存して離脱後に復元したいとき(下書き機能)

注意点 / Pitfalls

  • ·認証トークン(JWT など)を localStorage に保存すると XSS で窃取されるリスクがある。トークンは httpOnly Cookie で管理する
  • ·persist middleware で store 全体を保存すると、スキーマ変更時に古い形式のデータが復元されて壊れることがある。保存対象を partial で絞る
  • ·SSR 環境(Next.js)では localStorage はサーバー側に存在しない。サーバーとクライアントの初期値が異なると hydration エラーになる
  • ·個人情報・カード番号など機密性の高いデータは localStorage に保存しない
  • ·localStorage の容量は約 5MB。大量のデータを保存しようとすると QuotaExceededError が発生する

何と混同しやすいか

補足

localStorage はブラウザ固有のストレージであり、JavaScript から自由に読めるため XSS に弱い。セキュリティが要件にある情報は Cookie(httpOnly)かサーバーで管理する。persist middleware はその手軽さから使いすぎになりやすく、保存対象を明示的に選ぶことが重要。

localStorage に向くデータ / 向かないデータ

データの種類localStorage理由
テーマ(light / dark)✅ 向く機密性なし・リロード後も維持したい
サイドバーの開閉状態✅ 向くUI 設定でユーザーごとに異なる
言語設定(ja / en)✅ 向くサーバー保存不要な好み設定
カート内容(未ログイン)✅ 向くセッション跨ぎで一時保持
フォームの下書き✅ 向く離脱後に復元したい一時データ
認証トークン(JWT など)❌ 向かないXSS で窃取されるリスク
パスワード・カード番号❌ 向かない機密情報は保存しない
API レスポンス(キャッシュ)❌ 向かないTanStack Query / SWR に任せる
大量データ(数 MB 超)❌ 向かない容量制限(約 5MB)を超える

認証トークンは localStorage に置かない

localStorage のセキュリティ特性:
  ・JavaScript から document.cookie と異なり直接読める
  ・XSS(クロスサイトスクリプティング)の被害を受けると
    攻撃者のスクリプトが localStorage を自由に読める
  ・一度窃取されたトークンはサーバー側で即時無効化が難しい

✅ 認証トークンは httpOnly Cookie で管理する
  ・httpOnly Cookie は JavaScript から読めないため XSS で盗まれない
  ・サーバーが発行・無効化を制御できる
// ❌ localStorage にトークンを保存するパターン
localStorage.setItem("token", jwt);
// → XSS があれば攻撃者が読める

// ✅ サーバーが httpOnly Cookie を発行する
response.cookies.set("token", jwt, {
  httpOnly: true,   // JS から読めない
  sameSite: "lax",
  secure: true,
});

persist middleware の位置づけ

Zustand の persist middleware は、store の状態を localStorage(または sessionStorage)に自動保存・復元します。

// store/preference.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

type PreferenceStore = {
  theme: "light" | "dark";
  sidebarCollapsed: boolean;
  setTheme: (theme: "light" | "dark") => void;
  toggleSidebar: () => void;
};

export const usePreferenceStore = create<PreferenceStore>()(
  persist(
    (set) => ({
      theme: "light",
      sidebarCollapsed: false,
      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
    }),
    {
      name: "user-preference", // localStorage のキー名
      // ✅ 保存対象を partial で絞る(action 関数は保存不要)
      partialize: (state) => ({
        theme: state.theme,
        sidebarCollapsed: state.sidebarCollapsed,
      }),
    }
  )
);

partialize で保存対象を絞る理由:

  • action 関数(setTheme など)は保存しても意味がない
  • store 全体を保存するとスキーマ変更時に古いデータが復元されて壊れる
  • 保存するフィールドを明示することで意図を伝えられる

初期化タイミングと hydration ずれ

Next.js などの SSR 環境では、サーバー側に localStorage は存在しません。これが hydration ずれの原因になります。

問題の流れ:
  1. サーバー: localStorage なし → store の初期値(theme: "light")でレンダリング
  2. ブラウザ: localStorage に theme: "dark" が保存されていた
  3. persist が復元して theme: "dark" に更新
  4. サーバーとクライアントで描画が異なる → ハイドレーションエラー

対処法 1: useEffect 内で読む

// localStorage の値は useEffect の中でのみ使う
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<"light" | "dark">("light"); // 初期値はデフォルト

  useEffect(() => {
    // ブラウザのみで実行される
    const saved = localStorage.getItem("theme") as "light" | "dark" | null;
    if (saved) setTheme(saved);
  }, []);

  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}

対処法 2: Zustand の skipHydration

export const usePreferenceStore = create<PreferenceStore>()(
  persist(
    (set) => ({
      theme: "light",
      // ...
    }),
    {
      name: "user-preference",
      skipHydration: true, // 自動復元を無効にする
    }
  )
);

// クライアント側で明示的に復元する
function App() {
  useEffect(() => {
    usePreferenceStore.persist.rehydrate();
  }, []);
}

対処法 3: ハイドレーション完了後のみ描画する

function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null; // サーバー描画時は何も返さない
  return <>{children}</>;
}

// localStorage に依存する UI を包む
<ClientOnly>
  <ThemeSwitcher />
</ClientOnly>

保存しすぎない考え方

保存する前に問う:

□ リロード後も維持したい状態か?
    No → メモリ上の store(Zustand)や useState で十分

□ ユーザーごとに異なる好み・設定か?
    No → サーバーでデータとして管理する

□ 機密性がないか?
    機密あり → localStorage に保存しない

□ サーバーとの同期が必要か?
    Yes → サーバー保存(DB)が正として、localStorage はキャッシュ扱いにする

□ スキーマ変更時に古いデータが残っても問題ないか?
    問題あり → バージョニングか保存対象の見直しが必要

localStorage vs sessionStorage vs Cookie

localStoragesessionStorageCookie(httpOnly)
スコープ同一オリジン・永続タブ単位・タブを閉じると消えるドメイン単位・期限設定可
JS からの読み取り✅ 可✅ 可❌ httpOnly なら不可
XSS リスク高い高い低い(httpOnly 時)
向く用途UI 設定・ユーザー好みセッション中の一時データ認証トークン
容量約 5MB約 5MB約 4KB

よくある誤用

persist で store 全体を保存する

// ❌ partialize なしで全保存
persist(
  (set) => ({ theme: "light", products: [], user: null, ...actions }),
  { name: "app-store" }
)
// → products や user など不要なデータまで localStorage に保存される
// → スキーマ変更時に古い形式が復元されて壊れる

// ✅ 保存したいフィールドだけを partialize で指定する
persist(
  (set) => ({ ... }),
  {
    name: "app-store",
    partialize: (state) => ({ theme: state.theme }),
  }
)

localStorage にサーバーデータをキャッシュする

// ❌ API レスポンスを localStorage で自前キャッシュ
const cached = localStorage.getItem("products");
if (cached) setProducts(JSON.parse(cached));
else {
  const data = await api.getProducts();
  localStorage.setItem("products", JSON.stringify(data));
  setProducts(data);
}
// → stale 判定・無効化・エラー処理を自前実装することになる

// ✅ TanStack Query に任せる(staleTime でキャッシュ期間を制御できる)
const { data } = useQuery({ queryKey: ["products"], queryFn: api.getProducts, staleTime: 60_000 });

サーバー側で localStorage を参照する

// ❌ Server Component や Server Action で localStorage を参照する
// app/page.tsx(Server Component)
export default function Page() {
  const theme = localStorage.getItem("theme"); // ReferenceError: localStorage is not defined
}

// ✅ localStorage はクライアント側(useEffect 内)でのみ使う
"use client";
function ThemeButton() {
  const [theme, setTheme] = useState("light");
  useEffect(() => {
    setTheme(localStorage.getItem("theme") ?? "light");
  }, []);
}

ハイドレーションの仕組み → ハイドレーション

store 設計の全体像 → Zustand / store 設計パターン

global store と private state の使い分け → グローバル store と private state の使い分け

persist の実装例 → zustand-persist

関連ドキュメント

関連サンプル

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