Zustand erhalten und zurücksetzen: React und die Position im Baum
React speichert Zustand nicht in der Komponente, sondern an ihrer Position im Render-Baum – wer das versteht, steuert Erhalt und Reset absichtlich statt zufällig.
Zustand erhalten und zurücksetzen: React und die Position im Baum
In Wie React den Baum rendert habe ich gezeigt, wie React bei jeder Aktualisierung zwei Bäume vergleicht – den alten und den neuen – und aus den Unterschieden ableitet, was am DOM zu tun ist. Das klang dort nach einem Detail der Rendering-Mechanik. Es hat aber eine Konsequenz, die im Alltag ständig für Verwirrung sorgt und die viel weiter reicht: Sie entscheidet, ob der Zustand einer Komponente über eine Aktualisierung hinweg erhalten bleibt oder verschwindet.
Die Verwirrung entsteht, weil fast jeder ein falsches Bild im Kopf hat. Man stellt sich vor, der Zustand einer Komponente wohne in der Komponente – so, wie eine Variable in einem Objekt steckt. Nach diesem Bild müsste useState seinen Wert dort halten, wo die Funktion steht. Genau das tut React nicht, und aus diesem Missverständnis wachsen zwei Sorten von Bugs: mal geht ein Zustand verloren, den man behalten wollte, mal bleibt einer kleben, den man loswerden wollte. Beide fühlen sich wie Magie an, solange man das richtige Bild nicht hat.
Der Zustand hängt an der Position, nicht an der Komponente
Das richtige Bild ist dieses: React speichert den Zustand nicht in der Komponente, sondern an ihrer Position im Render-Baum. Jede Stelle im Baum – erstes Kind eines Containers, zweites Kind, das Element hinter dieser Bedingung – ist eine Adresse. An dieser Adresse merkt sich React, welche Komponente dort zuletzt stand und welchen Zustand sie hatte. Steht bei der nächsten Aktualisierung dieselbe Komponente wieder an derselben Adresse, reicht React ihr den gemerkten Zustand erneut. Steht dort etwas anderes – oder gar nichts mehr –, wirft React den Zustand dieser Adresse weg.
Daraus folgt die ganze Regel in einem Satz: Gleiche Komponente an gleicher Position behält ihren Zustand. Ändert sich an einer Position der Typ, oder verschwindet die Position, wird der Zustand dort verworfen. Alles Weitere in diesem Beitrag ist nur diese eine Regel, angewendet auf Fälle, die einem sonst rätselhaft vorkommen.
Beispiel A: zwei Counter, zwei Positionen
Nehmen wir zwei gleiche Komponenten nebeneinander. Beide sind derselbe Counter, geschrieben aus derselben Funktion:
function Counter({ label }) {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{label}: {count}
</button>
);
}
function App() {
return (
<div>
<Counter label="left" />
<Counter label="right" />
</div>
);
}
Klickt man den linken hoch, bleibt der rechte stehen. Für viele überraschend, denn es ist doch dieselbe Komponente – sollte sie sich nicht denselben Zustand teilen? Nein, und die Positionsregel erklärt sofort, warum. Der linke Counter sitzt an der Adresse „erstes Kind des div", der rechte an „zweites Kind". Das sind zwei Adressen, also zwei getrennte Zustände. Dass beide aus derselben Funktion stammen, spielt keine Rolle – die Funktion ist nur die Vorlage, der Zustand lebt an der Position.
Jetzt der Fall, der den Punkt wirklich schärft. Wir blenden den rechten Zähler bedingt aus und wieder ein:
function App() {
const [showRight, setShowRight] = useState(true);
return (
<div>
<Counter label="left" />
{showRight && <Counter label="right" />}
<button onClick={() => setShowRight(!showRight)}>toggle right</button>
</div>
);
}
Man zählt den rechten auf 7 hoch, schaltet ihn aus, schaltet ihn wieder ein – und er steht bei 0. Der Zustand ist weg, unwiederbringlich. Solange showRight falsch ist, steht an der zweiten Position nichts. React sieht eine Position, die verschwunden ist, und räumt ihren Zustand ab. Kommt der Counter zurück, ist es aus Sicht von React eine frisch besetzte Adresse, kein Wiedersehen. Der Zustand war nie „im Zähler" – er war an eine Position gebunden, die zwischenzeitlich nicht existierte.
Beispiel B: dieselbe Position, anderer Typ
Der Gegenfall ist ebenso lehrreich. Solange an einer Position derselbe Komponententyp steht, überlebt der Zustand jede Änderung der Props. Man kann einem Counter bei jeder Aktualisierung ein anderes label geben, seine Farbe umstellen, ihn von aktiv auf inaktiv schalten – der Zählerstand bleibt, weil an der Position weiter derselbe Typ steht. Props formen die Komponente neu, sie setzen sie nicht zurück.
Kippt an derselben Stelle dagegen der Typ, ist der Zustand weg. Genau hier greift die Typ-Regel der Reconciliation, die ich in Wie React den Baum rendert beschrieben habe: Trifft React an einer Position auf einen anderen Elementtyp als vorher, baut es den alten Teilbaum ab und den neuen von Grund auf neu auf – samt Zustand. Ein verbreiteter Auslöser sieht harmlos aus:
function Panel({ editing }) {
if (editing) {
return <input defaultValue="draft" />;
}
return <p>draft</p>;
}
An dieser Position wechselt der Typ zwischen <input> und <p>. Jeder Wechsel wirft den alten Knoten weg – der <input> verliert seinen getippten Text, weil beim nächsten Umschalten dort ein <p> steht und danach ein ganz neuer <input>. Dasselbe passiert, wenn an einer Stelle mal die eine, mal eine andere Komponente gerendert wird: verschiedene Typen an einer Adresse heißt für React verschiedene Dinge, und Verschiedenes teilt sich keinen Zustand.
flowchart TB
subgraph erhalten["gleiche Position, gleicher Typ"]
A1["Counter an Position 2<br/>count = 7"] --> A2["neue Props<br/>Typ bleibt Counter"]
A2 --> A3["count = 7<br/>bleibt erhalten"]
end
subgraph reset["gleiche Position, anderer Typ"]
B1["p an Position 2"] --> B2["jetzt input<br/>an Position 2"]
B2 --> B3["alter Teilbaum weg,<br/>Zustand verworfen"]
end
Zustand absichtlich zurücksetzen
Bis hierher klingt das nach einer Falle. Man kann die Regel aber umdrehen und sie zum Werkzeug machen. Manchmal will man einen Reset ja ausdrücklich – und dann sagt einem die Positionsregel genau, wie man ihn bestellt.
Der grobe Weg: die Komponente an einer anderen Position rendern. Zwei Zweige eines if, die scheinbar denselben Counter zeigen, sind aus Sicht von React zwei verschiedene Positionen, wenn sie an unterschiedlichen Stellen im Baum hängen; wer zwischen ihnen umschaltet, bekommt jedes Mal einen frischen Zustand. Das funktioniert, ist aber grobschlächtig und leicht versehentlich ausgelöst.
Der elegante Weg führt über den key. Ein key sagt React ausdrücklich: „Das hier ist ein bestimmtes Element, unterscheide es von seinen Nachbarn." Aus JSX genau kennt man Keys als das, was Listen stabil macht, damit React beim Umsortieren die Elemente wiedererkennt. Genau dieselbe Fähigkeit taugt aber auch außerhalb von Listen zum gezielten Reset. Ändert sich der key eines Elements, behandelt React es als ein neues Element an dieser Position – der alte Teilbaum wird abgebaut, der Zustand frisch angelegt.
Der Klassiker dafür ist ein Formular, das pro ausgewähltem Datensatz frisch starten soll:
function ContactEditor({ selectedId }) {
return <Form key={selectedId} contactId={selectedId} />;
}
Ohne den key bliebe Form an derselben Position stehen; wechselt selectedId, bekäme das Formular nur neue Props, und der halb getippte Text des vorigen Kontakts stünde weiter im Feld. Mit key={selectedId} wechselt bei jeder neuen Auswahl der Schlüssel, React sieht ein neues Element, und das Formular startet leer – ohne dass man ein einziges Feld von Hand zurücksetzt. Der key erledigt genau die Arbeit, die man sonst mit einem Effekt und viel setState nachbauen würde.
flowchart LR S1["selectedId = 1"] --> F1["Form key=1<br/>frischer Zustand"] S2["selectedId = 2"] --> F2["Form key=2<br/>neues Element,<br/>Formular leer"] S3["selectedId = 2<br/>Feld getippt"] --> F3["Form key=2<br/>Zustand bleibt"]
Zustand erhalten, obwohl sich die Struktur ändert
Das Gegenstück ist der Fall, in dem man den Zustand behalten will, die Umgebung sich aber ändert. Hier gilt es, Position und Typ stabil zu halten, auch wenn drumherum umgebaut wird. Wer eine Komponente je nach Bedingung mal in den einen, mal in den anderen Zweig schreibt, verschiebt sie damit an eine andere Position – und wundert sich über den Reset. Oft lässt sich das vermeiden, indem man die Komponente an einer festen Stelle rendert und nur ihre Props umschaltet, statt sie im Baum wandern zu lassen.
Und wenn ein Zustand einen echten Umbau überleben muss, gehört er meist nicht dorthin, wo er gerade steht. Dann zieht man ihn nach oben, zu einem gemeinsamen Elternteil, der von der Umstrukturierung unberührt bleibt. Der Zustand hängt dann an einer Position, die stabil ist, und die Kinder darunter dürfen kommen und gehen, ohne ihn mitzureißen. Das ist dieselbe Bewegung wie beim klassischen Hochziehen von Zustand – nur ist der Grund hier nicht Teilen zwischen Geschwistern, sondern Überleben trotz Strukturänderung.
Der fieseste Bug: eine Komponente in einer Komponente
Den unangenehmsten Fall hebe ich mir für den Schluss auf, weil er die Positionsregel auf die Probe stellt und weil er in echten Codebasen erstaunlich oft steckt. Er sieht so aus:
function Page() {
function Sidebar() {
const [open, setOpen] = useState(true);
return <button onClick={() => setOpen(!open)}>{open ? "hide" : "show"}</button>;
}
return (
<div>
<Sidebar />
</div>
);
}
Sidebar ist innerhalb von Page definiert. Das wirkt ordentlich – die Komponente steht ja da, wo sie gebraucht wird. Der Effekt ist trotzdem verheerend. Bei jedem Rendern von Page läuft die Zeile function Sidebar() erneut und erzeugt eine neue Funktion. Es ist bei jedem Durchlauf buchstäblich eine andere Funktion mit einer anderen Identität. Für React ist die Identität der Funktion aber der Typ. Ein anderer Typ an einer Position heißt: alter Teilbaum weg, neuer aufgebaut, Zustand verworfen.
In der Praxis sieht das gespenstisch aus. Jede noch so kleine Aktualisierung von Page setzt open zurück, das Eingabefeld darin verliert seinen Text, und wer gerade in einem Feld tippt, verliert mitten im Wort den Fokus, weil das DOM-Element unter ihm ersetzt wurde. Man sucht den Fehler überall – im Effekt, im Event-Handler, im State-Management – nur nicht an der Stelle, wo die Komponente definiert ist. Dabei ist es genau das: eine bei jedem Rendern neu geborene Funktionsidentität, die React zwingt, den ganzen Teilbaum wegzuwerfen.
Die Lösung ist unspektakulär und deshalb leicht zu übersehen: Komponenten gehören auf die Modulebene, nicht in den Rumpf einer anderen Komponente. Dann existiert Sidebar genau einmal, behält über alle Aktualisierungen dieselbe Identität, steht als derselbe Typ an derselben Position – und ihr Zustand bleibt. Braucht die innere Komponente Daten von außen, reicht man sie als Props durch, nicht über die verschachtelte Definition.
Ort und Zeit im Baum
Zustand in React ist an Ort und Zeit gebunden: an eine Position im Render-Baum und an die Dauer, die diese Position mit demselben Typ besetzt bleibt. Fast alles, was am Verhalten von Zustand rätselhaft wirkt, klärt sich, sobald man aufhört zu fragen „in welcher Komponente steht das?" und stattdessen fragt „an welcher Position im Baum, und steht dort noch derselbe Typ?".
Wer das verinnerlicht, hat beide Richtungen in der Hand. Einen Reset bestellt man mit einem key oder einer neuen Position, statt Felder einzeln zurückzusetzen. Erhalt sichert man, indem man Position und Typ stabil hält und den Zustand notfalls nach oben zieht. Und den fiesesten Bug vermeidet man, bevor er entsteht, indem Komponenten dort stehen, wo sie hingehören – auf der Modulebene. Aus etwas, das sich wie Magie anfühlte, wird so eine Entscheidung, die man absichtlich trifft statt zufällig zu erleiden.
Weiterführende Quellen
- Repository introduction-react: https://github.com/mikebild/introduction-react
- React, „Preserving and Resetting State": https://react.dev/learn/preserving-and-resetting-state
- React, „State as a Snapshot": https://react.dev/learn/state-as-a-snapshot
- React, „Render and Commit": https://react.dev/learn/render-and-commit
Kommentare
Kommentar schreiben