Custom Hooks sind ganz normale JavaScript-Funktionen, deren Name mit use beginnt und die intern andere Hooks aufrufen. Sie sind das idiomatische React-Pattern, um stateful Logik zwischen Komponenten zu teilen — ohne die Komponentenhierarchie zu verändern, ohne Wrapper-Hell (wie bei HOCs) und ohne Render-Prop-Verschachtelung. Eingeführt mit React 16.8 (Hooks Release, Februar 2019), haben sie HOCs und Render Props in den meisten Codebases vollständig abgelöst. Dieser Artikel klärt das mentale Modell (jeder Hook-Aufruf hat eigenen isolierten State pro Komponenten-Instanz), zeigt die Hook-Regeln, baut ein konkretes Beispiel von Grund auf und macht deutlich, was Custom Hooks sind — und was sie nicht sind.

Was ist ein Custom Hook — und was nicht?

Ein Custom Hook ist eine Funktion, die folgende Bedingungen erfüllt:

  1. Der Name beginnt mit use — z.B. useCounter, useUser, useDebounce. Das ist nicht nur Stil-Konvention, sondern auch ein technisches Signal an den Linter eslint-plugin-react-hooks: nur Funktionen mit use-Prefix werden auf Hook-Regeln geprüft, und nur sie dürfen andere Hooks aufrufen.
  2. Sie ruft mindestens einen anderen Hook aufuseState, useEffect, useRef oder einen anderen Custom Hook. Eine Funktion ohne Hook-Aufruf ist keine Custom Hook, sondern eine ganz normale Utility-Funktion.
  3. Sie wird aus einer Komponente oder einem anderen Hook aufgerufen — niemals aus Event-Handlern, asynchronen Callbacks oder normalen Funktionen.

Was Custom Hooks NICHT sind:

  • Sie sind kein Mechanismus, um Komponenten zu rendern — sie geben Werte zurück, kein JSX. Daher endet eine Custom-Hook-Datei auch konventionell auf .js/.ts, nicht .jsx/.tsx.
  • Sie sind kein Singleton und kein globaler State. Jeder useCounter-Aufruf erzeugt eine eigene, isolierte State-Instanz. Wer global geteilten State braucht, kombiniert Custom Hooks mit Context oder einem externen Store.
  • Sie sind keine HOC-Wrapper und keine Render Props — sie vermeiden gerade die Probleme dieser älteren Patterns (Wrapper-Hell, Prop-Collisions, ungenauer this-Kontext).

Mentales Modell: Pro Aufruf eine eigene Welt

Der wichtigste Punkt, der bei Custom Hooks oft falsch verstanden wird: jeder Aufruf eines Custom Hooks erzeugt eine eigene, isolierte State-Instanz. Zwei Komponenten, die denselben Hook aufrufen, teilen sich nichts — sie haben jeweils ihren eigenen State, ihren eigenen Effect-Lifecycle, ihre eigenen Refs.

TypeScript ZweiInstanzen.jsx
function ProductPage() {
    const [count, inc] = useCounter(0);   // eigene State-Instanz #1
    return <button onClick={inc}>Likes: {count}</button>;
}

function CommentBox() {
    const [count, inc] = useCounter(0);   // eigene State-Instanz #2
    return <button onClick={inc}>Replies: {count}</button>;
}

Wenn ProductPage und CommentBox gleichzeitig im UI sind, klickt man auf den Like-Button — die Replies-Anzeige bleibt unverändert. Der useCounter ist nicht „der gemeinsame Zähler", sondern eine Schablone, aus der bei jedem Aufruf ein neuer Zähler entsteht.

Genau dieses Modell macht Custom Hooks so kompositionsfreundlich. Wer geteilten State will, muss ihn bewusst hochlevel halten — typischerweise im gemeinsamen Eltern (State-Lifting) oder in Context.

Vorteile, die sich aus diesem Modell ergeben:

  • Wiederverwendbarkeit ohne Abhängigkeit. Beliebig viele Komponenten nutzen denselben Hook, ohne sich gegenseitig zu beeinflussen.
  • Trennung von Logik und Darstellung. Der Hook regelt das Verhalten (z.B. „wie ein Zähler funktioniert"), die Komponente kümmert sich nur ums Rendern.
  • Testbarkeit. Mit @testing-library/react's renderHook lässt sich der Hook isoliert testen, ohne UI-Setup.
  • Lesbarkeit. Die Komponente bleibt fokussiert auf das, was sie zeigt — die Hooks tragen das „wie".

Die Hook-Regeln gelten auch für Custom Hooks

Custom Hooks sind keine Ausnahme von den Hook-Regeln. Beide Regeln gelten in voller Strenge:

Regel 1: Nur auf der Top-Ebene aufrufen — nie in if, for, while, try/catch, nicht nach einem Early Return. Hooks (sowohl eingebaute als auch Custom) müssen bei jedem Render in derselben Reihenfolge und Anzahl ausgeführt werden, damit React sie intern zuverlässig zuordnen kann.

Regel 2: Nur in Function-Components oder anderen Custom Hooks aufrufen — nicht in Event-Handlern, normalen Funktionen, Klassen oder asynchronen Callbacks.

TypeScript RegelnGeltenAuch.jsx
// FALSCH — bedingter Aufruf
function Profile({ userId }) {
    if (userId) {
        const user = useUser(userId);   // HOOK-REGEL VERLETZT
    }
    return null;
}

// RICHTIG — Hook immer aufrufen, conditional Logik im Render
function Profile({ userId }) {
    const user = useUser(userId);   // immer aufrufen
    if (!userId) return null;
    return <p>{user?.name}</p>;
}

// FALSCH — Hook in normaler Funktion
function getDisplayName(userId) {
    const user = useUser(userId);   // HOOK-REGEL VERLETZT
    return user?.name;
}

// RICHTIG — als Custom Hook umbenennen
function useDisplayName(userId) {
    const user = useUser(userId);
    return user?.name;
}

Die Konvention „Name beginnt mit use" ist genau deshalb wichtig: der Linter erkennt am Prefix, dass diese Funktion Hook-Aufrufe enthalten darf — und wendet die Regeln darauf an. Ohne den Prefix würde der Linter die Funktion als gewöhnlich behandeln und Hook-Aufrufe als Verstoß markieren.

Beispiel: useCounter Schritt für Schritt

Wir bauen einen Custom Hook für einen konfigurierbaren Zähler. Das Beispiel ist absichtlich klein — der Wert liegt im schrittweisen Aufbau, der das Pattern verinnerlicht.

Schritt 1 — Logik in einer Komponente

TypeScript VorDemHook.jsx
// Counter-Logik in der Komponente eingebaut
function Counter() {
    const [count, setCount] = useState(0);

    const increment = () => setCount(c => c + 1);
    const decrement = () => setCount(c => c - 1);
    const reset = () => setCount(0);

    return (
        <>
            <p>Wert: {count}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>−</button>
            <button onClick={reset}>Reset</button>
        </>
    );
}

Funktioniert, aber: sobald eine zweite Komponente dasselbe Verhalten will, müssen wir den gesamten Block kopieren. Drei oder vier Stellen später ist die Logik dupliziert und Bug-anfällig.

Schritt 2 — Logik in einen Custom Hook auslagern

Wir extrahieren useState und die drei Aktionsfunktionen in eine eigene Funktion. Der Name beginnt mit use, damit der Linter die Regel-Prüfung übernimmt. Die Komponente bekommt nur noch Werte und Funktionen zurück — kein JSX.

TypeScript useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
    const [count, setCount] = useState(initialValue);

    const increment = () => setCount(c => c + 1);
    const decrement = () => setCount(c => c - 1);
    const reset = () => setCount(initialValue);

    return { count, increment, decrement, reset };
}

Drei Designentscheidungen, die es wert sind, bewusst getroffen zu werden:

  • Rückgabe als Objekt statt Tupel. Bei useState ist das Tupel ([value, setter]) idiomatisch, weil es genau zwei Werte hat und Destructuring frei umbenennen lässt. Bei einem Custom Hook mit mehreren benannten Werten ist ein Objekt expliziter — die Konsumenten sehen, was sie destructuren.
  • initialValue mit Default 0. Macht den Hook „ready-to-use", verlangt aber keine Konfiguration. Wer einen anderen Startwert braucht, übergibt ihn.
  • Funktionale Setter (c => c + 1). Selbst wenn die Aktion gerade nur einmal aufgerufen wird: der funktionale Setter ist robust gegen Closure-Fallen und schadet nie.

Schritt 3 — Hook nutzen

TypeScript App.jsx
import { useCounter } from './useCounter';

function App() {
    const likes = useCounter(0);
    const dislikes = useCounter(0);

    return (
        <>
            <section>
                <h3>Likes: {likes.count}</h3>
                <button onClick={likes.increment}>+</button>
                <button onClick={likes.decrement}>−</button>
                <button onClick={likes.reset}>Reset</button>
            </section>
            <section>
                <h3>Dislikes: {dislikes.count}</h3>
                <button onClick={dislikes.increment}>+</button>
                <button onClick={dislikes.decrement}>−</button>
            </section>
        </>
    );
}

likes und dislikes sind vollständig getrennt. Ein Klick auf den Likes-Reset setzt nur die Likes zurück — die Dislikes-Zähler-Instanz wird gar nicht berührt. Das ist das mentale Modell „pro Aufruf eine eigene Welt" in der Praxis.

React Custom Hook - Beispiel 1

Schritt 4 — Hook erweitern: konfigurierbare Schrittweite

Häufige Anforderung: nicht immer plus/minus 1, sondern eine konfigurierbare Schrittweite (z.B. 5er-Schritte für Lautstärke-Regler).

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

export function useCounter(initialValue = 0, step = 1) {
    const [count, setCount] = useState(initialValue);

    const increment = useCallback(() => setCount(c => c + step), [step]);
    const decrement = useCallback(() => setCount(c => c - step), [step]);
    const reset = useCallback(() => setCount(initialValue), [initialValue]);

    return { count, increment, decrement, reset };
}

Warum hier jetzt useCallback? Sobald der Hook Parameter hat, die sich zwischen Renders ändern können (step, initialValue), kann es Sinn machen, die zurückgegebenen Funktionen mit useCallback zu stabilisieren. Konsumenten, die increment an React.memo-Kinder weitergeben oder als useEffect-Dependency benutzen, profitieren davon. Bei einem kleinen Counter ist es oft Overkill — bei einem ausgereiften, wiederverwendbaren Hook gehört es zur guten Praxis.

Hooks aus Hooks: Komposition

Custom Hooks können andere Custom Hooks aufrufen — daraus entstehen kleine Domain-Layer. Ein useUser ruft intern useFetch; ein useShoppingCart kombiniert useLocalStorage und useReducer; ein useDebouncedSearch kombiniert useDebounce und useEffect.

TypeScript HooksAusHooks.jsx
function useDebouncedSearch(query, delay = 300) {
    const debouncedQuery = useDebounce(query, delay);
    const { data, isLoading } = useFetch(
        debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
    );
    return { results: data, isLoading };
}

Drei Custom Hooks (useDebounce, useFetch, intern useState/useEffect) und das Resultat ist eine einsatzbereite Such-Logik mit Auto-Throttling. Details zu diesem Pattern stehen im eigenen Artikel zur Hook-Komposition.

Wann lohnt sich ein Custom Hook?

Faustregeln, ab wann das Extrahieren in einen Custom Hook sinnvoll ist:

  • Dieselbe Hook-Sequenz erscheint in 2+ Komponenten. Wenn drei Komponenten dasselbe useState + useEffect-Pattern haben, gehört es in einen Hook.
  • Die Logik hat einen klaren Namen. Wenn man dem Verhalten einen sprechenden Namen geben kann (useFormValidation, useOnlineStatus, useScrollPosition), ist die Trennung naheliegend.
  • Die Komponente wird zu lang. Wenn der Hook-Block am Anfang einer Komponente mehr als 15-20 Zeilen einnimmt, hilft das Auslagern für Lesbarkeit — auch ohne Wiederverwendung.
  • Eine Komponente kümmert sich um zu viele Belange. Wenn Profile gleichzeitig Auth-State, Fetch-State, Form-State und URL-Sync regelt, lassen sich diese Aspekte je in einen eigenen Hook ziehen.

Wann lohnt sich KEIN Custom Hook:

  • Wenn die Logik nur einmal verwendet wird und überschaubar bleibt — das ist „premature abstraction".
  • Wenn der Hook keine eigenen Hooks aufruft — dann ist es eine Utility-Funktion, kein Hook.
  • Wenn der Hook trivial ist (z.B. useDouble = (x) => x * 2) — eine reine Funktion ohne Hook-Aufruf gehört nicht in den Custom-Hook-Stil.

Interessantes

use-Prefix ist nicht nur Konvention — der Linter prüft danach.

eslint-plugin-react-hooks erkennt am Prefix, dass die Funktion Hooks aufruft und wendet die Hook-Regeln an. Eine Funktion ohne use-Prefix, die useState aufruft, wird vom Linter als Hook-Verletzung markiert — auch wenn sie aus einer Komponente aufgerufen wird.

Jeder Hook-Aufruf erzeugt eine eigene, isolierte Instanz.

Zwei Komponenten, die useCounter() aufrufen, haben jeweils ihren eigenen State. Custom Hooks sind keine Singletons. Geteilter State braucht Context oder externen Store.

Rückgabe-Form: Tupel bei zwei Werten (wie useState), Object bei mehreren.

useState liefert ein Tupel — passt für genau zwei Werte. Bei mehreren benannten Werten ist ein Object expliziter: const { count, inc, dec } = useCounter(). Konsumenten sehen sofort, was verfügbar ist.

Custom Hooks sind die moderne Antwort auf HOCs und Render Props.

Vor Hooks (Pre-2019) waren HOCs (withUser, connect) und Render Props die Standard-Mechanismen, um Logik zu teilen. Beide hatten Probleme: Wrapper-Hell, Prop-Collisions, unklarer Daten-Fluss. Custom Hooks lösen das mit einer einzigen Funktion ohne Komponenten-Verschachtelung.

Hook-Regeln gelten auch für Custom Hooks — keine Ausnahmen.

Top-Level-Aufruf, immer in derselben Reihenfolge, nur in Components/Custom-Hooks. Wer im Inneren eines Custom Hooks if (x) useState(...) macht, verletzt die Regeln — egal wie clever die Bedingung wirkt.

Custom Hooks geben Werte zurück — KEIN JSX.

Wer JSX zurückgeben will, schreibt eine Komponente. Custom Hooks sind nur für Logik. Daher die Datei-Endung .js/.ts statt .jsx/.tsx. Wer doch JSX zurückgibt, hat einen Namens-Konflikt mit Komponenten (Großbuchstabe vs. use-Prefix) — Linter würde das fangen.

Testen mit renderHook aus `@testing-library/react`.

const { result } = renderHook(() => useCounter(5)) erzeugt eine Test-Komponente, ruft den Hook auf und gibt das Resultat in result.current zurück. Aktionen testen mit act(() => result.current.increment()). Isoliertes Testen ohne UI-Setup.

Datei-Organisation: ein Hook pro Datei, gleicher Name wie der Hook.

useCounter in useCounter.ts, exportiert als named export. Bei größeren Codebases einen hooks/-Ordner mit Index-Datei für Sammel-Imports. default export auch möglich, named export ist heute idiomatischer (besseres Refactoring-Verhalten).

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht