Von useState über useReducer bis zum eigenen Custom Hook
React Hooks funktionieren auch ohne explizite Typen, meistens jedoch nur zufällig richtig. Sobald ein Zustand mehrere mögliche Formen annimmt, eine Ref auf drei unterschiedliche Arten verwendet wird oder ein Custom Hook Daten aus einer API lädt, entscheidet die Typisierung darüber, ob Fehler beim Kompilieren oder erst beim Nutzer auffallen. Dieser Artikel zeigt, wie man React Hooks mit TypeScript von Grund auf typsicher gestaltet, von useState bis zum eigenen generischen Custom Hook.
Inhaltsverzeichnis
- 1. Warum Hooks explizite Typen brauchen
- 2. useState: Type Inference, Union-States und Objekt-Zustand
- 3. useRef: Drei Anwendungsfälle, drei unterschiedliche Typen
- 4. useEffect und Dependency Arrays typsicher halten
- 5. useReducer mit Discriminated-Union-Actions
- 6. useMemo und useCallback: Generics und Rückgabetypen
- 7. Eigene Custom Hooks mit Generics schreiben
- 8. Async Custom Hooks: Laden, Daten und Fehler typisieren
- 9. Hook-Typisierungsmuster im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Hooks explizite Typen brauchen
React Hooks sind gewöhnliche Funktionen, und wie bei jeder Funktion hängt die Qualität der Typprüfung davon ab, wie präzise Parameter und Rückgabewerte beschrieben sind. Bei einfachen Fällen wie useState(0) leitet TypeScript den Typ automatisch und korrekt ab, ohne dass eine explizite Angabe nötig wäre. Sobald der Zustand jedoch komplexer wird, etwa ein Objekt, das anfangs null ist und später eine konkrete Form annimmt, reicht die automatische Inferenz nicht mehr aus, um React Hooks zuverlässig typsicher zu halten.
Ein zweiter Grund, Hooks bewusst zu typisieren, betrifft die Fehlerarten, die sie typischerweise verursachen. Ein useRef ohne korrekten Typ erlaubt Zugriffe auf Properties, die zur Laufzeit gar nicht existieren. Ein useReducer mit losen Action-Objekten lässt Tippfehler im type-Feld unbemerkt durchrutschen. Ein Custom Hook ohne generischen Typparameter zwingt jede Aufrufstelle zu manuellen Typumwandlungen. All diese Probleme verschwinden, sobald die zugrunde liegenden Typen der Hooks bewusst und explizit definiert werden, statt sich auf zufällige Inferenz zu verlassen.
Der dritte Aspekt ist die Wiederverwendbarkeit: Ein typsicherer Custom Hook lässt sich problemlos in andere Teile der Anwendung übernehmen, weil sein Vertrag, also Eingabe- und Rückgabetyp, unabhängig vom konkreten Aufrufkontext dokumentiert ist. Ohne diese explizite Typisierung bleibt jeder Custom Hook faktisch an den Ursprungskontext gebunden, in dem er entstanden ist.
2. useState: Type Inference, Union-States und Objekt-Zustand
useState leitet den Zustandstyp bei primitiven Initialwerten wie Zahlen, Strings oder Booleans zuverlässig automatisch ab. Problematisch wird es, sobald der initiale Wert null oder undefined ist, ein Muster, das bei asynchron geladenen Daten üblich ist: Ohne explizites Typargument leitet TypeScript den Zustandstyp fälschlich als reines null ab, wodurch jede spätere Zuweisung eines echten Werts einen Compile-Fehler auslöst. Die Lösung ist ein explizites Typargument, etwa useState<User | null>(null), das dem Compiler von Anfang an mitteilt, welche Formen der Zustand über seine gesamte Lebensdauer annehmen kann.
Bei Zuständen mit mehreren klar abgegrenzten Phasen, etwa Laden, Erfolg und Fehler, lohnt sich statt mehrerer einzelner useState-Aufrufe ein einziger Zustand als Union Type. Diese Modellierung verhindert, dass sich widersprüchliche Zwischenzustände einschleichen, etwa gleichzeitig isLoading: true und ein bereits gesetztes Fehlerobjekt, was mit mehreren unabhängigen booleschen Flags leicht passieren kann, mit einer einzigen Discriminated Union dagegen strukturell ausgeschlossen ist.
interface User { id: number; email: string; }
// Without an explicit type argument, TypeScript infers "null" only
const [user, setUser] = useState<User | null>(null);
// Later assignment is now valid and fully typed
setUser({ id: 1, email: "dev@mironsoft.de" });
// Union state instead of multiple independent booleans
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; message: string };
const [requestState, setRequestState] = useState<RequestState>({ status: "idle" });
// Narrowing works exactly like with any other discriminated union
if (requestState.status === "success") {
console.log(requestState.data.email); // fully typed, no optional chaining needed
}
3. useRef: Drei Anwendungsfälle, drei unterschiedliche Typen
useRef wird in der Praxis für drei grundlegend verschiedene Zwecke eingesetzt, und jeder davon verlangt eine eigene Typisierung. Für den Zugriff auf ein DOM-Element, etwa um programmatisch den Fokus zu setzen, ist useRef<HTMLInputElement>(null) die richtige Wahl. Der Initialwert null ist dabei zwingend, weil React die Ref erst nach dem ersten Rendern mit dem tatsächlichen Element befüllt, und der Zugriff über ref.current erfordert deshalb konsequent eine Null-Prüfung oder den Optional-Chaining-Operator.
Für veränderliche Werte, die über Renderings hinweg erhalten bleiben sollen, ohne einen erneuten Render auszulösen, etwa eine Timer-ID oder einen Zähler für vergangene Aufrufe, ist der Typ ohne null als Initialwert die richtige Form: useRef<number>(0). In diesem Fall ist ref.current niemals null, weshalb TypeScript hier auch keine Null-Prüfung erzwingt. Wird useRef dagegen ganz ohne Typargument mit null als einzigem Initialwert aufgerufen, leitet TypeScript automatisch MutableRefObject<null> ab, was ref.current dauerhaft auf null festlegt und für praktisch jeden Anwendungsfall zu eng ist.
import { useRef, useEffect } from "react";
function SearchInput() {
// DOM ref: initial value must be null, access requires a null check
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="text" />;
}
function PollingWidget() {
// Mutable value ref: never null, no null check needed on access
const intervalIdRef = useRef<number>(0);
const callCountRef = useRef<number>(0);
useEffect(() => {
intervalIdRef.current = window.setInterval(() => {
callCountRef.current += 1;
}, 1000);
return () => window.clearInterval(intervalIdRef.current);
}, []);
return null;
}
4. useEffect und Dependency Arrays typsicher halten
useEffect selbst benötigt selten eine explizite Typisierung, weil TypeScript die Effekt-Funktion und die aufgeräumte Rückgabefunktion automatisch korrekt ableitet. Der eigentliche Gewinn an Typsicherheit entsteht hier weniger durch Typparameter als durch striktes Linting: Das ESLint-Plugin eslint-plugin-react-hooks prüft, ob jede im Effekt verwendete Variable auch tatsächlich im Dependency Array auftaucht, und verhindert damit den häufigsten Bugtyp bei useEffect, nämlich einen veralteten Wert (Stale Closure), der aus einem unvollständigen Dependency Array resultiert.
Auf Typenseite lohnt es sich trotzdem, die Rückgabefunktion aus useEffect bewusst konsistent zu halten: Entweder gibt der Effekt eine Cleanup-Funktion vom Typ () => void zurück, oder er gibt implizit undefined zurück, niemals aber einen anderen Wert. TypeScript lehnt Effekt-Funktionen, die versehentlich einen Promise zurückgeben, etwa durch ein vergessenes async direkt auf der Effekt-Funktion, bereits beim Kompilieren ab, ein häufiger Fehler beim ersten Kontakt mit asynchronem Code in useEffect.
import { useEffect, useState } from "react";
function ProductDetail({ productId }: { productId: number }) {
const [product, setProduct] = useState<{ name: string } | null>(null);
useEffect(() => {
// Compile error if this arrow function itself were declared "async":
// useEffect callbacks must return void or a cleanup function, not a Promise
let cancelled = false;
async function load() {
const response = await fetch(`/api/products/${productId}`);
const data = await response.json();
if (!cancelled) setProduct(data);
}
load();
return () => {
cancelled = true; // cleanup function: return type is () => void
};
}, [productId]); // eslint-plugin-react-hooks flags a missing productId here
return <p>{product?.name ?? "Lädt..."}</p>;
}
5. useReducer mit Discriminated-Union-Actions
Sobald ein Zustand mehrere zusammenhängende Felder hat, die durch verschiedene Ereignisse aktualisiert werden, etwa ein Formular mit mehreren Feldern, Validierungsfehlern und einem Sende-Status, wird useReducer gegenüber mehreren useState-Aufrufen deutlich wartbarer. Die Typsicherheit steht und fällt dabei mit der Typisierung der Actions: Ein loses { type: string; payload: any } erlaubt beliebige Tippfehler im type-Feld und macht payload faktisch ungeprüft. Die richtige Modellierung ist eine Discriminated Union aus allen möglichen Actions, bei der jede Variante ihr eigenes, spezifisches payload-Feld trägt.
Der Reducer selbst erhält dadurch innerhalb jedes case-Zweigs im switch automatisch den korrekt eingeengten Action-Typ, inklusive des passenden payload. Ein default-Zweig, der action als never behandelt, macht zusätzlich sichtbar, wenn eine neue Action-Variante hinzukommt, aber im Reducer noch nicht behandelt wird: Der Compiler meldet dann einen Fehler, weil never keinen anderen Wert als sich selbst zulässt.
interface FormState {
email: string;
password: string;
error: string | null;
isSubmitting: boolean;
}
type FormAction =
| { type: "SET_EMAIL"; payload: string }
| { type: "SET_PASSWORD"; payload: string }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_ERROR"; payload: string }
| { type: "SUBMIT_SUCCESS" };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_EMAIL":
return { ...state, email: action.payload };
case "SET_PASSWORD":
return { ...state, password: action.payload };
case "SUBMIT_START":
return { ...state, isSubmitting: true, error: null };
case "SUBMIT_ERROR":
return { ...state, isSubmitting: false, error: action.payload };
case "SUBMIT_SUCCESS":
return { ...state, isSubmitting: false };
default:
// Exhaustiveness check: compile error if a new action variant is unhandled
return ((): never => { throw new Error(`Unhandled action`); })();
}
}
6. useMemo und useCallback: Generics und Rückgabetypen
useMemo und useCallback sind beide generisch, leiten ihren Typparameter aber in der Regel automatisch aus dem übergebenen Callback ab, sodass eine explizite Typangabe selten nötig ist. Wichtiger als die Typisierung selbst ist bei diesen beiden Hooks das korrekte Dependency Array: Ein useCallback mit einer Funktion, die eine Prop referenziert, aber diese Prop nicht im Dependency Array führt, produziert eine Closure über einen veralteten Wert, ein Fehler, den TypeScript für sich genommen nicht erkennt, den aber eslint-plugin-react-hooks zuverlässig meldet.
Ein Sonderfall, der explizite Typisierung tatsächlich erfordert, ist ein useMemo, dessen Rückgabewert von einem komplexeren, nicht direkt ableitbaren Typ sein soll, etwa eine berechnete Map-Struktur. Hier hilft eine explizite Typannotation an der Zielvariable mehr als ein Typargument am Hook selbst, weil TypeScript den Rückgabetyp der übergebenen Funktion ohnehin korrekt ableitet und die Präzisierung eigentlich der Lesbarkeit an der Aufrufstelle dient.
import { useMemo, useCallback } from "react";
interface Product { id: number; category: string; price: number; }
function ProductBoard({ products, onSelect }: { products: Product[]; onSelect: (id: number) => void }) {
// Return type is inferred as Map<string, Product[]>, no explicit argument needed
const byCategory = useMemo(() => {
const map = new Map<string, Product[]>();
for (const product of products) {
const list = map.get(product.category) ?? [];
list.push(product);
map.set(product.category, list);
}
return map;
}, [products]);
// Type of handleSelect is inferred as (id: number) => void
const handleSelect = useCallback(
(id: number) => {
onSelect(id);
},
[onSelect] // eslint-plugin-react-hooks flags this if omitted
);
return null;
}
7. Eigene Custom Hooks mit Generics schreiben
Ein Custom Hook ist letztlich eine gewöhnliche Funktion, die andere Hooks aufruft, und profitiert deshalb genauso von generischen Typparametern wie jede andere wiederverwendbare Funktion. Ein häufiges Beispiel ist ein useLocalStorage-Hook, der einen Wert synchron mit dem Browser-Speicher hält: Der gespeicherte Wert kann prinzipiell jeden serialisierbaren Typ haben, weshalb ein Typparameter T die einzig sinnvolle Lösung ist, statt für Strings, Zahlen und Objekte separate Hooks zu schreiben.
Wichtig ist, den Rückgabewert eines Custom Hooks als Tupel mit fester Reihenfolge zu typisieren, wenn er dem Muster von useState folgt, damit die Destrukturierung an der Aufrufstelle die korrekten, spezifischen Typen statt eines zu breiten Union-Typs erhält. Ohne explizite Tupel-Typisierung würde TypeScript ein zurückgegebenes Array manchmal als (T | Function)[] ableiten, was die Destrukturierung ihrer Präzision beraubt.
import { useState, useCallback } from "react";
// Explicit tuple return type keeps destructuring precise at the call site
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T) => {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
}, [key]);
return [storedValue, setValue];
}
interface Preferences { theme: "light" | "dark"; language: string; }
// T is inferred as Preferences from the initial value
const [prefs, setPrefs] = useLocalStorage<Preferences>("prefs", { theme: "light", language: "de" });
8. Async Custom Hooks: Laden, Daten und Fehler typisieren
Ein besonders häufiger Custom Hook lädt Daten von einer API und muss dabei drei Zustände abbilden: den Ladevorgang, die erfolgreich geladenen Daten und einen möglichen Fehler. Statt drei unabhängiger Felder, die ungültige Kombinationen zulassen würden, modelliert man auch hier einen einzigen Union-State, exakt wie im useState-Abschnitt beschrieben, und macht diesen State generisch über den Typ der geladenen Daten, damit derselbe Hook für Produkte, Nutzer oder beliebige andere Ressourcen wiederverwendbar bleibt.
Der generische Typparameter T wird beim Aufruf des Hooks entweder explizit angegeben oder aus dem Rückgabetyp der übergebenen Lade-Funktion abgeleitet. Innerhalb des Hooks selbst bleibt jede Zustandsänderung an genau eine der drei möglichen Formen gebunden, sodass ein Aufrufer niemals gleichzeitig auf Ladezustand und Daten zugreifen kann, ohne vorher explizit den status geprüft zu haben.
import { useState, useEffect } from "react";
type AsyncState<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
// Generic custom hook: T is the shape of the loaded resource
function useAsyncResource<T>(loader: () => Promise<T>, deps: unknown[]): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
loader()
.then((data) => { if (!cancelled) setState({ status: "success", data }); })
.catch((err) => { if (!cancelled) setState({ status: "error", message: String(err) }); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}
interface Product { id: number; name: string; }
function useProduct(id: number) {
return useAsyncResource<Product>(
() => fetch(`/api/products/${id}`).then((r) => r.json()),
[id]
);
}
9. Hook-Typisierungsmuster im Vergleich
Die folgende Übersicht fasst zusammen, welches Typisierungsmuster für welchen Hook und welches Szenario geeignet ist.
| Hook / Szenario | Unpassender Ansatz | Empfohlenes Muster | Vorteil |
|---|---|---|---|
| useState mit initial null | kein Typargument | useState<User | null>(null) |
Spätere echte Zuweisung bleibt gültig |
| DOM-Element referenzieren | useRef(null) ohne Typ |
useRef<HTMLInputElement>(null) |
Korrekte Methoden am Element autovervollständigt |
| Mehrere zusammenhängende Zustandsfelder | mehrere unabhängige useState | useReducer mit Discriminated Union | Ungültige Zustandskombinationen unmöglich |
| Wiederverwendbarer Speicher-Hook | separater Hook je Datentyp | generischer Custom Hook mit T |
Eine Implementierung für alle Typen |
| Lade-/Erfolg-/Fehlerzustand | drei unabhängige booleans | generischer Union-State AsyncState<T> |
Kein gleichzeitiger Zugriff auf inkonsistente Felder |
Der gemeinsame Nenner aller empfohlenen Muster: Zustände, die sich gegenseitig ausschließen, werden als Union statt als unabhängige Felder modelliert, und wiederverwendbare Logik wird generisch statt für jeden konkreten Typ dupliziert. Beides zusammen macht React Hooks robust gegenüber genau den Fehlern, die in der Praxis am häufigsten auftreten: inkonsistente Zwischenzustände und stillschweigend falsch typisierte Refs oder Rückgabewerte.
Mironsoft
TypeScript-Tooling, typsichere React-Hooks und Magento/Hyvä-Integrationen
React Hooks, die sich auf den Compiler statt auf Konventionen verlassen?
Wir prüfen bestehende Hooks auf lose Zustände, unsichere Refs und fehlende Discriminated Unions und bauen typsichere Custom Hooks, die sich generisch über euer gesamtes Frontend wiederverwenden lassen.
Hooks-Audit
Bestehende Hooks auf lose Typen und Race-Conditions prüfen
Custom-Hook-Bibliothek
Generische, typsichere Hooks für Datenladen, Speicher und Formulare
Schulung
Team-Workshop zu typsicheren Hooks-Patterns
10. Zusammenfassung
React Hooks typsicher gestalten bedeutet vor allem, Zustände, die sich gegenseitig ausschließen, konsequent als Discriminated Union statt als unabhängige Felder zu modellieren, ob bei useState, useReducer oder einem async ladenden Custom Hook. useRef braucht je nach Anwendungsfall drei unterschiedliche Typformen: mit null für DOM-Elemente, ohne null für veränderliche Werte über Renderings hinweg. useEffect selbst benötigt selten explizite Typen, profitiert aber massiv von striktem ESLint-Linting für das Dependency Array.
Eigene Custom Hooks werden erst durch generische Typparameter wirklich wiederverwendbar, kombiniert mit einer expliziten Tupel-Typisierung des Rückgabewerts, wenn sie dem useState-Muster folgen. Wer diese Prinzipien konsequent auf alle React Hooks im Projekt anwendet, bekommt eine Codebase, in der Zustandsfehler und veraltete Refs bereits beim Kompilieren auffallen, statt erst nach dem Deploy beim Nutzer.
React Hooks typsicher gestalten - Das Wichtigste auf einen Blick
useState & useReducer
Explizites Typargument bei initial null, Discriminated Union statt unabhängiger Felder für zusammenhängende Zustände.
useRef
Mit null als Initialwert für DOM-Elemente, ohne null für veränderliche Werte, die renderübergreifend erhalten bleiben.
useEffect
Rückgabefunktion konsistent als () => void, ESLint-Plugin für vollständige Dependency Arrays einsetzen.
Custom Hooks
Generischer Typparameter für Wiederverwendbarkeit, explizite Tupel-Typisierung des Rückgabewerts.