14 useState – Reaktiver Zustand in Funktionskomponenten

State-Management ist das Herzstück jeder interaktiven React-Anwendung. Während Props die Schnittstelle nach außen darstellen, repräsentiert State den inneren, veränderbaren Zustand einer Komponente. Der useState-Hook ist das fundamentale Werkzeug für diese Aufgabe und gleichzeitig der meistgenutzte Hook im gesamten React-Ökosystem.

14.1 Der Kern: Ein Tupel für Zustand und Änderung

Die Signatur von useState ist bewusst minimalistisch gehalten. Der Hook nimmt einen Initialwert entgegen und liefert genau zwei Dinge zurück: den aktuellen Zustandswert und eine Funktion zu dessen Änderung. Diese beiden Elemente werden per Array-Destructuring extrahiert.

const [count, setCount] = useState(0);

Was hier auf den ersten Blick wie Magie aussieht, folgt einem klaren Muster. React nutzt die Aufruf-Reihenfolge der Hooks innerhalb einer Komponente, um jedem Hook-Aufruf einen internen Speicherplatz zuzuordnen. Der erste useState-Aufruf erhält Slot 1, der zweite Slot 2 und so weiter. Diese Zuordnung bleibt während der gesamten Lebensdauer der Komponente konstant – und genau deshalb dürfen Hooks niemals in Schleifen, Bedingungen oder verschachtelten Funktionen aufgerufen werden.

Der Initialwert wird nur beim ersten Rendering verwendet. Bei jedem weiteren Durchlauf ignoriert React diesen Parameter und liefert stattdessen den aktuellen, möglicherweise bereits geänderten Wert aus seinem internen Speicher.

14.2 TypeScript und Typinferenz

In den meisten Fällen kann TypeScript den Typ des States automatisch aus dem Initialwert ableiten. Ein String als Initialwert führt zu einem State vom Typ string, eine Zahl zu number. Diese Inferenz funktioniert zuverlässig und erspart explizite Typangaben.

const [name, setName] = useState("Alice");     // string
const [age, setAge] = useState(42);            // number
const [active, setActive] = useState(true);    // boolean

Sobald jedoch komplexere Datenstrukturen oder optionale Werte ins Spiel kommen, wird eine explizite Typangabe notwendig. Der generische Typ-Parameter von useState definiert dann präzise, welche Werte erlaubt sind.

interface User {
  id: number;
  name: string;
  email: string;
}

const [user, setUser] = useState<User | null>(null);

Ohne die explizite Angabe <User | null> würde TypeScript den Typ als null inferieren – und jeder spätere Versuch, ein User-Objekt zu setzen, würde zu einem Typfehler führen. Die Typ-Parameter-Syntax klärt von Anfang an, dass dieser State entweder null oder ein vollständiges User-Objekt enthalten kann.

Situation Typ-Deklaration Bemerkung
Primitiver Initialwert Implizit TypeScript inferiert korrekt
Union Types Explizit nötig useState<string \| null>(null)
Komplexe Objekte Optional Zur Dokumentation empfohlen
Arrays Meist implizit useState<number[]>([]) nur bei leerem Array

14.3 Funktionale Updates: Der vorherige Wert als Basis

Die Setter-Funktion akzeptiert nicht nur direkte Werte, sondern auch Callbacks. Diese Callback-Variante erhält den aktuellen State als Parameter und gibt den neuen State zurück. Dieses Muster ist mehr als syntaktischer Zucker – es löst ein fundamentales Problem bei asynchronen Updates.

const [count, setCount] = useState(0);

// Direkte Übergabe: funktioniert, aber gefährlich
setCount(count + 1);

// Funktionaler Update: sicher
setCount(prev => prev + 1);

Der Unterschied wird kritisch, wenn mehrere State-Updates schnell hintereinander erfolgen oder wenn Event-Handler mit Closures arbeiten. Die direkte Variante arbeitet mit dem zum Zeitpunkt der Definition erfassten Wert – die funktionale Variante hingegen garantiert, dass immer der aktuellste Wert verwendet wird.

function handleTripleClick() {
  // Problematisch: Alle drei Aufrufe verwenden denselben count-Wert
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Ergebnis: count + 1
  
  // Korrekt: Jeder Aufruf baut auf dem Ergebnis des vorherigen auf
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  // Ergebnis: count + 3
}

14.4 Re-Rendering: Der Zyklus verstehen

Ein State-Update ist kein imperativer Befehl, sondern eine Anfrage an React. Die Setter-Funktion sagt nicht „ändere jetzt sofort diesen Wert”, sondern „plane eine Aktualisierung mit diesem neuen Wert”. React sammelt solche Anfragen, bündelt sie und führt dann ein Re-Rendering durch.

Dieser asynchrone Charakter hat weitreichende Konsequenzen. Nach dem Aufruf von setCount(5) enthält die Variable count nicht sofort den Wert 5. Der neue Wert wird erst beim nächsten Rendering-Durchlauf verfügbar sein.

function handleClick() {
  console.log(count);        // z.B. 0
  setCount(count + 1);
  console.log(count);        // immer noch 0!
  // Der neue Wert ist erst im nächsten Render verfügbar
}

React nutzt Object.is für den Vergleich von altem und neuem Wert. Wenn beide identisch sind, wird das Re-Rendering übersprungen – eine wichtige Optimierung, die verhindert, dass unnötige Rendering-Zyklen ausgelöst werden. Bei primitiven Werten wie Zahlen oder Strings funktioniert das intuitiv. Bei Objekten und Arrays wird es tückisch.

14.5 Immutabilität: Die goldene Regel

React verlässt sich auf Referenz-Gleichheit, um Änderungen zu erkennen. Ein Objekt, das modifiziert wurde, behält seine Referenz – für React sieht es aus, als hätte sich nichts geändert. Die Lösung liegt in der konsequenten Erzeugung neuer Objekte und Arrays bei jeder Änderung.

interface Person {
  name: string;
  age: number;
}

const [person, setPerson] = useState<Person>({
  name: "Alice",
  age: 30
});

// Falsch: Direkte Mutation
person.age = 31;
setPerson(person);  // React erkennt keine Änderung!

// Richtig: Neues Objekt erstellen
setPerson({ ...person, age: 31 });

// Alternative mit Object-Spread
setPerson(prev => ({ ...prev, age: 31 }));

Der Spread-Operator (...) ist das Arbeitspferd für immutable Updates. Er erstellt eine flache Kopie des Objekts und ermöglicht das Überschreiben einzelner Eigenschaften. Bei verschachtelten Strukturen muss jede Ebene neu erstellt werden.

interface Address {
  street: string;
  city: string;
}

interface User {
  name: string;
  address: Address;
}

const [user, setUser] = useState<User>({
  name: "Bob",
  address: {
    street: "Main St",
    city: "Berlin"
  }
});

// Verschachtelte Struktur: Beide Ebenen müssen kopiert werden
setUser({
  ...user,
  address: {
    ...user.address,
    city: "Hamburg"
  }
});

Für Arrays gelten ähnliche Regeln. Methoden wie push, pop, splice oder sort modifizieren das Array direkt und sollten vermieden werden. Stattdessen kommen immutable Alternativen zum Einsatz: concat, slice, filter, map oder der Spread-Operator.

const [items, setItems] = useState<string[]>([]);

// Element hinzufügen
setItems([...items, "Neues Item"]);
setItems(items.concat("Neues Item"));

// Element entfernen
setItems(items.filter(item => item !== "Zu entfernen"));

// Element ersetzen
setItems(items.map(item => 
  item === "Alt" ? "Neu" : item
));

14.6 Mehrere States: Granularität vor Monolithen

Die Versuchung ist groß, zusammengehörige Daten in einem einzigen Objekt zu bündeln. In vielen Fällen führt das jedoch zu unnötiger Komplexität. React ermutigt dazu, State granular zu halten und lieber mehrere useState-Aufrufe zu verwenden als ein großes Objekt zu verwalten.

// Monolithischer Ansatz: anfällig für Fehler
const [form, setForm] = useState({
  firstName: "",
  lastName: "",
  email: "",
  age: 0
});

// Granularer Ansatz: explizit und wartbar
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);

Der granulare Ansatz hat mehrere Vorteile. Updates sind explizit und betreffen nur die tatsächlich geänderte Variable. Es gibt keine Gefahr, versehentlich andere Felder zu überschreiben. Die Komponente wird testbarer, weil jeder State isoliert betrachtet werden kann. Und TypeScript kann präzisere Typen ableiten, ohne dass jedes Update das gesamte Objekt rekonstruieren muss.

Natürlich gibt es Situationen, in denen zusammengehörige Daten sinnvoll gruppiert werden. Ein Adressobjekt oder Koordinaten sind Beispiele, wo die Daten semantisch zusammengehören und meist gemeinsam aktualisiert werden. Die Kunst liegt darin, die richtige Balance zu finden.

14.7 Lazy Initialization: Performance bei teuren Berechnungen

Manchmal ist die Berechnung des Initialwerts aufwendig. Ein useState-Aufruf mit direktem Wert würde diese Berechnung bei jedem Rendering wiederholen – auch wenn das Ergebnis nur beim ersten Mal relevant ist.

// Ineffizient: Berechnung bei jedem Render
const [data, setData] = useState(expensiveCalculation());

// Effizient: Berechnung nur beim ersten Render
const [data, setData] = useState(() => expensiveCalculation());

Die Callback-Variante wird nur beim initialen Rendering ausgeführt. React ruft die Funktion auf, speichert das Ergebnis und ignoriert den Callback bei allen weiteren Renderings. Diese Optimierung ist besonders relevant, wenn der Initialwert aus localStorage gelesen, durch komplexe Algorithmen berechnet oder aus großen Datenstrukturen extrahiert wird.

14.8 Praktische Hinweise aus der Entwicklungspraxis

State-Management sieht in Tutorials oft einfacher aus als im realen Projekt. Ein paar Beobachtungen aus der Praxis helfen, typische Stolperfallen zu vermeiden.

State nicht zu früh abstrahieren. Der Reflex, sofort useReducer oder externe State-Management-Bibliotheken einzusetzen, führt oft zu unnötiger Komplexität. useState ist für die überwiegende Mehrheit der Komponenten vollkommen ausreichend. Komplexere Patterns sollten erst dann eingeführt werden, wenn ein konkreter Bedarf entsteht.

Setter-Funktionen niemals während des Renderings aufrufen. Ein häufiger Fehler ist es, State-Updates direkt im Komponenten-Body zu platzieren. Das führt zu Endlosschleifen, weil jedes Update ein Re-Rendering auslöst, das wiederum ein Update auslöst.

// Falsch: Endlosschleife!
function Component() {
  const [count, setCount] = useState(0);
  setCount(count + 1);  // Bei jedem Render
  return <div>{count}</div>;
}

// Richtig: Update in Event-Handler
function Component() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

State-Werte sind Snapshots. Jeder Render “friert” die State-Werte zum Zeitpunkt des Renderings ein. Asynchrone Operationen wie setTimeout oder Promises arbeiten mit diesen eingefrorenen Werten, nicht mit den aktuellsten.

function DelayedCounter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setTimeout(() => {
      // Verwendet den count-Wert zum Zeitpunkt des Klicks
      setCount(count + 1);
    }, 3000);
  }
  
  return <button onClick={handleClick}>{count}</button>;
}

Bei schnellem mehrfachen Klicken zeigt sich das Problem: Alle Timeouts verwenden denselben count-Wert. Die Lösung liegt wieder in funktionalen Updates: setCount(prev => prev + 1) garantiert, dass jedes Update auf dem aktuellsten Wert basiert.