ReactでローカルストレージにStateを永続化する方法
再利用できるフックでReactの状態をlocalStorageに保存。useStateの遅延初期化、JSONのtry/catch、SSR対策、タブ間同期まで解説。
ReactのStateをlocalStorageに永続化するには、イニシャライザ関数内でストレージからuseStateを初期化し、値が変わるたびに書き戻します。そして、JSONをtry/catchでラップし、サーバーサイドレンダリングに対するガードを設けます。
Reactアプリは最終的に、たいていテーマの切り替えや折りたたんだ状態を維持すべきサイドバーのために、こうした処理を実装することになります。3行で書けるバージョンは5分で完成しますが、後になって静かに半日を奪っていきます。その素朴なバージョンは単一タブ上のカウンターには機能しますが、3つの予測可能な方法で壊れます。破損したデータでクラッシュし、Next.jsでwindow is not definedをスローし、タブをまたいで古い値が残ります。この記事ではuseLocalStorageフックを正確性の階段を一段ずつ上るように構築し、各障害モードを順番に修正し、React 18または19のプロジェクトにそのまま貼り付けられるフックで締めくくります。
localStorageは、オリジンごとに約5MBの同期的・同一オリジン・文字列専用のキー/バリューストアで、MDN Web Storage APIに文書化されています。コードを書く前に一つのルール:認証トークンや個人識別情報(PII)は絶対に保存しないでください。ページ上のあらゆるJavaScriptから読み取り可能で、暗号化されていません。
重要なポイント
localStorageの読み取りはuseStateのイニシャライザ内で行い、useEffectを通じてデフォルト値が先にフラッシュされることなく、マウント時に一度だけ実行されるようにします。localStorageは文字列のみを保存するため、書き込み時はJSON.stringify、読み取り時はJSON.parseで永続化し、破損した値でコンポーネントがクラッシュしないようtry/catchでラップします。- サーバーには
windowが存在しないため、最初のレンダリング中にストレージを読み取ると、Next.jsやRemixでwindow is not definedがスローされます。サーバーではデフォルト値をレンダリングし、マウント後に永続化された値と同期します。 - ブラウザの
storageイベントは他のタブでのみ発火し、値を書き込んだタブでは発火しません。そのため、同一タブのリスナーには手動でディスパッチされたイベントが必要です。 - React 18で追加された
useSyncExternalStoreは、localStorageのような外部のミュータブルストアにコンポーネントをサブスクライブするための公式サポートされた方法です。
素朴なReact localStorageパターン
出発点は、遅延初期化されたuseStateと書き込みエフェクトの組み合わせです。Reactでは、localStorageの読み取りをuseStateのイニシャライザ関数内で行い、useEffectでデフォルト値が先にフラッシュされることなく、マウント時に一度だけ実行されるようにします。
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
Theme: {theme}
</button>
);
}
useStateに関数を渡すこと(useState(localStorage.getItem(...))ではなく)が重要です。遅延イニシャライザは最初のレンダリング時にのみ実行されるため、再レンダリングのたびにlocalStorageにアクセスするのを避けられます。また、別のuseEffectではなくイニシャライザ内で読み取ることで、最初のペイント時に正しい値が存在し、デフォルト値から永続化された値へのフラッシュが発生しません。
JSONとtry/catchで安全にシリアライズする
Discover how at OpenReplay.com.
素朴なバージョンは文字列しか扱えません。localStorageは文字列のみを保存するため、文字列以外のStateは書き込み時にJSON.stringify、読み取り時にJSON.parseで永続化し、パースをtry/catchでラップして、単一の破損または古い値でコンポーネントがクラッシュしないようにします。本番環境でよくある障害パターンは、スキーマの変更や半分だけ書き込まれた値によって、あるキーの下に無効なJSONが残ることです。このガードがなければ、JSON.parseはマウント時にスローし、コンポーネントを落とします。
function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback; // 破損または古い値 → デフォルトにフォールバック
}
}
catchブランチはエラーを伝播させる代わりにデフォルト値を返します。これが、不正なキーで一つの設定がリセットされるだけで済むか、ページ全体が白紙になるかの違いです。
再利用可能なuseLocalStorageフックはどのように構築するか?
このパターンをuseStateのドロップイン代替となるフックにラップします。useStateとの対称性を保つため、useLocalStorageのセッターは関数型更新を受け付ける必要があり、setValue(prev => prev + 1)が組み込みのStateと同じように機能します。これは手作りのバージョンの多くが見落とすエルゴノミクスのギャップです。
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => readJSON(key, initialValue));
const set = useCallback(
(next: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = next instanceof Function ? next(prev) : next;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
},
[key],
);
return [value, set] as const;
}
next instanceof FunctionチェックがuseStateのエルゴノミクスを維持します。このバージョンはクライアント上では正しく動作しますが、レンダリング中にlocalStorageを読み取るため、サーバーサイドレンダリングを行った瞬間に壊れます。
SSRの落とし穴:「window is not defined」とハイドレーションの不一致
サーバーにはwindowもlocalStorageも存在しないため、最初のレンダリング中にストレージを読み取ると、Next.jsやRemixでwindow is not definedがスローされます。typeof window === 'undefined'でガードし、マウント後のエフェクトで永続化された値を読み取るようにします。
クラッシュを止めた後でも、さらに微妙なバグが残ります。サーバーがデフォルトのStateをレンダリングする一方でクライアントはすでに保存された値を持っているため、ハイドレーションの不一致が発生します。Reactの最初のクライアントレンダリングはサーバーのHTMLと一致しなければならないため、ハイドレーション中にイニシャライザでlocalStorageを読み取ると、マークアップが乖離します。修正方法は、サーバーではデフォルト値をレンダリングし、ハイドレーション後のエフェクトで永続化された値と同期することです。
const IS_SERVER = typeof window === 'undefined';
function useLocalStorage<T>(key: string, initialValue: T, initializeWithValue = true) {
const readValue = () => (IS_SERVER ? initialValue : readJSON(key, initialValue));
const [value, setValue] = useState<T>(() =>
initializeWithValue ? readValue() : initialValue,
);
useEffect(() => {
setValue(readValue()); // マウント後にストレージから同期
}, [key]);
// ...セッターは前述のとおり
}
initializeWithValueフラグはusehooks-tsのuseLocalStorageのスイッチを踏襲しています。SSRではfalseに設定することで、フックはサーバーでデフォルト値を返し、ハイドレーション後に同期します。このクラスのバグは、クリーンなlocalhostでの読み込みではほぼ見えません。実際の本番セッションをリプレイすることで、ハイドレーションのフラッシュ(永続化された値が引き継ぐ前に1フレームだけデフォルトのテーマが描画される)が初めて見えることが多いです。これはタイミングと環境に依存するものであり、オンデマンドで再現できるものではないからです。
タブをまたいだ同期とモダンなuseSyncExternalStoreアプローチ
ユーザーが2つのタブを開いている場合、永続化されたStateは一貫性を保つ必要があります。MDNのWindow: storage eventに記載されているブラウザのstorageイベントは、他のタブやドキュメントでのみ発火し、値を書き込んだタブでは発火しません。タブをまたいだ同期にはstorageリスナーが必要で、同一タブのリスナーには手動でディスパッチされたカスタムイベントが必要です。
新しいコードには、useStateとエフェクトよりもクリーンなプリミティブがあります。useSyncExternalStoreはReact 18で導入され、コンポーネントを外部のミュータブルストアにサブスクライブする公式の方法です。コンポーネントは通常、props、State、コンテキストから読み取りますが、Reactの外部に存在し時間とともに変化する値を読み取る必要がある場合もあります。これには、ミュータブルな値を保持し変化時にイベントを発行するブラウザAPIが含まれます。Reactのフックのリファレンスでは、可能な限り組み込みのStateを推奨し、主に既存の非Reactコードとの統合のためにこのフックを使用することを推奨しています。localStorageはその条件を満たしており、メンテナンスされているライブラリが並行処理安全でタブをまたいで正確な読み取りのためにこれを採用した理由です。
function useLocalStorageValue(key: string, initial: string) {
const subscribe = (cb: () => void) => {
window.addEventListener('storage', cb);
return () => window.removeEventListener('storage', cb);
};
return useSyncExternalStore(
subscribe,
() => localStorage.getItem(key) ?? initial,
() => initial, // サーバースナップショット
);
}
useLocalStorageを自作すべきか、ライブラリを使うべきか?
クライアント上で単一のプリミティブ値が必要な場合は自作します。シリアライズのエッジケース、SSR、タブをまたいだ同期をまとめて処理する必要がある場合は、メンテナンスされているライブラリを使用します。以下の両オプションはReact 18と19で動作します。現在のリリースラインはReact 19.2で、2025年10月1日にリリースされ、その後の19.2.xパッチリリースはReactのchangelogに記載されています。
| オプション | 最適な用途 | SSR処理 | 備考 |
|---|---|---|---|
| 自作フック | 単発のプリミティブ、完全な制御 | typeof windowガード + マウント後エフェクト | エッジケースは自己責任 |
| usehooks-ts | removeValue付きのドロップインフック | initializeWithValue: false | useSyncExternalStoreではなくuseState + イベントで構築 |
| use-local-storage-state | タブをまたいだ同期 + 並行処理の正確性 | useSyncExternalStoreで構築 | 広く使用されている。メンテナはハイドレーション中のコンポーネントが2回レンダリングされる可能性を指摘 |
以下は完全な自作フックです。React 18と19で正しく動作し、遅延初期化、try/catch JSON、SSRガード、関数型更新、removeValue、タブをまたいだ同期と同一タブのイベントを備えています。
import { useCallback, useEffect, useState } from 'react';
const IS_SERVER = typeof window === 'undefined';
type Options<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean; // SSRではfalseに設定
};
export function useLocalStorage<T>(
key: string,
initialValue: T,
options: Options<T> = {},
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const { initializeWithValue = true } = options;
const serialize = options.serializer ?? JSON.stringify;
const deserialize = options.deserializer ?? ((v: string) => JSON.parse(v) as T);
const readValue = useCallback((): T => {
if (IS_SERVER) return initialValue;
try {
const raw = window.localStorage.getItem(key);
return raw ? deserialize(raw) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue, deserialize]);
const [storedValue, setStoredValue] = useState<T>(() =>
initializeWithValue ? readValue() : initialValue,
);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const next = value instanceof Function ? value(readValue()) : value;
window.localStorage.setItem(key, serialize(next));
setStoredValue(next);
window.dispatchEvent(new StorageEvent('local-storage', { key }));
} catch {
/* クォータ超過またはプライベートモード — 無視 */
}
},
[key, readValue, serialize],
);
const removeValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
window.dispatchEvent(new StorageEvent('local-storage', { key }));
}, [key, initialValue]);
// マウント後にストレージから同期(SSRハイドレーションを修正)し、キー変更時にも同期。
useEffect(() => {
setStoredValue(readValue());
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
// タブをまたいだ('storage')+ 同一タブ('local-storage')リスナー。
useEffect(() => {
const onChange = (event: Event) => {
const e = event as StorageEvent;
if (e.key && e.key !== key) return;
setStoredValue(readValue());
};
window.addEventListener('storage', onChange);
window.addEventListener('local-storage', onChange);
return () => {
window.removeEventListener('storage', onChange);
window.removeEventListener('local-storage', onChange);
};
}, [key, readValue]);
return [storedValue, setValue, removeValue];
}
エフェクトの依存関係が毎回のレンダリングで変動しないよう、安定したinitialValue(プリミティブまたはメモ化されたオブジェクト)を渡してください。
ReactのStateの永続化は一行で済む話ではなく、段階的なプロセスです。遅延初期化されたuseStateと書き込みエフェクトから始め、JSONのtry/catchを追加し、SSRのガードを設け、タブをまたいだイベントを接続します。上記のフックを共有のhooks/ファイルに配置し、リフレッシュ後も維持する必要があるStateのuseStateをそれに置き換えてください。タブをまたいだ並行処理の正確性が重要になった時点で、useSyncExternalStoreまたはメンテナンスされているライブラリを採用してください。
よくある質問
ReactのStateを永続化する際のlocalStorageとsessionStorageの違いは何ですか?
どちらも同期的・同一オリジン・文字列専用のキー/バリューストアで約5MBですが、ライフタイムが異なります。localStorageは明示的にクリアされるまで無期限に永続化されるため、リフレッシュ、タブを閉じる、ブラウザの再起動後もStateが残ります。sessionStorageは単一のタブセッションにスコープされ、そのタブが閉じられると消去され、タブ間で共有されません。セッションを超えて維持すべき設定にはlocalStorageを、タブごとの一時的なStateにはsessionStorageを使用してください。
単一のStateを永続化するためにRedux PersistやグローバルストアをなぜReactで使うべきではないのですか?
Redux Persistのようなグローバルストアを使って1つの値を保存しようとすると、ローカルフックで既に処理できるStateのために、ストア、ミドルウェア、シリアライズの設定が追加されます。useLocalStorageフックは値をそれを所有するコンポーネントと同じ場所に置き、関数型更新を含むuseStateのエルゴノミクスを踏襲します。Redux PersistがReduxストアをすでに運用しており、スライス全体の再ハイドレーションが必要な場合には価値がありますが、テーマの切り替えや単一のフォームフィールドには不要です。
localStorageがいっぱいになったり、プライベートブラウジングモードで無効になったりするとどうなりますか?
オリジンの約5MBクォータを超えるとlocalStorageへの書き込みはQuotaExceededErrorをスローし、一部のブラウザはクォータがゼロに設定されているためプライベートまたはシークレットモードでは書き込みをスローします。ガードのないsetItemはコンポーネントをクラッシュさせます。これが、堅牢なフックのセッターが書き込みをtry/catchでラップする理由です。読み取りもデフォルト値にフォールバックすべきで、ブロックされたまたはいっぱいのストアがレンダリングを壊すのではなく、インメモリのStateに劣化するようにします。
useSyncExternalStoreはuseStateとuseEffectを使ったlocalStorageパターンを完全に置き換えますか?
すべてのケースでそうとは限りません。React 18で追加されたuseSyncExternalStoreは、コンポーネントを外部のミュータブルストアにサブスクライブする並行処理安全な方法であり、タブをまたいだ正確性と並行レンダリングが重要な場合に適しています。ReactのドキュメントでもできるだけBuilt-inのStateを推奨し、非ReactストアとのIntegrationのためにこのフックを使用することを推奨しています。クライアント専用の単一プリミティブには、遅延初期化されたuseStateと書き込みエフェクトの方がシンプルかつ正確です。タブ間の同期が必要になった時点でuseSyncExternalStoreを採用してください。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k