25 Custom Hooks – Wiederverwendbare State-Logik

React-Komponenten teilen sich oft ähnliche Probleme. Mehrere Formulare brauchen Input-Validierung. Verschiedene Seiten laden Daten von derselben API. Dashboard-Widgets reagieren auf Window-Resize-Events. Vor Hooks bedeutete Code-Wiederverwendung entweder Higher-Order Components mit ihrer Wrapper-Hell oder Render Props mit verschachtelten Callbacks. Custom Hooks lösen dieses Problem mit einer einfachen Idee: Logik ist eine Funktion, die andere Hooks verwendet.

25.1 Das Problem: Duplikation überall

Stellen wir uns ein Szenario vor, das in fast jeder Anwendung vorkommt. Drei verschiedene Komponenten benötigen Input-Felder mit State-Management. Die naive Lösung dupliziert denselben Code dreimal.

function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  
  return (
    <>
      <input 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
      />
      <input 
        value={password} 
        onChange={(e) => setPassword(e.target.value)} 
        type="password"
      />
    </>
  );
}

function RegistrationForm() {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  
  // ... dieselben Input-Handler wieder und wieder
}

function ProfileEditor() {
  const [displayName, setDisplayName] = useState("");
  const [bio, setBio] = useState("");
  
  // ... und wieder
}

Das Pattern ist offensichtlich: State-Variable, Setter-Funktion, Change-Handler. Diese drei Zeilen wiederholen sich in jeder Komponente, die ein Formularfeld verwaltet. Ein Custom Hook extrahiert dieses Pattern.

function useInput(initialValue: string = "") {
  const [value, setValue] = useState(initialValue);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };
  
  return {
    value,
    onChange: handleChange
  };
}

// Verwendung: Drastisch reduzierter Boilerplate
function LoginForm() {
  const email = useInput("");
  const password = useInput("");
  
  return (
    <>
      <input {...email} type="email" />
      <input {...password} type="password" />
    </>
  );
}

Der Spread-Operator ({...email}) überträgt value und onChange direkt an das Input-Element. Drei Zeilen pro Feld werden zu einer. Wichtiger noch: Die Logik ist jetzt an einem Ort definiert. Änderungen – etwa zusätzliche Validierung oder Debouncing – müssen nur einmal implementiert werden.

25.2 Die Hook-Konvention: Mehr als nur ein Name

Custom Hooks beginnen immer mit use. Das ist keine kosmetische Konvention, sondern ein Signal an React und seine Tooling-Infrastruktur. ESLint mit dem React Hooks Plugin erkennt an diesem Präfix, dass Hook-Regeln gelten. React selbst nutzt es, um die interne Zuordnung von Hook-Aufrufen zu verwalten.

// ✓ React erkennt dies als Hook
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  // ...
  return size;
}

// ❌ React behandelt dies als normale Funktion
function windowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });  // Fehler!
  // ...
}

Die Namenskonvention ermöglicht es auch den Development Tools, Hook-Aufrufe zu verfolgen. React DevTools zeigen Custom Hooks in der Komponenten-Hierarchie an und machen den State-Flow transparent.

25.3 Regel Nummer 1: Isolation, keine gemeinsame Instanz

Ein fundamentales Missverständnis: Custom Hooks teilen keine State-Instanzen zwischen Komponenten. Jeder Aufruf eines Custom Hooks erzeugt einen völlig isolierten State.

function useCounter(initial: number = 0) {
  const [count, setCount] = useState(initial);
  
  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1)
  };
}

function ComponentA() {
  const counter = useCounter(0);
  // counter.count ist unabhängig von ComponentB
  return <div>{counter.count}</div>;
}

function ComponentB() {
  const counter = useCounter(10);
  // Völlig separater State, andere Initialwert
  return <div>{counter.count}</div>;
}

Jede Komponente erhält ihre eigene useState-Instanz. Das Inkrement in ComponentA ändert nichts in ComponentB. Custom Hooks kapseln Logik, nicht State-Instanzen. Für geteilten State zwischen Komponenten bleibt Context oder externe State-Management-Lösungen der richtige Ansatz.

25.4 LocalStorage: Ein praktisches Beispiel

Browser-Storage ist ein Klassiker für Custom Hooks. Viele Komponenten müssen Daten persistent speichern – Theme-Präferenzen, Formular-Drafts, Sidebar-Zustände. Die Logik wiederholt sich: Lesen bei Mount, Schreiben bei Änderung.

function useLocalStorage<T>(key: string, initialValue: T) {
  // Lazy Initialization: localStorage nur einmal lesen
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  // Wrapper-Funktion: Schreibt in State UND localStorage
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function 
        ? value(storedValue) 
        : value;
      
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };
  
  return [storedValue, setValue] as const;
}

// Verwendung: Identisch zu useState, aber persistent
function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

Die as const Assertion am Ende ist ein TypeScript-Detail, das präzise Typen erzeugt. Ohne sie würde TypeScript den Rückgabetyp als (T | ((value: T | ((val: T) => T)) => void))[] inferieren – wenig hilfreich. Mit as const wird es ein Tupel: readonly [T, (value: T | ((val: T) => T)) => void], genau wie useState.

Lazy Initialization ist hier kritisch. Die Callback-Variante von useState stellt sicher, dass localStorage.getItem() nur beim ersten Render ausgeführt wird, nicht bei jedem Re-Render.

25.5 Data Fetching: Vom naiven Ansatz zur robusten Lösung

API-Calls sind ein weiterer Bereich, wo Custom Hooks glänzen. Eine typische Komponente, die Daten lädt, braucht: Loading-State, Error-Handling, die eigentlichen Daten. Ohne Custom Hook sieht das in jeder Komponente gleich aus.

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    let cancelled = false;
    
    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        const json = await response.json();
        
        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err : new Error(String(err)));
          setData(null);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }
    
    fetchData();
    
    return () => {
      cancelled = true;
    };
  }, [url]);
  
  return { data, loading, error };
}

// Verwendung: Komponente fokussiert auf Rendering, nicht auf Fetch-Logik
interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: number }) {
  const { data, loading, error } = useFetch<User>(
    `https://api.example.com/users/${userId}`
  );
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return null;
  
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  );
}

Die cancelled-Flag ist essentiell. Wenn die Komponente unmountet wird, während der Fetch noch läuft, würde ohne diese Absicherung ein State-Update auf einer nicht mehr existierenden Komponente versucht werden. React warnt in diesem Fall: “Can’t perform a React state update on an unmounted component”. Die Cleanup-Funktion setzt cancelled = true, und alle State-Updates werden übersprungen.

Pattern Zweck TypeScript-Typ
useState<T \| null> Daten noch nicht geladen Expliziter Null-Zustand
as const bei Tupel-Returns Präzise Array-Typen Verhindert Union-Type-Arrays
Generischer Hook <T> Flexibilität für verschiedene APIs Type-Safety beim Aufruf
Cleanup-Flag Race Conditions vermeiden Boolean-Flag im Closure

25.6 Window Events: Reactive Browser-APIs

Browser-Events außerhalb von React zu handhaben, war immer umständlich. Ein Custom Hook macht reaktive Browser-APIs einfach.

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }
    
    window.addEventListener('resize', handleResize);
    
    // Cleanup: Event-Listener entfernen
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return size;
}

// Responsive Komponente ohne Media Queries
function ResponsiveGrid() {
  const { width } = useWindowSize();
  const columns = width < 768 ? 1 : width < 1024 ? 2 : 3;
  
  return (
    <div style={{ 
      display: 'grid', 
      gridTemplateColumns: `repeat(${columns}, 1fr)` 
    }}>
      {/* Grid Items */}
    </div>
  );
}

Wichtig: Die leere Dependency-Array [] bei useEffect stellt sicher, dass Event-Listener nur einmal registriert werden. Ohne diese würde bei jedem Re-Render ein neuer Listener hinzugefügt – und die alten würden bleiben. Memory Leak garantiert.

25.7 Hook-Komposition: Bausteine zusammensetzen

Custom Hooks können andere Custom Hooks verwenden. Diese Komposition ermöglicht modulare, wiederverwendbare Bausteine.

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

function useSearchQuery(initialQuery: string = "") {
  const [query, setQuery] = useState(initialQuery);
  const debouncedQuery = useDebounce(query, 500);
  
  return {
    query,
    setQuery,
    debouncedQuery  // Nur dieser Wert triggert API-Calls
  };
}

// Verwendung: Suchfeld mit automatischer Verzögerung
function SearchBox() {
  const { query, setQuery, debouncedQuery } = useSearchQuery();
  const { data, loading } = useFetch<SearchResult[]>(
    `https://api.example.com/search?q=${debouncedQuery}`
  );
  
  return (
    <>
      <input 
        value={query} 
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {loading && <span>Searching...</span>}
      {data && <ResultsList results={data} />}
    </>
  );
}

useSearchQuery kombiniert lokalen State mit Debouncing. useDebounce ist wiederverwendbar für beliebige Werte. Die Komponente SearchBox nutzt beide, ohne die Implementation-Details zu kennen. Jeder Hook hat eine klare Verantwortung, zusammen bilden sie ein leistungsfähiges System.

25.8 TypeScript: Generics und Präzise Typen

Custom Hooks profitieren massiv von TypeScript. Generische Typen machen Hooks flexibel, während Type Inference die Verwendung einfach hält.

// Generischer Hook mit Type Constraints
function useArray<T>(initialArray: T[] = []) {
  const [array, setArray] = useState<T[]>(initialArray);
  
  const push = (element: T) => {
    setArray(current => [...current, element]);
  };
  
  const remove = (index: number) => {
    setArray(current => current.filter((_, i) => i !== index));
  };
  
  const clear = () => {
    setArray([]);
  };
  
  return {
    array,
    set: setArray,
    push,
    remove,
    clear
  };
}

// Type Inference funktioniert automatisch
function TodoList() {
  const todos = useArray<string>([]);  // Explizit
  const numbers = useArray([1, 2, 3]);  // Inferiert: number[]
  
  todos.push("New todo");  // ✓ Type-safe
  todos.push(42);          // ❌ Type error
}

Für komplexere Rückgabewerte sollte ein explizites Interface den Return-Type definieren. Das macht die API selbstdokumentierend.

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  // Implementation...
  
  const refetch = () => {
    // Re-trigger fetch
  };
  
  return { data, loading, error, refetch };
}

IDE-Autocomplete zeigt dann alle verfügbaren Felder und ihre Typen. Nutzer des Hooks müssen nicht die Implementation lesen, um zu verstehen, was zurückgegeben wird.

25.9 Performance: Stabile Referenzen

Ein häufiger Fehler: Funktionen, die bei jedem Render neu erstellt werden, aus dem Hook zurückgeben. Das führt zu unnötigen Re-Renders in Child-Komponenten.

// ❌ Problematisch: Neue Funktion-Referenz bei jedem Render
function useCounter() {
  const [count, setCount] = useState(0);
  
  return {
    count,
    increment: () => setCount(c => c + 1),  // Neue Referenz!
    decrement: () => setCount(c => c - 1)   // Neue Referenz!
  };
}

// ✓ Richtig: Stabile Referenzen mit useCallback
function useCounter() {
  const [count, setCount] = useState(0);
  
  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);
  
  const decrement = useCallback(() => {
    setCount(c => c - 1);
  }, []);
  
  return { count, increment, decrement };
}

Wenn eine Child-Komponente increment in ihre Dependencies aufnimmt (z.B. in useEffect), würde ohne useCallback jedes Parent-Re-Render den Effect neu triggern. Mit useCallback bleibt die Referenz stabil.

Dasselbe gilt für Objekte. Der Hook sollte kein neues Objekt bei jedem Render zurückgeben, es sei denn, sich tatsächlich etwas geändert hat.

// ❌ Neues Objekt bei jedem Render
function useUser(id: number) {
  const [user, setUser] = useState<User | null>(null);
  
  return {  // Immer neue Referenz!
    user,
    isLoading: user === null
  };
}

// ✓ Nur neue Referenz wenn user sich ändert
function useUser(id: number) {
  const [user, setUser] = useState<User | null>(null);
  
  return useMemo(() => ({
    user,
    isLoading: user === null
  }), [user]);
}

25.10 Häufige Fehlerquellen

Bedingte Hook-Aufrufe verletzen die Hook-Regeln, auch in Custom Hooks.

// ❌ Falsch: Hook in Bedingung
function useConditionalData(shouldFetch: boolean) {
  if (shouldFetch) {
    const data = useFetch('/api/data');  // Regel-Verstoß!
    return data;
  }
  return null;
}

// ✓ Richtig: Bedingung innerhalb des Hooks
function useConditionalData(shouldFetch: boolean) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    if (!shouldFetch) return;
    
    // Fetch-Logik hier
  }, [shouldFetch]);
  
  return data;
}

Vergessene Cleanups führen zu Memory Leaks. Jeder registrierte Listener, jeder Timer, jedes Subscription muss aufgeräumt werden.

// ❌ Memory Leak: Listener wird nie entfernt
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    function handleMove(e: MouseEvent) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    
    window.addEventListener('mousemove', handleMove);
    // Cleanup fehlt!
  }, []);
  
  return position;
}

// ✓ Korrekt: Cleanup-Funktion
function useMousePosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    function handleMove(e: MouseEvent) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    
    window.addEventListener('mousemove', handleMove);
    
    return () => {
      window.removeEventListener('mousemove', handleMove);
    };
  }, []);
  
  return position;
}

Über-Abstraktion ist ebenfalls ein Antipattern. Nicht jede Logik braucht einen Custom Hook. Wenn Code nur in einer Komponente verwendet wird und einfach ist, gehört er dort hinein.

// ❌ Unnötiger Custom Hook
function useButtonText(isLoading: boolean) {
  return isLoading ? "Loading..." : "Submit";
}

// ✓ Direkt in der Komponente
function Form() {
  const [isLoading, setIsLoading] = useState(false);
  const buttonText = isLoading ? "Loading..." : "Submit";
  
  return <button>{buttonText}</button>;
}

Custom Hooks sollten echte Wiederverwendung ermöglichen oder komplexe Logik kapseln. Ein Hook, der nur eine Zeile Logic enthält, ist meistens Overhead ohne Mehrwert.