Die Datenpersistenz in React-Anwendungen stellt Entwickler oft vor Herausforderungen. Custom Hooks bieten eine elegante Lösung für die effiziente Verwaltung von LocalStorage-Daten. Dieser Beitrag demonstriert anhand eines praktischen Beispiels, wie ein maßgeschneiderter Hook die Browser-Speicherung vereinfacht, den Code besser strukturiert und die Wiederverwendbarkeit fördert. Der Leitfaden zeigt ein effektives Implementierungsmuster für die nahtlose Integration von LocalStorage in moderne React-Anwendungen und erläutert dessen Anwendung im Entwicklungsalltag.

Beschreibung

In diesem Beispiel möchten wir einen Custom Hook definieren, welcher eine Box mit Funktionalität für das Schreiben, Lesen und Löschen aus dem LocalStorage darstellt.

Sprich, mit diesem Custom Hook wollen wir in der Lage sein, beliebige Werte in LocalStorage speichern zu können. Damit können wir diesen Hook an unterschiedlichen Stellen unserer Anwendung verwenden.

Nach dem wir unseren Custom Hook aufgebaut haben, machen wir eine kleine Komponente, welche Aufgabenliste in LocalStorage verwaltet. Dank der Nutzung von LocalStorage, bleiben unsere gespeicherte Werte auch nach dem vollständigen Neuladen der Browser-Seite erhalten.

Custom Hook für LocalStorage

In diesem Schritt werden wir unseren Custom Hook in einer Datei namens useLocalStorage.js aufbauen.

Unsere Hook-Funktion wird zwei Parameter annehmen:

  • key: Schlüssel, unter welchem ein Wert in LocalStorage gespeichert werden soll.
  • initialValue: Wert, welcher unter dem angegebenen Schlüssel gespeichert werden soll.

Die ausführliche Beschreibung der Funktionalität und der Bestandteile findet ihr weiter unten, nach dem Code.

TypeScript useLocalStorage.js
import { useRef, useState, useCallback } from 'react';

function useLocalStorage(key, initialValue) {

    // Prüfen, ob localStorage verfügbar
    const isLocalStorageAvailable = typeof window !== 'undefined' && window.localStorage;

    // Fallback Speicher (In-Memory)
    const memoryStorage = useRef({});

    // Lesen eines Wertes
    const getStorageItem = () => {
        if (isLocalStorageAvailable) {
            // Aus localStorage lesen
            try {
                const item = window.localStorage.getItem(key);
                return item ? JSON.parse(item) : initialValue;
            } catch (error) {
                console.log(`Fehler beim Lesen von localStorage von "${key}":`, error);
                return initialValue;
            }
        } else {
            // Aus dem Speicher lesen
            return memoryStorage.current[key] !== undefined ? memoryStorage.current[key] : initialValue;
        }
    };

    // Lazy State-Initialisierung
    const [storedValue, setStoredValue] = useState(getStorageItem);

    // Schreiben des Wertes
    const setValue = useCallback((value) => {
        try {
            const valueToStore = value instanceof Function ? value(storedValue) : value;
            setStoredValue(valueToStore);

            if (isLocalStorageAvailable) {
                window.localStorage.setItem(key, JSON.stringify(valueToStore));
                window.dispatchEvent(new CustomEvent('local-storage', {
                    detail: { key: key, newValue, valueToStore }
                }));
            } else {
                memoryStorage.current[key] = valueToStore;
            }
        } catch (error) {
            console.log(`Fehler beim Speichern in localStorage von "${key}":`, error);
        }
    }, [
        key,
        storedValue,
        isLocalStorageAvailable
    ]);

    // Löschen eines Wertes
    const removeValue = useCallback(() => {
        try {
            if (isLocalStorageAvailable) {
                window.localStorage.removeItem(key);
                window.dispatchEvent(
                    new CustomEvent(
                        'local-storage',
                        { detail: key: key, newValue: null }
                    )
                );
            } else {
                delete memoryStorage.current[key];
            }
            setStoredValue(initialValue);
        } catch (error) {
            console.log(`Fehler beim Löschen von localStorage von "${key}":`, error);
        }
    }, [
        key,
        initialValue,
        isLocalStorageAvailable
    ]);

    // Listener für native 'storage' Event und CustomEvent
    useEffect(() => {
        
        // Handler für navite 'storage' Events
        const handleNativeStorage = (event) => {
            if (event.storageArea === window.localStorage && event.key === key) {
                try {
                    const newValue = event.newValue ? JSON.parse(event.newValue) : null;
                    setStoredValue(newValue !== null ? newValue : initialValue);
                } catch (error) {
                    console.log('Fehler in handleNativeStorage', error);
                    setStoredValue(initialValue);
                }
            }
        };

        // Handler für CustomEvent
        const handleCustomEvent = (event) => {
            if (event.detail?.key === key) {
                setStoredValue(event.detail.newValue !== null ? event.detail.newValue : initialValue);
            }
        };

        if (isLocalStorageAvailable) {
            window.addEventListener('storage', handleNativeStorage);
            window.addEventListener('local-storage', handleCustomEvent);

            return () => {
                window.removeEventListener('storage', handleNativeStorage);
                window.removeEventListener('local-storage', handleCustomEvent);
            };
        }

    }, [
        key,
        initialValue,
        isLocalStorageAvailable
    ]);

    return [storedValue, setValue, removeValue];

}

export default useLocalStorage;

Damit haben wir unseren Custom Hook aufgebaut.

Detaillierte Beschreibung

Nun gehe ich auf einzelne Abschnitte ein, um die Funktion dieser etwas zu erklären.

const isLocalStorageAvailable = typeof window !== 'undefined' && window.localStorage;
  • Prüft, ob wir in einer Browser-Umgebung sind (window existiert)
  • Prüft, ob localStorage auf window existiert. Wichtig, weil z.B. Server-Side-Rendering (SSR) kein window hat
  • isLocalStorageAvailable ist ein Boolean, ob localStorage benutzt werden kann
const memoryStorage = useRef({});
  • Erzeugt eine Referenz mit useRef, die ein Objekt hält
  • Dieses Objekt ist ein einfacher, temporärer Speicher, wenn localStorage nicht verfügbar ist
  • useRef sorgt dafür, dass sich das Objekt über mehrere Render-Zyklen nicht verändert
  • Das memoryStorage ist nur im aktuellen Tab und während der Session gültig
const getStorageItem = () => {
    if (isLocalStorageAvailable) {
        // Aus localStorage lesen
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.log(`Fehler beim Lesen von localStorage von "${key}":`, error);
            return initialValue;
        }
    } else {
        // Aus dem Speicher lesen
        return memoryStorage.current[key] !== undefined ? memoryStorage.current[key] : initialValue;
    }
};
  • Wenn localStorage verfügbar ist:
    • Versuche den Wert zu lesen (getItem(key)), das gibt einen String oder null
    • Wenn es einen Wert gibt (item nicht null), parse ihn mit JSON.parse, damit auch Objekte oder Arrays erhalten bleiben
    • Falls kein Wert da ist (null), gib initialValue zurück
    • Sonst fange Fehler ab (z.B. ungültiges JSON) und gib dann initialValue zurück
  • Wenn localStorage nicht verfügbar ist:
    • Greife auf das In-Memory-Objekt memoryStorage.current zu
    • Wenn der Schlüssel dort existiert, gib den Wert zurück
    • Sonst gib initialValue zurück
const [storedValue, setStoredValue] = useState(getStorageItem);
  • Zustandswerte, um den aktuellen Wert zu halten
  • Die Funktion getStorageItem wird lazy nur beim ersten Render ausgeführt, um initialen Wert zu setzen
  • storedValue ist die lokale State-Variable
  • setStoredValue ist die Funktion zum Aktualisieren
const setValue = useCallback((value) => {
    try {
        const valueToStore = value instanceof Function ? value(storedValue) : value;
        setStoredValue(valueToStore);

        if (isLocalStorageAvailable) {
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
            window.dispatchEvent(new CustomEvent('local-storage', {
                detail: { key: key, newValue, valueToStore }
            }));
        } else {
            memoryStorage.current[key] = valueToStore;
        }
    } catch (error) {
        console.log(`Fehler beim Speichern in localStorage von "${key}":`, error);
    }
}, [
    key,
    storedValue,
    isLocalStorageAvailable
]);

Dies ist die Funktion, die verwendet wird, um einen Wert in localStorage zu speichern.

useCallback sorgt dafür, dass die Funktion nicht bei jedem Render neu erzeugt wird, sondern nur wenn sich key, storedValue oder isLocalStorageAvailable ändern.

  • value kann entweder ein Wert sein (z.B. "dark") oder eine Funktion, die den aktuellen Wert nimmt und einen neuen Wert zurückgibt (wie in useState üblich).
  • Falls value eine Funktion ist, wird sie mit dem aktuellen storedValue aufgerufen, um den neuen Wert zu berechnen
  • Dann wird setStoredValue ausgeführt, um den React State zu aktualisieren
  • Falls localStorage verfügbar ist:
    • Der neue Wert wird als JSON-String in localStorage gespeichert (setItem)
    • Ein CustomEvent local-storage wird ausgelöst, um andere Hook-Instanzen im gleichen Tab über die Änderung zu informieren
  • Wenn kein localStorage verfügbar ist:
    • Der Wert wird im In-Memory-Objekt gespeichert
const removeValue = useCallback(() => {
    try {
        if (isLocalStorageAvailable) {
            window.localStorage.removeItem(key);
            window.dispatchEvent(
                new CustomEvent(
                    'local-storage',
                    { detail: key: key, newValue: null }
                )
            );
        } else {
            delete memoryStorage.current[key];
        }
        setStoredValue(initialValue);
    } catch (error) {
        console.log(`Fehler beim Löschen von localStorage von "${key}":`, error);
    }
}, [
    key,
    initialValue,
    isLocalStorageAvailable
]);

Dies ist die Funktion, die einen gespeicherten Wert entfernt.

  • Falls localStorage vorhanden:
    • Entfernt den Eintrag via removeItem(key)
    • Sendet ebenfalls das local-storage Event mit newValue: null, damit alle Listener reagieren
  • Falls localStorage nicht vorhanden:
    • Löscht den Eintrag im Fallback-Speicher
  • Setzt State zurück auf initialValue
  • useCallback sorgt für stabile Funktion, die nur dann neu erzeugt wird, wenn key, initialValue oder isLocalStorageAvailable sich ändern

TodoComponent

Nun bauen wir das Todo-Component auf und werden dort unseren Custom Hook useLocalStorage verwenden. Die Todo-Elemente werden wir im localStorage-Speichern speichern.

Wir fügen ebenfalls Stile in diesem Component hinzu, damit wir ein wenig das Aussehen optimieren. Diese haben, wie immer, keinen Zusammenhang mit dem Thema an sich.

TypeScript ExampleLocalStorage.jsx
import { useState } from 'react';
import useLocalStorage from './useLocalStorage';

function ExampleLocalStorage() {

    // Verwendung - Custom Hook
    const [todos, setTodos] = useLocalStorage('todos', []);

    // Verwendung - State
    const [inputValue, setInputValue] = useState('');

    // Handler - Todo hinzufügen
    const handleAddTodo = () => {
        if (inputValue.trim()) {
            const newTodo = {
                id: Date.now(),
                text: inputValue,
                completed: false,
                createdAt: new Date().toISOString()
            };

            setTodos(prevTodos => [...prevTodos, newTodo]);
            setInputValue('');
        }
    };

    // Handler - Todo aktivieren/deaktivieren
    const handleToggleTodo = id => {
        setTodos(prevTodos => (prevTodos.map(todo => (
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        ))));
    };

    // Handler - Todo entfernen
    const handleDeleteTodo = id => {
        setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
    };

    const containerStyle = {
        maxWidth: '500px',
        margin: '20px auto',
        padding: 20,
        border: '2px solid #dddddd',
        borderRadius: 8
    };

    const inputContainerStyle = {
        display: 'flex',
        marginBottom: 20
    };

    const inputStyle = {
        flex: 1,
        padding: 10,
        fontSize: 16,
        border: '2px solid #cccccc',
        borderRadius: '4px 0 0 4px'
    };

    const addButtonStyle = {
        padding: '10px 20px',
        backgroundColor: '#28a745',
        color: '#ffffff',
        border: 'none',
        borderRadius: '0 4px 4px 0',
        cursor: 'pointer',
        fontSize: 16
    };

    const todoItemStyle = {
        display: 'flex',
        alignItems: 'center',
        padding: 10,
        borderBottom: '2px solid #eeeeee'
    };

    const todoTextStyle = (completed) => ({
        flex: 1,
        textDecoration: completed ? 'line-through' : 'none',
        opacity: completed ? 0.6 : 1,
        cursor: 'pointer'
    });

    const deleteButtonStyle = {
        padding: '5px 10px',
        backgroundColor: '#dc3545',
        color: '#ffffff',
        border: 'none',
        borderRadius: 4,
        cursor: 'pointer',
        fontSize: 12
    };

    return (
        <div style={containerStyle}>
            <div style={inputContainerStyle}>
                <input
                    type="text"
                    value={inputValue}
                    onChange={(e) => setInputValue(e.target.value)}
                    onKeyUp={(e) => e.key === 'Enter' && addTodo()}
                    style={inputStyle}
                    placeholder="Neue Aufgabe ..."
                />
                <button style={addButtonStyle} onClick={addTodo}>
                    Hinzufügen
                </button>
            </div>

            <div>
                {todos.length === 0 ? (
                    <p style={{ textAlign: 'center', color: '#666666' }}>
                        Keine Aufgaben vorhanden. Füge eine hinzu!
                    </p>
                ) : (
                    todos.map(todo => (
                        <div key={todo.id} style={todoItemStyle}>
                            <span
                                onClick={() => toggleTodo(todo.id)}
                                style={todoTextStyle(todo.completed)}
                            >
                                {todo.text}
                            </span>
                            <button
                                style={deleteButtonStyle}
                                onClick={() => deleteTodo(todo.id)}
                            >
                                Löschen
                            </button>
                        </div>
                    ))
                )}
            </div>

            {todos.length > 0 && (
                <p style={{ marginTop: 10, fontSize: 14, color: '#666666' }}>
                    {todos.filter(t => t.completed).length} von {todos.length} erledigt.
                </p>
            )}
        </div>
    );

}

export default ExampleLocalStorage;

Damit haben wir unser Component und unsere kleine Todo-App aufgebaut, in der wir unseren Custom Hook useLocalStorage verwenden.

Wenn wir in unserer Anwendung nun ein paar Aufgaben hinzufügen, bestimmte anklicken, um diese als erledigt zu markieren und einen Blick in den LocalStorage-Speicher werfen, werden wir feststellen, dass alle unseren erstellten Aufgaben dort zu finden sind. Auch, wenn wir die Seite vollständig neuladen, bleiben die Aufgaben erhalten und werden sofort wieder in die App hineingeladen.

React Custom Hook - Beispiel localStorage und Todo App

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht