TypeScript in React: Props, Hooks und Events typisieren
Wie aus Props und State überprüfbare Verträge werden – und der Editor Fehler meldet, bevor der Nutzer sie sieht. Von interface/type über useState-Generics und Event-Typen bis zu useRef und Utility-Types.
Wenn ich in einer Schulung die erste TypeScript-Komponente an die Wand werfe, kommt fast immer dieselbe Frage: „Bringt das nicht nur mehr Tipparbeit?" Meine Antwort ist jedes Mal ein kleines Experiment. Ich baue absichtlich einen Fehler ein – ich übergebe einer Komponente einen Prop mit dem falschen Typ – und lasse die Teilnehmer raten, wann dieser Fehler auffällt. In JavaScript: irgendwann, im Browser, vielleicht beim Nutzer. In TypeScript: sofort, rot unterkringelt, während ich noch tippe.
Genau das ist der Punkt. TypeScript in React ist kein Selbstzweck und keine Bürokratie. Es verwandelt Props und State in überprüfbare Verträge. Ein Vertrag sagt: „Diese Komponente erwartet einen label-String und optional ein disabled-Flag." Wer sich nicht daran hält, bekommt eine Rückmeldung – und zwar an der richtigen Stelle. Der Fehler wandert vom Nutzer in den Editor. Das ist die ganze Idee, und der Rest dieses Artikels zeigt, wie man sie im Alltag umsetzt.
Der Kern: Fehler wandern nach vorn
Bevor wir Code schreiben, will ich das Bild klarziehen, das mir in Schulungen am meisten hilft. Ohne Typen baut die App durch, egal was ich in ein Prop stecke – der Fehler passiert erst zur Laufzeit, wenn jemand die Seite benutzt. Mit Typen prüft der Compiler die JSX-Nutzung gegen das Interface, und der Fehler entsteht, bevor irgendetwas gebaut wird.
flowchart TD
A[Falscher Prop im JSX] --> B{TypeScript aktiv?}
B -->|Nein| C[App baut durch]
C --> D[Laufzeitfehler<br/>beim Nutzer im Browser]
B -->|Ja| E[tsc / Editor prüft<br/>gegen das Interface]
E --> F[Compile-Fehler<br/>rot im Editor]
F --> G[Fix vor dem Commit]
Der linke Pfad kostet dich einen Bugreport, im schlimmsten Fall einen verärgerten Kunden. Der rechte Pfad kostet dich zehn Sekunden. Diese Verschiebung nach vorn ist der eigentliche Gewinn – nicht die Autovervollständigung, so schön die auch ist.
Props als Vertrag: interface oder type
Der Einstieg ist unspektakulär und genau deshalb so tragfähig. Eine Komponente ist eine Funktion, ihre Props sind ein Objekt, und dieses Objekt bekommt einen Typ.
interface ButtonProps {
label: string;
disabled?: boolean;
}
function Button({ label, disabled }: ButtonProps) {
return <button disabled={disabled}>{label}</button>;
}
Das ? hinter disabled macht den Prop optional. Vergisst jemand label, meldet der Compiler einen fehlenden Required-Prop – kein undefined, das später als leerer Button endet, sondern ein Fehler an Ort und Stelle.
In Schulungen kommt an dieser Stelle die Frage: interface oder type? Beides funktioniert für Props, und die pragmatische Faustregel aus dem React-TypeScript-Cheatsheet lautet: „interface until you need type." Anders gesagt:
interfaceunterstützt Declaration Merging, was für öffentliche Bibliotheks-APIs praktisch ist, und liest sich bei einfachen Objektformen etwas natürlicher.typeist die richtige Wahl, sobald du Union-Typen, Mapped Types oder Kompositionen aus mehreren Typen brauchst – Dinge, die eininterfacenicht ausdrücken kann.
Für 90 Prozent der Komponenten-Props ist die Wahl reine Geschmackssache. Wichtig ist Konsistenz im Team, nicht die Grundsatzdebatte.
children ist kein Automatismus mehr
Sobald eine Komponente andere Elemente umschließt, braucht sie children. Und hier lohnt ein genauer Blick, weil sich mit @types/react in Version 18 etwas Grundlegendes geändert hat: Die implizite children-Deklaration wurde entfernt. Früher brachte React.FC ein automatisches children mit. Heute musst du es selbst deklarieren, wenn deine Komponente Kinder rendert.
interface CardProps {
title: string;
children?: React.ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<section className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</section>
);
}
React.ReactNode ist der richtige Typ für „alles, was man rendern kann": Strings, Zahlen, Elemente, Arrays davon, null, boolean. Wenn du dagegen exakt ein einzelnes Element erzwingen willst, greifst du zu React.ReactElement. In den Types 18 liegt der JSX-Namespace übrigens unter React.JSX, ein einzelnes Element ist also auch als React.JSX.Element beschreibbar. Für den Alltag reicht ReactNode fast immer – es ist bewusst großzügig.
Dass children nicht mehr magisch erscheint, ist einer der Gründe, warum React.FC an Bedeutung verloren hat. Dazu gleich mehr.
State typisieren: wann Inferenz reicht und wann nicht
useState ist ein schöner Fall, an dem man sieht, dass TypeScript nicht überall Handarbeit verlangt. Meistens leitet der Compiler den Typ aus dem Startwert ab:
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [open, setOpen] = useState(false); // boolean
Hier ein Generic anzugeben wäre nur Ballast. Die Inferenz ist eindeutig, und setCount('drei') scheitert trotzdem am Compiler. Interessant wird es, wenn der Startwert dem Compiler zu wenig verrät. Das ist der Fallstrick, über den in Schulungen die meisten stolpern:
// Wrong: type is inferred as `null`
const [user, setUser] = useState(null);
// later setUser({ id: 1, name: 'Mike' }) -> compile error
// Correct: specify the generic explicitly
const [user, setUser] = useState<User | null>(null);
Startest du mit null, inferiert TypeScript den Typ null – und dann darfst du nie wieder etwas anderes hineinschreiben. Dasselbe passiert bei useState([]), das zu never[] wird, oder bei einem leeren Objekt. Die Regel, die ich Teilnehmern mitgebe: Wenn der Startwert leer, null oder ein leeres Array ist, gib den Typ mit dem Generic vor.
const [items, setItems] = useState<string[]>([]);
const [selected, setSelected] = useState<User | null>(null);
Events typisieren: Schluss mit any
Der nächste Klassiker sind Event-Handler. In JavaScript schreibt man onChange={(e) => setValue(e.target.value)} und denkt nicht weiter nach. In TypeScript ist e ohne Typ ein any – und any ist das Loch, durch das genau die Fehler zurückkommen, die wir eigentlich loswerden wollten. React bringt für die synthetischen Events präzise Typen mit. Man muss nur den passenden nehmen.
function LoginForm() {
const [email, setEmail] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('submitting', email);
};
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={handleChange} />
<button type="submit">Sign in</button>
</form>
);
}
Zwei Dinge sind hier wichtig. Erstens muss das Generic zum Element passen: Ein <input> liefert HTMLInputElement, nur damit ist e.target.value überhaupt bekannt. Wer versehentlich HTMLElement schreibt, verliert value. Zweitens gibt es für onChange, onSubmit, onClick jeweils die passenden Typen:
React.ChangeEvent<HTMLInputElement>für Eingaben, analogHTMLSelectElementundHTMLTextAreaElement.React.FormEvent<HTMLFormElement>für das Absenden eines Formulars.React.MouseEvent<HTMLButtonElement>für Klicks.
Wenn du den Handler inline direkt am JSX-Attribut definierst, kannst du dir den Typ oft sparen, weil React ihn aus dem Kontext ableitet. Sobald du den Handler aber als eigenständige Funktion herausziehst, musst du den Event-Typ selbst angeben. Für Props, die einen Handler entgegennehmen, gibt es zusätzlich die Kurzformen React.ChangeEventHandler<HTMLInputElement> und React.FormEventHandler<HTMLFormElement> – praktisch, wenn du einen Callback als Prop durchreichst.
useRef: DOM-Referenz mit Null-Check
useRef verwirrt am Anfang, weil es zwei Rollen spielt. Die häufigste ist die DOM-Referenz, um etwa ein Feld beim Mounten zu fokussieren.
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="search" />;
}
Drei Details zählen. Das Generic <HTMLInputElement> sagt, worauf die Ref zeigt. Der Startwert null ist Absicht: Vor dem ersten Render existiert das DOM-Element noch nicht. Diese Kombination ergibt eine read-only RefObject, und genau die passt an das JSX-Attribut ref. Weil current vor dem Mount null sein kann, brauchst du beim Zugriff einen Null-Check – deshalb das ?. in inputRef.current?.focus(). Ohne den Guard meckert der Compiler zu Recht.
Die zweite Rolle von useRef ist die einer veränderlichen Instanzvariable, die kein Re-Render auslöst – etwa um eine Timer-ID oder einen vorherigen Wert festzuhalten:
const timerRef = useRef<number | null>(null);
const start = () => {
timerRef.current = window.setTimeout(() => {
console.log('done');
}, 1000);
};
Hier ist current beschreibbar. Der Unterschied entsteht aus dem Overload: Mit null im Union-Typ und ohne DOM-Bezug bekommst du ein MutableRefObject. Man muss diese Feinheit nicht auswendig können, aber es hilft zu wissen, dass der DOM-Fall bewusst read-only ist.
Utility-Types: Props ableiten statt kopieren
Wenn Datenstrukturen wachsen, will man Props nicht dreimal von Hand nachziehen. Dafür gibt es die Utility-Types aus der TypeScript-Standardbibliothek – nicht aus React, aber im React-Alltag ständig nützlich. Angenommen, es gibt einen User:
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'member';
}
Ein Formular, das nur Name und E-Mail bearbeitet, braucht nicht den ganzen User. Pick schneidet die relevanten Felder heraus:
type EditUserProps = Pick<User, 'name' | 'email'>;
Umgekehrt entfernt Omit, was du nicht willst – etwa die serverseitig vergebene id:
type NewUserProps = Omit<User, 'id'>;
Partial macht alle Felder optional, was für Patch-Operationen oder Formular-Zwischenstände passt:
type UserDraft = Partial<User>;
Der Gewinn: Ändert sich User, ziehen die abgeleiteten Typen automatisch nach. Kein Feld gerät aus dem Tritt. Neben Pick, Omit und Partial gehören Required<T> und Readonly<T> zum Standardrepertoire.
Ein React-spezifischer Trick ergänzt das Bild: Wenn deine Komponente die nativen Props eines HTML-Elements erben soll – alle Standard-Button-Attribute etwa –, nimmst du React.ComponentPropsWithoutRef:
type IconButtonProps = React.ComponentPropsWithoutRef<'button'> & {
icon: string;
};
function IconButton({ icon, ...rest }: IconButtonProps) {
return (
<button {...rest}>
<span className="icon">{icon}</span>
</button>
);
}
So bekommst du onClick, disabled, type und alle anderen Button-Attribute geschenkt und typgeprüft, ohne sie einzeln aufzuzählen.
Die Alltags-Typen im Überblick
Wenn ich am Ende eines Schulungstags zusammenfasse, zeichne ich diese Übersicht an die Wand. Sie zeigt die fünf Ecken, an denen man in einer typisierten React-Komponente am häufigsten mit Typen zu tun hat.
graph TD
K[Typen im React-Alltag] --> P[Props<br/>interface / type]
K --> S[State<br/>useState<T>]
K --> E[Events<br/>ChangeEvent / FormEvent]
K --> R[Refs<br/>useRef<HTMLInputElement>]
K --> U[Ableitung<br/>Pick / Omit / Partial]
P --> C[children:<br/>ReactNode]
Diese fünf decken den größten Teil der täglichen Arbeit ab. Alles andere baut darauf auf.
Und was ist mit React.FC?
Die Frage kommt garantiert, oft mit einem Codebeispiel aus einem älteren Tutorial: const Button: React.FC<ButtonProps> = (props) => .... Ist das falsch? Nein. Es funktioniert, es ist 2024 verfügbar, und niemand muss bestehenden Code umschreiben. Aber der moderne Default ist die schlichte Funktion mit typisierten Props, und dafür gibt es Gründe.
Der historisch wichtigste war das implizite children, das React.FC mitbrachte – Komponenten akzeptierten Kinder, auch wenn sie gar keine rendern sollten. Mit @types/react 18 wurde dieses implizite children entfernt, was einen der Hauptkritikpunkte entschärft hat. Übrig bleibt vor allem, dass React.FC bei generischen Komponenten im Weg steht. Wer eine typsichere Listen-Komponente über einen beliebigen Elementtyp schreiben will, fährt mit einer normalen Funktion besser:
interface ListProps<T> {
items: T[];
render: (item: T) => React.ReactNode;
}
function List<T>({ items, render }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;
}
So etwas lässt sich mit React.FC nur umständlich ausdrücken. Mein Rat in Schulungen ist deshalb entspannt: React.FC ist heute eine Stil-Frage, kein Fehler. Wählt eine Variante im Team und bleibt dabei. Ich selbst schreibe normale Funktionen, weil sie bei Generics flexibler sind und weniger Sonderregeln mitbringen.
Der Bogen zurück zu Hooks
Wer schon eigene Hooks schreibt, merkt schnell, dass sich die hier gezeigten Bausteine dort direkt fortsetzen: Ein Custom Hook gibt typisierten State zurück, nimmt typisierte Argumente entgegen und kapselt Event-Logik. Wenn du den Weg von wiederverwendbarer Logik noch nicht gegangen bist, lohnt der Blick auf Von HOCs und Render Props zu Custom Hooks – die Typisierung, die du hier gelernt hast, ist genau das, was einen Custom Hook von einer losen Sammlung von Funktionen zu einem klaren Vertrag macht.
Fazit
TypeScript in React zahlt sich dort aus, wo man es zuerst nicht vermutet: nicht beim Tippen, sondern beim Nicht-mehr-Debuggen. Props und State werden zu Verträgen, die der Compiler durchsetzt. Ein falscher Prop-Typ, ein vergessenes Pflichtfeld, ein null, das nie hätte null bleiben dürfen – all das wird zum Compile-Fehler, während du schreibst, statt zum Bugreport, nachdem der Nutzer geklickt hat.
Der Einstieg ist überschaubar. Props als interface oder type, children als ReactNode explizit deklarieren, useState mit Generic nur dort, wo die Inferenz zu eng wird, die passenden Event-Typen statt any, useRef mit Null-Check und den Utility-Types Props ableiten statt kopieren. Mit diesen sechs Handgriffen deckt man den Alltag ab. Alles Weitere – generische Komponenten, komplexe Unions, eigene Hooks – ist eine Fortsetzung derselben Idee: Sag klar, was du erwartest, und lass den Compiler dafür sorgen, dass sich alle daran halten.
Weiterführende Quellen
- React TypeScript Cheatsheet: react-typescript-cheatsheet.netlify.app und das Repository github.com/typescript-cheatsheets/react
- react.dev – „Using TypeScript": react.dev/learn/typescript
- react.dev – Hooks-Referenz: useState und useRef
- TypeScript Handbook – Utility Types: typescriptlang.org/docs/handbook/utility-types.html
- DefinitelyTyped –
@types/react: github.com/DefinitelyTyped/DefinitelyTyped
Kommentare
Kommentar schreiben