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.
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.
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 |
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
}
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.
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
));
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.
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.
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.