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.
Ein Prop an einer Button-Komponente wird umbenannt, der Build läuft durch, die Seite rendert. Drei Tage später kommt aus einem anderen Team ein Ticket: leerer Knopf im Checkout-Dialog. Fünf Aufrufstellen benutzen diese Komponente, keine davon hat gemeckert – JavaScript hatte keinen Grund dazu. Wer nach so einem Ticket TypeScript vorschlägt, hört als Erstes: „Bringt das nicht nur mehr Tipparbeit?“
Dahinter steht zuerst eine Entscheidung, keine Frage der Syntax. Die Entscheidung lautet: Wo will ich meine Integrationsfehler bezahlen? Ohne Typen bezahle ich sie spät, im Browser, beim Nutzer, oft in einem anderen Team als dem, das den Fehler eingebaut hat. Mit Typen bezahle ich sie früh, im Editor, mit einer roten Wellenlinie, bevor irgendetwas gebaut wird. Der Preis dafür ist ehrlich benennbar: ein paar Zeilen Typdeklaration und die Disziplin, sie aktuell zu halten. Die Wirkung ist es meistens wert, denn ein Fehler, den der Compiler abfängt, wird nie zum Bugreport, nie zum Rollback, nie zur Support-Anfrage.
TypeScript in React ist also kein Selbstzweck und keine Bürokratie. Aus einer Props-Signatur wird ein Versprechen, das der Compiler bei jedem Speichern nachprüft: „Diese Komponente erwartet einen label-String und optional ein disabled-Flag.“ Wer sich nicht daran hält, bekommt die Rückmeldung sofort und an der richtigen Stelle. Im Alltag spielt sich das an fünf Stellen einer Komponente ab: bei den Props, beim State, bei den Event-Handlern, bei den Refs und bei den Typen, die man aus bestehenden Datenstrukturen ableitet. Mehr braucht es selten, und der Rest dieses Artikels geht die fünf der Reihe nach durch.
Der Kern: Fehler wandern nach vorn
Bevor wir Code schreiben, lohnt es sich, den Unterschied im Ablauf klarzuziehen. 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.
Die UI-Grenze ist die teuerste Grenze
Bevor es in die Details geht, lohnt es sich, den Ort zu benennen, an dem sich der Aufwand am stärksten rechnet. Jede Komponente hat eine Grenze: Props fließen hinein, Events fließen hinaus. Genau diese Grenze ist die Nahtstelle, an der zwei Stücke Code zusammenkommen – oft zwei Autoren, manchmal zwei Teams. Ein Formular, das ein onSubmit erwartet, eine Tabelle, die eine Zeile über einen Callback nach oben meldet, ein Design-System-Button, den fünf Feature-Teams konsumieren: Das sind keine internen Implementierungsdetails, das sind Schnittstellen. Und Schnittstellen sind die Stellen, an denen die teuren Fehler entstehen, weil der Fehler auf der einen Seite passiert und auf der anderen auffällt.
Ein Typ an dieser Grenze ist ein ausführbarer Vertrag. „Ausführbar“ heißt: Er ist kein Kommentar, den jemand pflegen müsste, und keine Zeile in einer Wiki-Seite, die niemand liest. Der Compiler führt ihn bei jedem Speichern aus. Wenn ich die Props-Signatur eines Buttons ändere, meldet mir das Werkzeug an Ort und Stelle jede der fünf Aufrufstellen, die nicht mehr passt – bevor irgendwer die App startet. Der Vertrag ist damit gleichzeitig die Dokumentation, der Test und die Refactoring-Absicherung dieser Grenze, in genau einem Artefakt.
Der Trade-off ist real und gehört ausgesprochen. Typen kosten Schreibarbeit, sie müssen aktuell gehalten werden, und an einer schlecht gewählten Stelle erzeugen sie Reibung statt Sicherheit. Die Entscheidung, wo man sie setzt, ist deshalb eine Kosten-Nutzen-Frage. Je mehr Konsumenten eine Komponente hat, je länger sie lebt und je mehr Menschen an ihr vorbeikommen, desto klarer zahlt sich der Vertrag aus – ein geteilter UI-Baustein verdient präzise Props und Event-Typen. Ein Wegwerf-Prototyp oder eine rein interne Hilfskomponente mit einem einzigen Aufrufer verdient ihn kaum. Die Faustregel: Typisiere die Grenzen, an denen sich Verantwortung trifft, und lass die Inferenz den Rest erledigen. Nicht jede lokale Variable braucht einen Vertrag, aber jede Props- und Event-Signatur, die ein anderer aufruft, wird zu einem.
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 Code-Reviews kommt an dieser Stelle verlässlich 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. Diesen Fallstrick habe ich in bestehendem Code schon oft repariert:
// 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 mir dafür angewöhnt habe: 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
Diese Übersicht 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. Damit bleibt React.FC 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 aus diesem Artikel ist genau das, was einen Custom Hook von einer losen Sammlung von Funktionen zu einem klaren Vertrag macht.
Fazit
Der Gewinn von TypeScript in React lässt sich in vier Wirkungen fassen, und keine davon steht in der API-Dokumentation. Kürzere Zyklen, weil ein Integrationsfehler in Sekunden im Editor auffällt statt in Tagen über einen Bugreport. Weniger Reibung an der teuersten Stelle, der Grenze zwischen Komponenten und Teams, weil der Vertrag automatisch prüft, was sonst mündlich abgesprochen und dann vergessen wird. Höhere Qualität, weil ein falscher Prop-Typ, ein vergessenes Pflichtfeld oder ein null, das nie hätte null bleiben dürfen, gar nicht erst in den Build gelangt. Und klare Grenzen, weil eine typisierte Props- und Event-Signatur unmissverständlich sagt, was hinein- und was herauskommt.
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: Typisiere die Grenzen, an denen sich Verantwortung trifft, 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
Wie fandest du diesen Beitrag?
Kommentare