navigation Navigation


Beispiel mit LocalStorage


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.

Inhaltsverzeichnis

    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.

    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.

    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