Custom Hook in React: Logik richtig wiederverwenden

1. Überblick

Wenn mehrere Komponenten in deiner React-Anwendung die gleiche Logik benötigen — API-Aufrufe, localStorage lesen, Debouncing von Input — verfällst du leicht in Copy-Paste-Fallen mit State und useEffect. Dieser Artikel zeigt dir, wie du diese Logik in einen Custom Hook packst: eine Funktion, die mit use beginnt und bereits existierende Hooks (useState, useEffect, useRef…) nutzt, um sie wiederzuverwenden. Nach dem Lesen kannst du useFetch, useLocalStorage, useDebounce schreiben und sie mit renderHook testen.

2. Problem: Logik, die sich wiederholt

Angenommen, du hast zwei Komponenten, die beide eine API aufrufen und loading/error verwalten müssen:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...render
}

CustomerList und OrderList enthalten fast die gleiche Logik, unterscheiden sich nur in der URL. Wenn du später Retry oder Request-Abbruch hinzufügen möchtest, musst du überall ändern — das ist ein klares Zeichen, dass du einen Custom Hook brauchst.

3. Was ist ein Custom Hook?

Ein Custom Hook ist eine normale JavaScript-Funktion, deren Name mit use beginnt, die andere Hooks von React aufruft. Er gibt kein JSX zurück — er gibt State und Funktionen zurück, ähnlich wie ein kleiner „Store” gebunden an die Komponente, die ihn aufruft.

Hinweis: Jedes Mal wenn eine Komponente einen Custom Hook aufruft, erstellt React eine separate State-Instanz. Zwei Komponenten, die useFetch verwenden, teilen sich den State nicht — sie teilen sich nur die Logik.

4. Den ersten Custom Hook schreiben: useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

Erklärung:

  • cancelled verhindert Race Conditions: Falls sich url ändert bevor die alte Request fertig ist, wird das alte Ergebnis nicht mehr in den State geschrieben.
  • Die Cleanup-Funktion (return () => { cancelled = true }) wird ausgeführt, wenn die Komponente unmountet oder sich url ändert — genau im Lebenszyklus von useEffect.
  • Der Hook gibt ein Object { data, loading, error } zurück, damit es leicht zu destructurieren ist.

Die Komponente wird jetzt kurz:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>Wird geladen...</p>;
  if (error) return <p>Fehler: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // Render-Logik ähnlich, keine duplizierte Fetch-Logik mehr
}

5. Zweites Beispiel: useLocalStorage

Die Logik zum Synchronisieren von State mit localStorage wird auch oft kopiert:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

Erklärung:

  • useState(() => ...) nutzt einen Lazy Initializer — die Funktion wird nur einmal beim Mount ausgeführt, localStorage wird nicht bei jedem Render gelesen.
  • Der Hook gibt ein Tuple [value, setValue] zurück, genau wie useState, damit es dem Nutzer sofort vertraut ist.

In der Komponente verwendet:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Aktuelles Theme: {theme}
    </button>
  );
}

5.1. Wenn der State vom vorherigen Wert abhängt

Wenn du basierend auf dem alten Wert aktualisieren möchtest (z. B. Counter), nutze Functional Updates um Stale Closures zu vermeiden:

setValue((prev) => prev + 1); // sicherer als setValue(value + 1)

6. Drittes Beispiel: useDebounce

Debouncing von Such-Input ist ein sehr häufiges Problem:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

Erklärung:

  • Jedes Mal wenn value sich ändert, wird ein neuer setTimeout erstellt; der alte setTimeout wird in der Cleanup mit clearTimeout gelöscht.
  • Nur wenn der Nutzer für delayMs aufhört zu tippen, wird debounced aktualisiert.
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. Namenskonventionen und Rules of Hooks

Custom Hooks müssen die Rules of Hooks wie Built-in-Hooks befolgen:

  1. Name muss mit use beginnen — das ESLint Plugin eslint-plugin-react-hooks verlässt sich auf dieses Präfix, um zu überprüfen, ob die Regel eingehalten wird. Ohne use wird es nicht als Hook erkannt.
  2. Nur Top-Level aufrufen, nicht innerhalb von if, for oder Callbacks.
  3. Nur in Function Components oder anderen Custom Hooks aufrufen, nicht in normalen Funktionen.
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Aktiviere exhaustive-deps, um nicht zu vergessen, Dependencies in useEffect des Custom Hooks anzugeben — die häufigste Ursache für „Stale Data”-Bugs.

8. Custom Hook testen

Da Custom Hooks kein JSX zurückgeben, kannst du sie nicht wie Komponenten mit render testen. Die Bibliothek @testing-library/react bietet renderHook, um den Hook in einer Test-Umgebung aufzurufen.

<!-- Nicht anwendbar für React-Projekte; Abhängigkeit wird über npm installiert, siehe unten -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('aktualisiert den Wert erst nach der Verzögerung', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // noch nicht geändert

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce fertig
    vi.useRealTimers();
  });
});

Erklärung:

  • renderHook nimmt eine Funktion entgegen, die den Hook aufruft, und gibt result.current zurück — der Rückgabewert des Hooks beim aktuellen Render.
  • rerender simuliert eine Komponenten-Re-Render mit neuen Props (wie wenn sich value ändert, weil der Nutzer tippt).
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) lässt dich die Zeit kontrollieren, ohne darauf zu warten — Tests sind schnell und stabil.
✓ useDebounce > aktualisiert den Wert erst nach der Verzögerung

9. Häufige Fehler und wann man Custom Hooks nicht verwenden sollte

Häufige Fehler:

  • Cleanup in useEffect vergessen: Bei useFetch kann eine alte Request später zurückkommen und neue Daten überschreiben — zeigt die falschen Daten an, falls du die cancelled-Flag nicht hast.
  • Fehlende Dependencies: Falls useEffect im Hook externe Variablen nutzt, aber diese nicht im Dependency Array deklariert sind, wird der Hook einen veralteten Wert verwenden (Stale Closure).
  • Unnötiges neues Object bei jedem Render zurückgeben: Wenn der Hook { data, loading, error } als Object Literal bei jedem Render zurückgibt, könnte eine Komponente mit React.memo unnötig re-rendern, weil die Referenz sich ändert. Du kannst useMemo nutzen, wenn du das Problem wirklich gemessen hast.

Wann du KEINEN Custom Hook schreiben solltest:

  • Logik, die nur an einer Stelle verwendet wird und keine Chance auf Wiederverwendung hat — Extraktion macht Code nur schwerer zu folgen (musst in andere Datei springen, um zu verstehen).
  • Logik, die reine Berechnung ist (Pure Function), hat nichts mit State/Effect zu tun — schreib eine normale JS-Funktion, keinen Hook.
  • Wenn du State (nicht nur Logik) zwischen mehreren Komponenten an verschiedenen Stellen im Tree teilen möchtest — Custom Hooks können das nicht, du brauchst Context oder State Management Library (Zustand, Redux…).

Hinweis: Custom Hooks lösen das Problem duplizierter Logik, nicht das Problem Teilen von State zwischen Komponenten. Verwechsle diese beiden Aufgaben nicht.

10. Fazit

Custom Hooks sind ein Werkzeug zum Extrahieren von stateful Logik aus Komponenten, macht Code kurz, einfach zu testen, leicht wiederzuverwenden. Du musst nur beachten: Name beginnt mit use, halte dich an die Rules of Hooks, und teste mit renderHook statt render. Mit useFetch, useLocalStorage, useDebounce oben hast du ein Grundgerüst um weitere Hooks wie useMediaQuery, useForm im Geiste „schreib einmal, nutze überall” zu schreiben.

reactcustom-hookjavascriptfrontendtesting