# TypeScript in React: Props, Hooks und Events typisieren

URL: https://www.mikebild.dev/de/blog/typescript-in-react/

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.

```mermaid
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.

```tsx
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:

- `interface` unterstützt Declaration Merging, was für öffentliche Bibliotheks-APIs praktisch ist, und liest sich bei einfachen Objektformen etwas natürlicher.
- `type` ist die richtige Wahl, sobald du Union-Typen, Mapped Types oder Kompositionen aus mehreren Typen brauchst – Dinge, die ein `interface` nicht 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.

```tsx
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:

```tsx
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:

```tsx
// 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.

```tsx
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.

```tsx
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, analog `HTMLSelectElement` und `HTMLTextAreaElement`.
- `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.

```tsx
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:

```tsx
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`:

```ts
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:

```ts
type EditUserProps = Pick<User, 'name' | 'email'>;
```

Umgekehrt entfernt `Omit`, was du nicht willst – etwa die serverseitig vergebene `id`:

```ts
type NewUserProps = Omit<User, 'id'>;
```

`Partial` macht alle Felder optional, was für Patch-Operationen oder Formular-Zwischenstände passt:

```ts
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`:

```tsx
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.

```mermaid
graph TD
    K[Typen im React-Alltag] --> P[Props<br/>interface / type]
    K --> S[State<br/>useState&lt;T&gt;]
    K --> E[Events<br/>ChangeEvent / FormEvent]
    K --> R[Refs<br/>useRef&lt;HTMLInputElement&gt;]
    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:

```tsx
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](https://www.mikebild.dev/de/blog/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](https://react-typescript-cheatsheet.netlify.app/) und das Repository [github.com/typescript-cheatsheets/react](https://github.com/typescript-cheatsheets/react)
- react.dev – „Using TypeScript“: [react.dev/learn/typescript](https://react.dev/learn/typescript)
- react.dev – Hooks-Referenz: [useState](https://react.dev/reference/react/useState) und [useRef](https://react.dev/reference/react/useRef)
- TypeScript Handbook – Utility Types: [typescriptlang.org/docs/handbook/utility-types.html](https://www.typescriptlang.org/docs/handbook/utility-types.html)
- DefinitelyTyped – `@types/react`: [github.com/DefinitelyTyped/DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react)
