はじめに
localStorage は手軽に使える反面、素朴に React と組み合わせるとさまざまな罠を踏みます。SSR 環境でのクラッシュ、不正な JSON によるパースエラー、複数タブ間の状態ズレ、容量超過例外、スキーマ変更時の破綻 など、本番運用で問題になりがちな観点をひとつずつ潰していかないと安心して使えません。
本記事では useLocalStorage カスタムフックを段階的に堅牢化していく Tips をまとめます。
対象読者
- React フックの基礎(
useState/useEffect)を理解している方 - Next.js などの SSR 環境で
localStorageを扱う必要がある方 - 「とりあえず動く」状態から一歩進めて、本番運用に耐える実装にしたい方
動作環境
| 項目 | バージョン |
|---|---|
| React | 18.x / 19.x |
| TypeScript | 5.x |
| Next.js | 14.x / 15.x(SSR検証用) |
TL;DR
-
SSR 対策:
typeof windowをチェックし、サーバ側では初期値を返す -
型安全: ジェネリクスで
<T>を受け取り、useStateのシグネチャに揃える -
遅延初期化:
useState(() => ...)で初回レンダー時のみ読み出す -
JSON パース:
try/catchで囲み、壊れた値はデフォルト値にフォールバック -
タブ間同期:
storageイベントを購読し、他タブの変更を反映する -
容量超過:
QuotaExceededErrorを握りつぶさず、呼び出し側に伝える - スキーマ移行: バージョン番号付きで保存し、古い値は破棄または変換する
Tip 1: SSR 環境では localStorage に触らない
何が問題か
// Before(Next.js でビルドエラー or ハイドレーション不一致)
export const useLocalStorage = <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(
JSON.parse(localStorage.getItem(key) ?? "null") ?? initial,
);
// ...
};
サーバサイドには window も localStorage も存在しないため、ReferenceError: localStorage is not defined で落ちます。たとえ動いたとしても、サーバとクライアントで初期値が食い違い「ハイドレーションエラー」を引き起こします。
改善案
const isBrowser = typeof window !== "undefined";
const readValue = <T,>(key: string, initial: T): T => {
if (!isBrowser) return initial;
try {
const raw = window.localStorage.getItem(key);
return raw === null ? initial : (JSON.parse(raw) as T);
} catch {
return initial;
}
};
SSR で読み込んだ初期値と、クライアントで localStorage から読み出した値が異なると、React 18 以降は警告を出します。初回マウント後に useEffect で同期する設計にすると、ハイドレーション不一致を避けられます(後述の Tip 4 参照)。
Tip 2: ジェネリクスで型安全にする
よくある書き方
- export const useLocalStorage = (key: string, initial: unknown) => {
- // 戻り値が unknown になり、呼び出し側で毎回キャストが必要
- };
+ export const useLocalStorage = <T,>(
+ key: string,
+ initial: T,
+ ): [T, (value: T | ((prev: T) => T)) => void] => {
+ // useState と同じシグネチャに揃えるのが鉄則
+ };
なぜこう書くべきか
useState と同じ [value, setValue] のタプル形を返すと、既存コードの useState を機械的に置き換えられます。setValue が「値」も「更新関数」も両方受けられる点も忘れずに揃えます。
Tip 3: useState の遅延初期化で読み出しを1回に絞る
問題
localStorage.getItem と JSON.parse は安いとはいえ毎レンダー走らせる必要はありません。さらに SSR と CSR で初期値を分岐させたい場合、初期化ロジックは マウント時に1回だけ 走らせるのが理想です。
改善案
const [value, setValue] = useState<T>(() => readValue(key, initial));
useState に関数を渡すと初回レンダー時のみ実行されるため、無駄な読み出しと JSON パースを避けられます。
SSR でハイドレーション不一致を完全に避けたい場合は、初期値は initial を返し、マウント後の useEffect で localStorage の値に置き換える戦略も有効です。本記事の最終形ではこちらを採用します。
Tip 4: ハイドレーション不一致を避ける useEffect 同期
const [value, setValue] = useState<T>(initial);
useEffect(() => {
setValue(readValue(key, initial));
// key が変わったら再読み込み
}, [key]);
サーバとクライアントで同じ initial を返すことで、初回 HTML が一致します。localStorage の値はマウント後に反映されるため、一瞬だけデフォルト値が見えますが、SSR でレンダリングする UI ではこちらの挙動の方が安全です。
Tip 5: タブ間で値を同期する(storage イベント)
ユーザーが同じサイトを2タブで開いたとき、片方のタブの変更がもう片方にも反映されてほしいケースが多いです。storage イベントは 他タブで localStorage が更新されたときに発火します(自タブでは発火しない点に注意)。
useEffect(() => {
if (!isBrowser) return;
const handler = (e: StorageEvent) => {
if (e.key !== key || e.storageArea !== window.localStorage) return;
try {
setValue(e.newValue === null ? initial : (JSON.parse(e.newValue) as T));
} catch {
setValue(initial);
}
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, [key]);
storage イベントは 他タブの変更時のみ 発火します。同一タブ内の別コンポーネントで値を共有したい場合は、CustomEvent を併用するか、状態管理ライブラリ(Zustand など)を併用する設計が必要です。
Tip 6: 書き込みエラーを握りつぶさない
localStorage は容量上限(多くのブラウザで 5MB 程度)があり、超過すると QuotaExceededError を投げます。プライベートブラウジングや iOS Safari では書き込み自体が拒否されることもあります。
アンチパターン
- const setStoredValue = (next: T) => {
- try {
- localStorage.setItem(key, JSON.stringify(next));
- } catch {
- // 何も起きないように見えるが、実際は保存されていない
- }
- setValue(next);
- };
推奨パターン
const setStoredValue: Dispatch<SetStateAction<T>> = (action) => {
setValue((prev) => {
const next =
typeof action === "function" ? (action as (p: T) => T)(prev) : action;
if (isBrowser) {
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch (err) {
console.error(`[useLocalStorage] failed to persist "${key}":`, err);
// 必要に応じて呼び出し側に通知(onError コールバック等)
}
}
return next;
});
};
エラーをログに残し、必要なら onError のようなコールバックで呼び出し側に通知します。「黙って消えるバグ」が一番厄介なので、最低でもログは出します。
Tip 7: スキーマバージョニングで破壊的変更に耐える
リリースを重ねると保存する値の形が変わることがあります。古い値を新しいコードで JSON.parse した結果、画面が壊れる事故を防ぐにはバージョン情報を持たせます。
type Versioned<T> = { version: number; data: T };
const readVersioned = <T,>(key: string, version: number, initial: T): T => {
if (!isBrowser) return initial;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return initial;
const parsed = JSON.parse(raw) as Versioned<T>;
if (parsed.version !== version) return initial; // または migrate(parsed)
return parsed.data;
} catch {
return initial;
}
};
保存時に { version: 2, data: ... } の形でラップしておけば、version が一致しないときに 安全側に倒して破棄 できます。本格的に運用するなら migrate(oldData) => newData を挟む設計が望ましいです。
完成版
ここまでの Tips を統合した実装です。
import {
useCallback,
useEffect,
useState,
type Dispatch,
type SetStateAction,
} from "react";
const isBrowser = typeof window !== "undefined";
type Options = {
/** スキーマバージョン。変更すると古い値は破棄される */
version?: number;
/** 書き込み失敗時のコールバック */
onError?: (error: unknown) => void;
};
type Versioned<T> = { v: number; d: T };
const read = <T,>(key: string, initial: T, version: number): T => {
if (!isBrowser) return initial;
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return initial;
const parsed = JSON.parse(raw) as Versioned<T>;
return parsed?.v === version ? parsed.d : initial;
} catch {
return initial;
}
};
export const useLocalStorage = <T,>(
key: string,
initial: T,
options: Options = {},
): [T, Dispatch<SetStateAction<T>>] => {
const { version = 1, onError } = options;
const [value, setValue] = useState<T>(initial);
// マウント後に localStorage と同期(SSRハイドレーション対策)
useEffect(() => {
setValue(read(key, initial, version));
}, [key, version]);
// 他タブからの変更を購読
useEffect(() => {
if (!isBrowser) return;
const handler = (e: StorageEvent) => {
if (e.key !== key || e.storageArea !== window.localStorage) return;
setValue(read(key, initial, version));
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, [key, version, initial]);
const update: Dispatch<SetStateAction<T>> = useCallback(
(action) => {
setValue((prev) => {
const next =
typeof action === "function"
? (action as (p: T) => T)(prev)
: action;
if (isBrowser) {
try {
const payload: Versioned<T> = { v: version, d: next };
window.localStorage.setItem(key, JSON.stringify(payload));
} catch (err) {
onError?.(err);
console.error(`[useLocalStorage] persist failed: ${key}`, err);
}
}
return next;
});
},
[key, version, onError],
);
return [value, update];
};
使い方
type Theme = "light" | "dark";
export const ThemeToggle = () => {
const [theme, setTheme] = useLocalStorage<Theme>("theme", "light", {
version: 1,
onError: (e) => reportToSentry(e),
});
return (
<button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
現在のテーマ: {theme}
</button>
);
};
まとめ
useLocalStorage は一見シンプルですが、本番運用に耐える実装にするには以下のポイントを押さえる必要があります。
-
SSR:
typeof windowガードとuseEffect同期でハイドレーション不一致を回避 -
型安全: ジェネリクスで
useStateと同じシグネチャを再現 -
耐障害性: JSON パースと書き込みは必ず
try/catchで囲み、エラーを可視化 -
タブ間同期:
storageイベントを購読 - 将来の変更: バージョン番号で古いデータを安全に破棄
「動くもの」と「壊れないもの」の差は、こうした地味な配慮の積み重ねです。プロジェクトに useLocalStorage を導入する際は、ぜひ完成版のコードをベースに、必要なオプションを追加してみてください。