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:

TypeScript ZielAPI.jsx
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:

  1. Server-Side Rendering — auf dem Server gibt es kein window. Der Hook muss das erkennen und einen Fallback haben.
  2. JSON-Parse-FehlerlocalStorage speichert nur Strings. Wir serialisieren mit JSON.stringify. Falls der String später beschädigt ist oder im falschen Format, wirft JSON.parse — wir fangen das ab.
  3. Schreib-FehlerlocalStorage hat eine Quota (typischerweise 5-10 MB). Bei Überschreitung wirft setItem QuotaExceededError.
  4. 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.
  5. 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).
  6. 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

TypeScript useLocalStorage.js
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

JavaScript Detail-1
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 globales window. typeof wirft auch bei undefined-Variablen keinen ReferenceError, daher typeof-Test statt direkt window.
  • !!window.localStorage — selbst im Browser kann localStorage null oder 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

JavaScript Detail-2
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

JavaScript Detail-3
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.current unterscheidet „nicht gesetzt" von „explizit auf undefined gesetzt".
  • localStorage verfügbar, Eintrag existiert: getItem liefert einen String, den wir mit JSON.parse zurück in den Original-Wert umwandeln.
  • localStorage verfügbar, Eintrag fehlt oder ist beschädigt: getItem liefert null oder JSON.parse wirft. In beiden Fällen kommt initialValue zurück.

Wir nutzen useCallback, weil readValue von useEffect/useState-Initial verwendet wird und stabile Referenz hilfreich ist.

4. Lazy Initial mit useState

JavaScript Detail-4
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

JavaScript Detail-5
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 Function unterscheidet.
  • Schreiben passiert INNERHALB des Setters: Wir nutzen die funktionale Form setStoredValue(prev => ...), sodass prev der 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 wirft setItem QuotaExceededError. 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

JavaScript Detail-7
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:

  • storage ist 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-storage ist unser eigenes Custom-Event, das wir in setValue und removeValue selbst 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.

TypeScript TodoApp.jsx
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 zu Date.now() (kann bei schnellem Klicken kollidieren) oder Math.random() (nicht garantiert eindeutig).
  • onKeyDown für Enter-Submit — UX-Detail, das User erwarten.
  • Funktionale Setter überallsetTodos(prev => ...) vermeidet Stale-Closure-Bugs, vor allem wenn der User schnell hintereinander mehrere Aktionen ausführt.
  • completedCount abgeleitet — kein eigener State, sondern bei jedem Render aus todos berechnet.

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.

React Custom Hook - Beispiel localStorage und Todo App

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ür theme: 'light' | 'dark' statt nur string.
  • 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/atob oder Web Crypto API. Für echte Sicherheit aber nicht ausreichend — localStorage ist 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.
  • sessionStorage als 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

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht