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
| localStorage | sessionStorage | Cookie(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