useLocalStorage ist einer der häufigsten Custom Hooks in React-Codebases — auch deshalb, weil ein wirklich sauberer useLocalStorage deutlich mehr leistet, als auf den ersten Blick vermutet. Naive Implementierungen funktionieren in Dev, brechen aber bei Server-Side Rendering (kein localStorage auf dem Server), schweigen bei JSON-Parse-Fehlern (verlorene Daten), bleiben bei Quota-Überschreitungen still und sind nicht Tab-übergreifend synchronisiert. Dieser Artikel baut den Hook produktionsreif auf — mit allen Edge-Cases erklärt — und nutzt ihn anschließend in einer kleinen Todo-App.
Was der Hook leisten soll
Aus Konsumenten-Sicht soll useLocalStorage(key, initialValue) so funktionieren wie useState:
const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
// Lesen wie useState
console.log(theme);
// Schreiben wie useState — automatisch in localStorage gespiegelt
setTheme('dark');
// Funktionaler Setter
setTheme(t => t === 'light' ? 'dark' : 'light');
// Aufräumen — Eintrag aus localStorage entfernen, State zurück auf initialValue
removeTheme();Dazu kommen sechs Edge-Cases, die der Hook intern abdeckt:
- Server-Side Rendering — auf dem Server gibt es kein
window. Der Hook muss das erkennen und einen Fallback haben. - JSON-Parse-Fehler —
localStoragespeichert nur Strings. Wir serialisieren mitJSON.stringify. Falls der String später beschädigt ist oder im falschen Format, wirftJSON.parse— wir fangen das ab. - Schreib-Fehler —
localStoragehat eine Quota (typischerweise 5-10 MB). Bei Überschreitung wirftsetItemQuotaExceededError. - Memory-Fallback — wenn localStorage gar nicht verfügbar ist (SSR, Privacy-Mode, Storage disabled), liefert der Hook über ein In-Memory-Object weiter — die App stirbt nicht.
- Tab-übergreifender Sync — wenn der User in einem anderen Tab denselben Key ändert, soll dieser Tab das mitkriegen. Das Browser-
storage-Event hilft (feuert aber nur in ANDEREN Tabs, nicht im eigenen). - Same-Tab-Sync — zwei Komponenten im selben Tab, die denselben Key nutzen, sollen sich gegenseitig sehen. Dafür dispatchen wir ein eigenes Custom-Event.
Der Hook in vollständiger Form
import { useState, useEffect, useCallback, useRef } from 'react';
const STORAGE_EVENT = 'mibeon:local-storage';
export function useLocalStorage(key, initialValue) {
// 1. Verfügbarkeit prüfen (SSR-sicher)
const isAvailable = typeof window !== 'undefined' && !!window.localStorage;
// 2. Memory-Fallback für SSR / disabled storage
const memoryStore = useRef({});
// 3. Lese-Helfer — kapselt Quelle und JSON-Parsing
const readValue = useCallback(() => {
if (!isAvailable) {
// Fallback: Memory-Store oder initialValue
return key in memoryStore.current
? memoryStore.current[key]
: initialValue;
}
try {
const raw = window.localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : initialValue;
} catch (error) {
// JSON.parse-Fehler — beschädigter Eintrag
console.warn(`useLocalStorage: parse error for "${key}"`, error);
return initialValue;
}
}, [key, initialValue, isAvailable]);
// 4. Lazy Initial — readValue läuft nur beim Mount
const [storedValue, setStoredValue] = useState(readValue);
// 5. Schreib-Funktion — synchron mit React-State + localStorage
const setValue = useCallback((valueOrFn) => {
setStoredValue(prev => {
// Funktionaler Setter: valueOrFn ist eine Funktion
const next = valueOrFn instanceof Function ? valueOrFn(prev) : valueOrFn;
if (isAvailable) {
try {
window.localStorage.setItem(key, JSON.stringify(next));
// Custom-Event für Same-Tab-Sync (storage-Event feuert nur in ANDEREN Tabs)
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, {
detail: { key, newValue: next },
}));
} catch (error) {
// z.B. QuotaExceededError oder Serialisierungs-Fehler
console.warn(`useLocalStorage: write error for "${key}"`, error);
}
} else {
memoryStore.current[key] = next;
}
return next;
});
}, [key, isAvailable]);
// 6. Lösch-Funktion — Eintrag aus Storage und State zurück auf initialValue
const removeValue = useCallback(() => {
if (isAvailable) {
try {
window.localStorage.removeItem(key);
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, {
detail: { key, newValue: null },
}));
} catch (error) {
console.warn(`useLocalStorage: remove error for "${key}"`, error);
}
} else {
delete memoryStore.current[key];
}
setStoredValue(initialValue);
}, [key, initialValue, isAvailable]);
// 7. Cross-Tab + Same-Tab-Sync via Events
useEffect(() => {
if (!isAvailable) return;
// Browser-natives 'storage'-Event: feuert NUR in anderen Tabs
const handleStorageEvent = (event) => {
if (event.storageArea !== window.localStorage) return;
if (event.key !== key) return;
try {
const next = event.newValue !== null
? JSON.parse(event.newValue)
: initialValue;
setStoredValue(next);
} catch (error) {
console.warn(`useLocalStorage: cross-tab parse error for "${key}"`, error);
setStoredValue(initialValue);
}
};
// Custom-Event: feuert IM gleichen Tab (zwischen Komponenten)
const handleCustomEvent = (event) => {
if (event.detail?.key !== key) return;
const next = event.detail.newValue !== null
? event.detail.newValue
: initialValue;
setStoredValue(next);
};
window.addEventListener('storage', handleStorageEvent);
window.addEventListener(STORAGE_EVENT, handleCustomEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
window.removeEventListener(STORAGE_EVENT, handleCustomEvent);
};
}, [key, initialValue, isAvailable]);
return [storedValue, setValue, removeValue];
}Die acht Bausteine im Detail
Wir gehen die nummerierten Abschnitte aus dem Code durch — jeder hat seinen spezifischen Grund.
1. isAvailable — SSR-Detection
const isAvailable = typeof window !== 'undefined' && !!window.localStorage;Zwei Prüfungen kombiniert:
typeof window !== 'undefined'— auf dem Server (Node.js während SSR) gibt es kein globaleswindow.typeofwirft auch bei undefined-Variablen keinenReferenceError, dahertypeof-Test statt direktwindow.!!window.localStorage— selbst im Browser kannlocalStoragenulloder undefined sein (Storage-Disabled-Setting, Privacy-Mode, alte WebViews). Der Doppel-Bang macht daraus einen sauberen Boolean.
Das Ergebnis ist eine Konstante, die der Hook später überall als Verzweigungs-Schalter nutzt.
2. Memory-Fallback mit useRef
const memoryStore = useRef({});Wenn localStorage nicht verfügbar ist, muss der Hook trotzdem funktionieren — sonst stirbt die App auf dem Server oder im Privacy-Modus. Ein useRef-Object dient als minimaler In-Memory-Store: Werte überleben Re-Renders (Ref ändert sich nicht), aber nicht Page-Reloads. Das ist okay als Fallback — der Use-Case ist „App läuft, aber Persistenz war eh nicht möglich".
3. readValue — die Lese-Funktion
const readValue = useCallback(() => {
if (!isAvailable) {
return key in memoryStore.current ? memoryStore.current[key] : initialValue;
}
try {
const raw = window.localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : initialValue;
} catch (error) {
console.warn(`useLocalStorage: parse error for "${key}"`, error);
return initialValue;
}
}, [key, initialValue, isAvailable]);Drei Pfade:
- Kein localStorage: aus Memory lesen, sonst Initial.
key in memoryStore.currentunterscheidet „nicht gesetzt" von „explizit aufundefinedgesetzt". - localStorage verfügbar, Eintrag existiert:
getItemliefert einen String, den wir mitJSON.parsezurück in den Original-Wert umwandeln. - localStorage verfügbar, Eintrag fehlt oder ist beschädigt:
getItemliefertnulloderJSON.parsewirft. In beiden Fällen kommtinitialValuezurück.
Wir nutzen useCallback, weil readValue von useEffect/useState-Initial verwendet wird und stabile Referenz hilfreich ist.
4. Lazy Initial mit useState
const [storedValue, setStoredValue] = useState(readValue);useState akzeptiert nicht nur einen direkten Wert, sondern auch eine Initializer-Funktion. Diese wird nur einmal beim Mount aufgerufen — exakt das, was wir hier wollen: readValue läuft genau beim ersten Render, nicht bei jedem späteren. Ohne diese Lazy-Form würde readValue() bei jedem Render unnötig ausgeführt.
5. setValue — der Schreibe-Setter
const setValue = useCallback((valueOrFn) => {
setStoredValue(prev => {
const next = valueOrFn instanceof Function ? valueOrFn(prev) : valueOrFn;
// ... schreiben + Event dispatchen
return next;
});
}, [key, isAvailable]);Wichtige Punkte:
useState-API-kompatibel: Konsumenten dürfen einen direkten Wert übergeben (setValue('dark')) oder eine Funktion (setValue(v => !v)).instanceof Functionunterscheidet.- Schreiben passiert INNERHALB des Setters: Wir nutzen die funktionale Form
setStoredValue(prev => ...), sodassprevder aktuelle React-State ist — und schreiben den neuen Wert in derselben Closure in localStorage. Damit sind localStorage und React-State immer im Gleichschritt. - Custom-Event dispatchen: Das
storage-Browser-Event feuert nur in anderen Tabs (Browser-Sicherheit). Damit zwei Komponenten im selben Tab synchron bleiben, dispatchen wir manuell ein eigenes Event. - Try/Catch um
setItem: localStorage hat eine Quota (typisch 5-10 MB). Bei Überschreitung wirftsetItemQuotaExceededError. Im Catch-Pfad nur loggen — der React-State ist trotzdem aktualisiert, nur nicht persistiert.
6. removeValue — explizites Löschen
Setzt den Eintrag in localStorage zurück (removeItem), feuert das Custom-Event mit newValue: null, und resettet den React-State auf initialValue. Praktisch für Logout-Flows, „Standard wiederherstellen"-Buttons usw.
7. Cross-Tab + Same-Tab-Sync via Events
useEffect(() => {
if (!isAvailable) return;
const handleStorageEvent = (event) => { /* ... */ };
const handleCustomEvent = (event) => { /* ... */ };
window.addEventListener('storage', handleStorageEvent);
window.addEventListener(STORAGE_EVENT, handleCustomEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
window.removeEventListener(STORAGE_EVENT, handleCustomEvent);
};
}, [key, initialValue, isAvailable]);Der useEffect registriert zwei Event-Listener:
storageist ein natives Browser-Event, das feuert, wenn ein anderer Tab localStorage ändert. Wichtig: dieses Event feuert NICHT im Tab, der die Änderung gemacht hat. Damit ein User, der in Tab A den Theme wechselt, das in Tab B sofort sieht.mibeon:local-storageist unser eigenes Custom-Event, das wir insetValueundremoveValueselbst dispatchen. Es deckt den Same-Tab-Fall ab: zwei Komponenten im selben Tab, die denselben Key nutzen, sehen jeweils die Änderungen der anderen.
Der Cleanup im Return des useEffect entfernt beide Listener beim Unmount — sonst Memory-Leak und Cross-Component-Trigger.
8. Rückgabe als Tupel
return [storedValue, setValue, removeValue] — Tupel-Form analog zu useState. Konsumenten benennen frei beim Destructuring.
Todo-App als Konsument
Eine kleine Todo-App demonstriert den Hook in der Praxis. Die Komponente kümmert sich nur ums UI — Persistenz, JSON, Tab-Sync, SSR-Fallback erledigt der Hook.
import { useState } from 'react';
import { useLocalStorage } from './useLocalStorage';
export default function TodoApp() {
// Todos werden automatisch in localStorage gespiegelt
const [todos, setTodos] = useLocalStorage('todos', []);
const [draft, setDraft] = useState('');
const handleAdd = () => {
const text = draft.trim();
if (!text) return;
const newTodo = {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date().toISOString(),
};
setTodos(prev => [...prev, newTodo]);
setDraft('');
};
const handleToggle = (id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
};
const handleDelete = (id) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
const completedCount = todos.filter(t => t.completed).length;
return (
<div className="todo-app">
<div className="todo-input-row">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder="Neue Aufgabe …"
/>
<button onClick={handleAdd}>Hinzufügen</button>
</div>
{todos.length === 0 ? (
<p className="empty-state">Keine Aufgaben — füge eine hinzu!</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
onClick={() => handleToggle(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: todo.completed ? 0.6 : 1,
cursor: 'pointer',
}}
>
{todo.text}
</span>
<button onClick={() => handleDelete(todo.id)}>×</button>
</li>
))}
</ul>
)}
{todos.length > 0 && (
<p className="todo-summary">
{completedCount} von {todos.length} erledigt
</p>
)}
</div>
);
}Wichtige Details:
crypto.randomUUID()als stabile Item-ID — perfekt als List-Key, im Gegensatz zuDate.now()(kann bei schnellem Klicken kollidieren) oderMath.random()(nicht garantiert eindeutig).onKeyDownfür Enter-Submit — UX-Detail, das User erwarten.- Funktionale Setter überall —
setTodos(prev => ...)vermeidet Stale-Closure-Bugs, vor allem wenn der User schnell hintereinander mehrere Aktionen ausführt. completedCountabgeleitet — kein eigener State, sondern bei jedem Render austodosberechnet.
Test der Persistenz: Füge Aufgaben hinzu, lade die Seite neu — die Aufgaben sind da. Öffne die App in zwei Tabs und füge in einem etwas hinzu — der andere Tab updatet sich automatisch dank des storage-Events.

Weiterentwicklungs-Ideen
Der Hook deckt 95 % der Cases ab. Wer noch weiter optimieren will, hat diese Hebel:
- TypeScript-Generics für Type-Safety:
useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void, () => void]. Konsumenten bekommen exakte Typen fürtheme: 'light' | 'dark'statt nurstring. - Schema-Validation beim Lesen mit Zod:
parsed.success ? parsed.data : initialValue. Falls jemand außerhalb der App den Wert manuell editiert (DevTools), bekommt der Hook trotzdem nur valide Werte. - Encoding für sensible Daten mit
btoa/atoboder Web Crypto API. Für echte Sicherheit aber nicht ausreichend —localStorageist nicht für Secrets gedacht (XSS-Angriffe könnten alles lesen). - TTL-Support (Time-To-Live): den Wert mit Timestamp speichern, beim Lesen prüfen, ob abgelaufen. Macht den Hook zu einem Mini-Cache.
sessionStorageals Variante — denselben Hook mit konfigurierbarem Storage-Backend bauen.
Besonderheiten
typeof window !== 'undefined' ist die SSR-Test-Konvention.
Auf dem Server existiert kein window. typeof-Test wirft keinen ReferenceError, anders als direkter Zugriff. Pflicht in jedem Hook, der Browser-APIs nutzt.
Lazy Initial via Funktions-Form von useState.
useState(readValue) ruft readValue nur beim Mount auf. useState(readValue()) würde es bei JEDEM Render aufrufen — und das Ergebnis ignorieren. Detail-Unterschied, großer Performance-Impact.
Browser-natives storage-Event feuert NICHT im eigenen Tab.
Sicherheits-Design: ein Tab muss nicht seine eigenen Änderungen mitkriegen. Für Same-Tab-Sync ist ein eigenes Custom-Event nötig — das wir in setValue/removeValue dispatchen.
JSON.parse in `try/catch` — sonst stirbt der Hook bei beschädigten Daten.
Wenn der localStorage-Wert manuell editiert wurde oder eine Migration die Form geändert hat, wirft JSON.parse. Ohne Catch crasht der ganze Render — mit Catch fallen wir sauber auf initialValue zurück.
QuotaExceededError bei `setItem` — Schreib-Schutz.
localStorage hat 5-10 MB Quota. Bei Überschreitung wirft setItem. Try/Catch fängt das ab — React-State bleibt aktualisiert, nur die Persistenz schlägt fehl. App stirbt nicht.
useRef als Memory-Fallback überlebt Re-Renders, NICHT Reloads.
Wenn localStorage nicht verfügbar ist, nutzt der Hook ein Ref-Object als Mini-Store. Der überlebt React-Re-Renders, aber kein Page-Reload — was okay ist, weil Persistenz dort sowieso nicht möglich ist.
Custom-Event-Name mit eindeutigem Präfix (mibeon:) gegen Kollisionen.
Native und third-party Code kann ebenfalls Custom-Events feuern. Ein Präfix wie mibeon: oder app: verhindert Konflikte. Konvention aus dem Browser-Event-Ökosystem.
crypto.randomUUID() als List-Item-ID — perfekte Stabilität.
Im Gegensatz zu Date.now() (Kollision bei schnellem Klick) und Math.random() (nicht garantiert eindeutig) liefert randomUUID garantiert eindeutige Werte. Verfügbar in allen modernen Browsern + Node 19+.
Weiterführende Ressourcen
Externe Quellen
- Window: storage event – MDN
- Storage: setItem() – MDN
- QuotaExceededError – MDN
- crypto.randomUUID() – MDN
- Zod – Schema Validation