Zustandspersistenz in Local Storage mit React
Persistiere React-State in localStorage mit einem wiederverwendbaren Hook: lazy useState, JSON try/catch, SSR-Guard und Tab-Synchronisierung.
Um den React-Zustand in localStorage zu persistieren, initialisiert man useState aus dem Speicher heraus über eine Initializer-Funktion und schreibt den Wert bei jeder Änderung zurück — dabei wird das JSON in try/catch eingebettet und gegen Server-seitiges Rendering abgesichert.
Jede React-Anwendung entwickelt früher oder später eine solche Anforderung, meist für einen Theme-Umschalter oder eine Seitenleiste, die eingeklappt bleiben soll. Die Drei-Zeilen-Version ist in fünf Minuten geschrieben und kostet einen dann still und leise einen Nachmittag. Diese naive Version funktioniert für einen Zähler in einem einzigen Tab, scheitert jedoch auf drei vorhersehbare Arten: Sie stürzt bei korrumpierten Daten ab, wirft window is not defined in Next.js und wird über Tabs hinweg inkonsistent. Dieser Artikel entwickelt einen useLocalStorage-Hook schrittweise zur Korrektheit hin, behebt jeden Fehlerfall der Reihe nach und endet mit einem einsatzbereiten Hook, der direkt in ein React-18- oder -19-Projekt eingefügt werden kann.
localStorage ist ein synchroner, ursprungsgebundener, ausschließlich Zeichenketten speichernder Key/Value-Store mit etwa 5 MB pro Ursprung, dokumentiert in der MDN Web Storage API. Eine Regel vor jedem Code: Niemals Auth-Tokens oder personenbezogene Daten darin speichern. Er ist für jedes JavaScript auf der Seite lesbar und nicht verschlüsselt.
Wichtige Erkenntnisse
localStorageinnerhalb des Initializers vonuseStatelesen, damit der Zugriff nur einmal beim Mounten erfolgt, anstatt den Standardwert durch einuseEffectkurz aufzublitzen.- Da
localStorageausschließlich Zeichenketten speichert, wird beim SchreibenJSON.stringifyund beim LesenJSON.parseverwendet, eingebettet intry/catch, damit ein korrumpierter Wert nicht die gesamte Komponente zum Absturz bringen kann. - Auf dem Server existiert kein
window, weshalb das Lesen aus dem Speicher beim ersten Rendering in Next.js und Remixwindow is not definedauslöst. Den Standardwert auf dem Server rendern und nach dem Mounten mit dem persistierten Wert synchronisieren. - Das
storage-Event des Browsers wird nur in anderen Tabs ausgelöst, niemals in dem Tab, der den Wert geschrieben hat — daher benötigen Listener im selben Tab ein manuell ausgelöstes Event. useSyncExternalStore, eingeführt in React 18, ist der offiziell unterstützte Weg, eine Komponente auf einen externen, veränderlichen Store wielocalStoragezu abonnieren.
Das naive React-localStorage-Muster
Der Ausgangspunkt ist ein lazy-initialisiertes useState in Kombination mit einem Write-Effect. In React liest man localStorage innerhalb der Initializer-Funktion von useState, damit der Zugriff nur einmal beim Mounten erfolgt — anstatt ihn in einem useEffect zu lesen, das zunächst kurz den Standardwert anzeigen würde.
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>
);
}
Das Übergeben einer Funktion an useState (nicht useState(localStorage.getItem(...))) ist entscheidend: Der Lazy Initializer wird nur beim ersten Rendering ausgeführt, sodass localStorage nicht bei jedem Re-Rendering aufgerufen wird. Das Lesen im Initializer statt in einem separaten useEffect stellt außerdem sicher, dass der korrekte Wert bereits beim allerersten Rendering vorliegt — ohne das typische Aufblitzen von Standard- zu persistiertem Wert.
Sicheres Serialisieren mit JSON und try/catch
Discover how at OpenReplay.com.
Die naive Version verarbeitet nur Zeichenketten. Da localStorage ausschließlich Zeichenketten speichert, werden Nicht-String-Zustände mit JSON.stringify beim Schreiben und JSON.parse beim Lesen persistiert. Das Parsen wird in try/catch eingebettet, damit ein einzelner korrumpierter oder veralteter Wert nicht die gesamte Komponente zum Absturz bringen kann. Ein häufiger Fehler in Produktionsumgebungen entsteht durch Schema-Änderungen oder halb geschriebene Werte, die ungültiges JSON unter einem Key hinterlassen. Ohne diese Absicherung wirft JSON.parse beim Mounten eine Ausnahme und reißt die Komponente mit.
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; // korrumpierter oder veralteter Wert → Fallback auf Standard
}
}
Der catch-Zweig gibt den Standardwert zurück, anstatt den Fehler weiterzureichen. Das ist der Unterschied zwischen einem fehlerhaften Key, der eine einzelne Einstellung zurücksetzt, und einem fehlerhaften Key, der die gesamte Seite leert.
Wie baut man einen wiederverwendbaren useLocalStorage-Hook?
Das Muster wird in einen Hook verpackt, der useState spiegelt und damit als direkter Ersatz eingesetzt werden kann. Um die Parität mit useState zu wahren, muss der Setter von useLocalStorage funktionale Updates akzeptieren, sodass setValue(prev => prev + 1) genauso funktioniert wie beim eingebauten State. Das ist die ergonomische Lücke, die die meisten selbst geschriebenen Versionen übersehen.
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;
}
Die Prüfung next instanceof Function ist es, die die useState-Ergonomie erhält. Diese Version ist auf dem Client korrekt, liest localStorage jedoch noch während des Renderings — was sofort zu Problemen führt, sobald Server-seitiges Rendering im Spiel ist.
Die SSR-Falle: „window is not defined” und Hydration-Mismatch
Auf dem Server existieren weder window noch localStorage, weshalb das Lesen aus dem Speicher beim ersten Rendering in Next.js und Remix window is not defined auslöst. Mit typeof window === 'undefined' absichern und den persistierten Wert erst nach dem Mounten lesen.
Selbst nach Behebung des Absturzes verbirgt sich ein zweiter, subtilerer Fehler. Ein Hydration-Mismatch entsteht, weil der Server den Standardzustand rendert, während der Client bereits den gespeicherten Wert kennt. Reacts erster Client-Rendering-Durchlauf muss mit dem Server-HTML übereinstimmen — liest man localStorage also im Initializer während der Hydration, weicht das Markup ab. Die Lösung besteht darin, auf dem Server den Standardwert zu rendern und erst in einem Effect nach der Hydration mit dem persistierten Wert zu synchronisieren.
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()); // nach dem Mounten aus dem Speicher synchronisieren
}, [key]);
// ...Setter wie zuvor
}
Das Flag initializeWithValue spiegelt den Schalter im usehooks-ts useLocalStorage: Für SSR auf false setzen, damit der Hook auf dem Server den Standardwert zurückgibt und erst nach der Hydration synchronisiert. Diese Art von Fehler ist bei einem sauberen localhost-Ladevorgang kaum sichtbar. Das Wiedergeben einer echten Produktionssitzung ist oft der einzige Weg, das Hydration-Aufblitzen — den Standardtheme, der für einen Frame sichtbar ist, bevor der persistierte Wert übernimmt — tatsächlich zu erkennen, da es von Timing und Umgebung abhängt und sich nicht zuverlässig reproduzieren lässt.
Tab-übergreifende Synchronisierung und der moderne useSyncExternalStore-Ansatz
Persistierter Zustand sollte konsistent bleiben, wenn ein Benutzer zwei Tabs geöffnet hat. Das storage-Event des Browsers, beschrieben in MDNs Window: storage event, wird nur in anderen Tabs und Dokumenten ausgelöst, niemals in dem Tab, der den Wert geschrieben hat. Tab-übergreifende Synchronisierung erfordert daher einen storage-Listener, während Listener im selben Tab ein manuell ausgelöstes Custom Event benötigen.
Für neuen Code gibt es ein saubereres Grundelement als useState plus Effects. useSyncExternalStore wurde in React 18 eingeführt als offizieller Weg, eine Komponente auf einen externen, veränderlichen Store zu abonnieren. Komponenten lesen normalerweise aus Props, State und Context, aber gelegentlich muss eine Komponente einen Wert lesen, der außerhalb von React lebt und sich im Laufe der Zeit ändert — einschließlich Browser-APIs, die einen veränderlichen Wert halten und Events bei Änderungen auslösen. Reacts eigene Referenz für den Hook empfiehlt den eingebauten State, wann immer möglich, und reserviert ihn hauptsächlich für die Integration mit bestehendem Nicht-React-Code. localStorage erfüllt diese Voraussetzung, weshalb gepflegte Bibliotheken ihn für nebenläufigkeitssichere, tab-übergreifend korrekte Lesevorgänge übernommen haben.
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, // Server-Snapshot
);
}
Selbst schreiben oder eine Bibliothek verwenden?
Selbst schreiben, wenn ein einzelner primitiver Wert auf dem Client benötigt wird. Auf eine gepflegte Bibliothek zurückgreifen, wenn Serialisierungs-Sonderfälle, SSR und tab-übergreifende Synchronisierung gemeinsam behandelt werden müssen. Beide nachfolgenden Optionen funktionieren mit React 18 und 19. Die aktuelle Release-Linie ist React 19.2, veröffentlicht am 1. Oktober 2025, mit den seitdem erschienenen 19.2.x-Patch-Releases im React-Changelog.
| Option | Am besten geeignet für | SSR-Behandlung | Hinweise |
|---|---|---|---|
| Selbst geschriebener Hook | Einmalige Primitive, volle Kontrolle | typeof window-Guard + Post-Mount-Effect | Sonderfälle liegen in eigener Verantwortung |
| usehooks-ts | Drop-in-Hook mit removeValue | initializeWithValue: false | Basiert auf useState + Events, nicht auf useSyncExternalStore |
| use-local-storage-state | Tab-übergreifende + nebenläufigkeitssichere Korrektheit | Basiert auf useSyncExternalStore | Weit verbreitet; Maintainer weist darauf hin, dass hydrierende Komponenten zweimal rendern können |
Hier ist der vollständige selbst geschriebene Hook, korrekt für React 18 und 19, mit Lazy-Initialisierung, try/catch-JSON, SSR-Guard, funktionalen Updates, removeValue sowie tab-übergreifenden und tab-internen Events:
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; // für SSR auf false setzen
};
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 {
/* Quota überschritten oder privater Modus — ignorieren */
}
},
[key, readValue, serialize],
);
const removeValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
window.dispatchEvent(new StorageEvent('local-storage', { key }));
}, [key, initialValue]);
// Nach dem Mounten aus dem Speicher synchronisieren (behebt SSR-Hydration) und bei Key-Änderung.
useEffect(() => {
setStoredValue(readValue());
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
// Tab-übergreifende ('storage')- und tab-interne ('local-storage')-Listener.
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];
}
Einen stabilen initialValue übergeben (ein primitiver Wert oder ein memoisiertes Objekt), damit die Effect-Abhängigkeiten nicht bei jedem Rendering neu berechnet werden.
Das Persistieren von React-Zustand ist eine Leiter, kein Einzeiler: Mit einem lazy-initialisierten useState und einem Write-Effect beginnen, JSON-try/catch hinzufügen, für SSR absichern und dann tab-übergreifende Events verdrahten. Den obigen Hook in eine gemeinsame hooks/-Datei einfügen, useState damit für den Zustandsteil ersetzen, der einen Seitenneuladen überleben soll, und zu useSyncExternalStore oder einer gepflegten Bibliothek wechseln, sobald nebenläufigkeitssichere Korrektheit über Tabs hinweg relevant wird.
Häufig gestellte Fragen
Was ist der Unterschied zwischen localStorage und sessionStorage für die Persistenz von React-Zustand?
Beide sind synchrone, ursprungsgebundene, ausschließlich Zeichenketten speichernde Key/Value-Stores mit etwa 5 MB, unterscheiden sich jedoch in ihrer Lebensdauer. localStorage persistiert unbegrenzt, bis er explizit geleert wird — der Zustand überlebt also einen Seitenneuladen, das Schließen des Tabs und einen Browser-Neustart. sessionStorage ist auf eine einzelne Tab-Sitzung beschränkt und wird beim Schließen des Tabs geleert; er wird nicht zwischen Tabs geteilt. localStorage für Einstellungen verwenden, die die Sitzung überdauern sollen, und sessionStorage für tab-spezifischen, flüchtigen Zustand.
Warum sollte ich nicht Redux Persist oder einen globalen Store verwenden, um einen einzelnen Zustandswert zu persistieren?
Einen globalen Store wie Redux Persist für einen einzelnen Wert einzusetzen, bedeutet, Store, Middleware und Serialisierungskonfiguration für Zustand hinzuzufügen, den ein lokaler Hook bereits verwaltet. Ein useLocalStorage-Hook hält den Wert nah an der Komponente, die ihn besitzt, und spiegelt die useState-Ergonomie — einschließlich funktionaler Updates. Redux Persist rechtfertigt seinen Aufwand, wenn bereits ein Redux-Store vorhanden ist und eine vollständige Slice-Rehydrierung benötigt wird, nicht jedoch für einen Theme-Umschalter oder ein einzelnes Formularfeld.
Was passiert, wenn localStorage voll ist oder im privaten Browsing-Modus deaktiviert wurde?
Das Schreiben in localStorage löst einen QuotaExceededError aus, wenn das Ursprungskontingent von etwa 5 MB überschritten wird. Einige Browser werfen bei jedem Schreibversuch im privaten oder Inkognito-Modus eine Ausnahme, da das Kontingent auf null gesetzt ist. Ein ungesichertes setItem bringt die Komponente zum Absturz — deshalb kapselt ein robuster Hook Schreibvorgänge in try/catch. Auch Lesevorgänge sollten auf den Standardwert zurückfallen, damit ein blockierter oder voller Store zu In-Memory-Zustand degradiert, anstatt das Rendering zu unterbrechen.
Ersetzt useSyncExternalStore das useState-plus-useEffect-localStorage-Muster vollständig?
Nicht in jedem Fall. useSyncExternalStore, eingeführt in React 18, ist der nebenläufigkeitssichere Weg, eine Komponente auf einen externen, veränderlichen Store zu abonnieren, und die richtige Wahl, wenn tab-übergreifende Korrektheit und Concurrent Rendering wichtig sind. Reacts eigene Dokumentation empfiehlt den eingebauten State, wann immer möglich, und reserviert den Hook für die Integration von Nicht-React-Stores. Für einen einzelnen, nur client-seitigen primitiven Wert bleibt ein lazy-initialisiertes useState mit einem Write-Effect einfacher und korrekt — useSyncExternalStore einsetzen, wenn Tabs synchron bleiben müssen.
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