React Controllable State Hook: Code und 3 Fallstricke

Jedes wiederverwendbare Eingabefeld bekommt irgendwann dieselbe Anfrage. Ein Screen will den Wert selbst verwalten (value plus onChange), ein anderer braucht nur einen Startwert (defaultValue) und will nur erfahren, wenn er sich ändert. Mit einem React Controllable State Hook unterstützt du beides in einer Komponente, ohne zwei Codepfade: Du schreibst die Komponente gegen ein einziges Paar [value, setValue], und der Hook entscheidet, wem der State gehört. Dieses Tutorial baut den Hook, zeigt die Bugs, die entstehen, wenn ein Prop den Modus wechselt, und erklärt, wie Switch und Tabs dasselbe Muster nutzen.
Was ist der Unterschied zwischen controlled und uncontrolled Komponenten?
Der Unterschied liegt darin, wem der Wert gehört: der Elternkomponente (controlled) oder der Komponente selbst (uncontrolled). In deutschen Texten heißen sie auch kontrollierte und unkontrollierte Komponenten. Die React-Doku zum Teilen von State (opens in a new tab) beschreibt es so: Eine Komponente ist uncontrolled, wenn ihr wichtiger State in ihr selbst liegt, und controlled, wenn Props der Elternkomponente diesen State bestimmen.
Das native <input> zeigt beide Formen:
Das ist der Zielkonflikt bei jeder Entscheidung „React controlled vs uncontrolled“. Uncontrolled bedeutet weniger Code für den Aufrufer. Controlled brauchst du, wenn die Elternkomponente validieren, zurücksetzen, mit der URL synchronisieren oder mehrere Controls von einer Stelle aus steuern muss. Eine Komponente aus einer Bibliothek sollte keine der beiden Varianten erzwingen. Deshalb nimmt sie controlled und uncontrolled Props nebeneinander an: value, defaultValue und onValueChange. Die Namen richten sich nach dem Wert: checked / defaultChecked / onCheckedChange bei einem Switch, open / defaultOpen / onOpenChange bei einem Dialog.
Was macht ein React Controllable State Hook?
Er gibt der Komponente einen Wert und einen Setter und versteckt die Frage, wem der State gehört:
- Solange der
value-Prop definiert ist, gibt der Hook ihn zurück und speichert nie eine eigene Kopie. Der Setter ruft nuronChangeauf, und die Elternkomponente entscheidet, ob sie die Änderung übernimmt. - Solange
valueundefined ist, hält der Hook den State selbst, beginnend mitdefaultValue, und der Setter aktualisiert ihn und ruftonChangeauf.
Hier ist der komplette Hook, wie er in Wingo UI ausgeliefert wird (hooks/use-controllable-state.ts):
Auf drei Details kommt es an. Der Setter ist stabil (useCallback ohne Dependencies), also kannst du ihn bedenkenlos in Dependency-Arrays von Effects und in memoisierten Kindkomponenten verwenden. Er liest den aktuellen Wert und den Callback aus einer Ref, sodass eine Elternkomponente, die bei jedem Render ein neues Inline-onChange übergibt, nie einen veralteten bekommt. Und er nimmt wie useState eine Updater-Funktion an, also funktioniert setValue((prev) => !prev) in beiden Modi.
Die Ref wird in useInsertionEffect geschrieben, nicht während des Renders. latest.current = ... direkt im Render-Body sieht gleichwertig aus. Ein Render, den React verwirft (ein suspendierter oder unterbrochener Concurrent Render), würde die Ref aber auf Werten stehen lassen, die nie committet wurden, und der Lint des React Compilers markiert Ref-Schreibzugriffe während des Renders. Der Insertion Effect läuft vor den Layout Effects. Selbst ein useLayoutEffect in einer Kindkomponente, der den Setter aufruft, sieht also die committeten Werte.
Installier ihn einzeln mit:
Die Doku zu useControllableState listet die Parameter und den Rückgabewert.
Wie schreibe ich eine Komponente, die value, defaultValue und onChange unterstützt?
Deklarier die drei Props, gib sie an den Hook weiter und schreib den Rest der Komponente so, als gehöre ihr der State. Ein Toggle-Button:
Jetzt funktionieren beide Aufrufe mit derselben Komponente:
Die Demo unten zeigt denselben Mengen-Stepper zweimal. Drück auf beiden Karten Plus und Minus: Der linke zählt selbst, der rechte wird von einer Elternkomponente gesteuert, die ihn bei 5 deckelt und zurücksetzen kann.
$ npx wingo-ui@latest add use-controllable-stateBeachte, was die Obergrenze auf der controlled Seite kostet: nichts im Stepper. Die Elternkomponente bekommt onChange(6), speichert 5, und der Stepper rendert den value, den er bekommt.
Was geht schief, wenn eine Komponente zwischen controlled und uncontrolled wechselt?
Die Komponente wechselt den Besitzer, ohne es jemandem zu sagen, und zeigt einen Wert, mit dem niemand rechnet. Der Hook entscheidet bei jedem Render mit value !== undefined über den Modus, also kann der Aufrufer ihn versehentlich umschalten. Die React-Referenz zu input (opens in a new tab) nennt die Regel für native Inputs: Ein Input soll während seiner gesamten Lebensdauer nicht zwischen controlled und uncontrolled wechseln, und React loggt eine Warnung, wenn es das doch tut. Zwei der folgenden Fallstricke kommen von diesem Wechsel, der dritte davon, dass defaultValue wie ein Wert behandelt wird.
Ein mit undefined geleerter Wert zeigt veralteten internen State
Der klassische Fall ist eine Tab-Leiste, die an optionale Daten gebunden ist:
Solange user.plan ein String ist, gehört der Tab der Elternkomponente, und der Hook schreibt nie in seinen internen State. Dieser State enthält aber noch, was er hatte, bevor die Elternkomponente übernommen hat, oft den ursprünglichen defaultValue (bei Tabs die erste aktivierte Option). Sobald user.plan zu undefined wird, gibt der Hook diesen alten Wert zurück, und kein onChange informiert die Elternkomponente.
Die Lösung: Halt den Wert definiert. Ersetz ihn durch einen Fallback (value={user.plan ?? ""}, das Tabs ohne aktiven Tab rendert), oder nimm in deinen eigenen Komponenten null für „nichts ausgewählt“. Der Hook von Wingo UI behandelt nur undefined als uncontrolled, also bleibt null ein controlled leerer Wert. Radix macht denselben Check mit prop !== undefined. Native Inputs sind strenger: React will als value einen String, nie null, also nimm dort "" als Fallback.
Beim Laden startet die Komponente uncontrolled
Das Gegenstück tritt beim ersten Laden auf:
Während der Request läuft, ist value undefined. Die Tabs halten den State also selbst, und der Nutzer kann schon herumklicken. Kommen die Daten an, wird die Komponente controlled und springt auf den Serverwert; der Tab, den der Nutzer gewählt hat, ist weg. Hat savePlan diese Auswahl schon gespeichert, zeigt der Screen jetzt womöglich einen veralteten Wert aus der ersten Antwort. Render das Control erst, wenn die Daten da sind, oder lass es uncontrolled und mounte es mit key={settings.data.plan} und defaultValue={settings.data.plan}, sobald die Daten vorliegen.
Ein neuer defaultValue wird ignoriert
defaultValue ist der Startwert von useState und wird deshalb einmal gelesen, beim Mount. Eine spätere Änderung bewirkt nichts. Das ist richtig (es ist ein Default, kein Wert) und beim ersten Mal trotzdem überraschend. Soll ein neuer Default die Komponente neu starten, ändere ihren key. Die Stepper-Demo oben macht genau das: Das defaultValue-Control im Playground ist zugleich der key des Steppers. Mit demselben Trick gibt eine Elternkomponente die Kontrolle sauber ab: Sie mountet die Komponente mit einem neuen key neu und übergibt den aktuellen Wert als defaultValue.
Wann ruft der Setter onChange auf?
Nur wenn sich der Wert wirklich ändert. Der Setter berechnet den nächsten Wert, vergleicht ihn per Object.is mit dem aktuellen und bricht ab, wenn beide gleich sind. Ein Klick auf einen schon aktiven Tab löst also kein onValueChange aus, und eine Elternkomponente, die Änderungen loggt, sieht nur echte Änderungen. Objekte werden per Referenz verglichen: Ein neues Objekt mit gleichem Inhalt zählt trotzdem als Änderung.
Object.is statt !== ist bei Zahlen wichtig. NaN !== NaN ist true. Mit einem !==-Check würde ein Zahlenfeld, das seinen leeren Text zu NaN parst, also jedes Mal onChange(NaN) auslösen, wenn es diesen leeren Text erneut parst. Object.is(NaN, NaN) ist true, also meldet der Hook nichts. Es ist derselbe Vergleich, mit dem useState einen Re-Render überspringt.
Der Aufruf ist synchron, im Setter, in beiden Modi. In einem Event-Handler heißt das: Das setState der Elternkomponente in onChange wird mit dem Update der Komponente gebatcht, und beide rendern zusammen.
Eine Feinheit solltest du kennen. Im uncontrolled Modus bauen zwei Updater im selben Event aufeinander auf, wie bei useState: Zweimal setValue((n) => n + 1) addiert 2. Im controlled Modus ändert sich der Wert erst, wenn die Elternkomponente neu rendert. Beide Updater sehen also dasselbe prev und melden zweimal dieselbe Zahl. Radix verhält sich genauso, weil auch sein controlled Setter den Updater gegen den aktuellen Prop auflöst. Muss eine Komponente mehrere Schritte auf einmal anwenden, berechne den Endwert und ruf den Setter einmal auf.
Wie nutzen Switch und Tabs den Hook?
152 der 227 Dateien in components/ui importieren useControllableState (im Oktober 2026 im Repository gezählt). Die Konvention ist ein Wert-Prop, ein Default-Prop und ein Change-Callback, benannt nach dem Wert. Tabs und Switch zeigen die zwei Wege, das zu verdrahten.
Tabs: Der Hook verwaltet den Wert
Die einfache Tabs-API reicht value, defaultValue und onValueChange direkt an den Hook weiter, mit einer Besonderheit der Bibliothek: Fehlt defaultValue, startet Tabs auf der ersten aktivierten Option. Uncontrolled ist das eine Zeile:
Im controlled Modus hält die Elternkomponente den Tab, zum Beispiel um ihn mit etwas anderem auf der Seite synchron zu halten (dieselben options wie oben):
Weil der Setter über den Hook läuft, kann Tabs den Wert auch selbst ändern und eine controlled Elternkomponente trotzdem informieren: Verschwindet der aktive Tab aus options, ruft Tabs den Setter mit dem ersten aktivierten Tab auf, und eine controlled Elternkomponente bekommt das über onValueChange mit.
Switch: Der Hook speichert, die Komponente meldet
Switch nutzt den Hook ohne onChange:
Der Grund ist der Rückgabewert. onCheckedChange ist als (checked: boolean) => void | Promise<unknown> typisiert, und der Switch braucht dieses Promise: Er schaltet sofort um, zeigt während des Speicherns einen Spinner im Thumb und schaltet zurück, wackelt und zeigt den Fehlertext, wenn das Promise rejected wird. Der Hook ruft onChange auf und verwirft, was zurückkommt. Deshalb ruft der Switch onCheckedChange selbst auf und nutzt den Hook nur zum Speichern. Das ist eine brauchbare Regel für deine eigenen Komponenten: Wenn das Ergebnis des Callbacks zählt, ruf ihn direkt auf und lass den Hook den Wert halten.
In der Demo unten schaltet der Button „Simulate an error“ den ersten Switch mit einem Speichervorgang um, der fehlschlägt. Schaltest du selbst einen Switch um, bleibt die Änderung bestehen.
$ npx wingo-ui@latest add switchIn diesem uncontrolled Aufbau hält der Switch den Wert, und die Elternkomponente speichert ihn nie; das Promise entscheidet nur, ob das Umschalten bleibt. Braucht ein Formular den Wert im State der Elternkomponente, übergib stattdessen checked und onCheckedChange und setz checked, wenn das Speichern geklappt hat.
Wie unterscheidet sich der Hook von Radix useControllableState?
Radix useControllableState hat dieselbe Grundform, unterscheidet sich aber im Detail. Radix Primitives nutzen den Hook intern, und er ist auf npm als @radix-ui/react-use-controllable-state veröffentlicht (Version 1.3.0, Stand Oktober 2026). Jede shadcn/ui-Komponente, die auf Radix aufbaut, erbt also sein Verhalten. Verglichen mit dem Radix-Quellcode auf GitHub (opens in a new tab), Stand Oktober 2026:
Zwei Zeilen ändern, wie du Code drumherum schreibst. Der Setter von Radix führt prop in seinen useCallback-Dependencies. Im controlled Modus ist er also bei jeder Wertänderung eine neue Funktion, und ein Effect, der von ihm abhängt, läuft bei jeder Änderung erneut. Und weil Radix uncontrolled Änderungen aus einem Effect meldet, rendert eine Elternkomponente, die den Wert in onChange speichert, einen Commit später als die Komponente.
Die Warnung ist der einzige Punkt, den wir als Grund nennen würden, Radix vorzuziehen: Sie fängt die oben beschriebenen Moduswechsel schon im Development ab. Unser Hook bleibt eine einzige Datei, die dir gehört, mit Namen, die zu den Props passen, die er bedient.
Nächste Schritte
Kopier den Hook mit npx wingo-ui@latest add use-controllable-state und nutz ihn in deiner nächsten Komponente, die einen Wert-Prop braucht. Switch und Tabs sind ebenfalls kostenlos: Die Doku zur Tabs-Komponente listet die Props value, defaultValue und onValueChange, und die Doku zur Switch-Komponente erklärt das asynchrone Speichern mit onCheckedChange. Wenn du deine Komponenten gleichzeitig auf React 19 umstellst, behandelt ref als Prop in React 19 die andere Hälfte einer modernen Komponenten-API, und der Guide zu Design Tokens in Tailwind v4 ist der Einstieg in die übrigen Artikel zu Next.js und Tailwind CSS.
Häufige Fragen
Was ist der Unterschied zwischen controlled und uncontrolled Komponenten in React?
Eine controlled Komponente zeigt den Wert, den die Elternkomponente übergibt, und meldet Änderungen über einen Callback; der State gehört also der Elternkomponente. Eine uncontrolled Komponente hält ihren State selbst, meist mit einem defaultValue-Prop als Startwert, und die Elternkomponente erfährt nur von Änderungen.
Was macht useControllableState?
Der Hook gibt wie useState einen Wert und einen Setter zurück. Solange der value-Prop definiert ist, gibt er diesen Prop zurück und der Setter ruft nur onChange auf; ist er undefined, speichert der Hook den Wert selbst, beginnend mit defaultValue.
Darf eine Komponente von uncontrolled zu controlled wechseln?
Sie sollte es nicht. React warnt, wenn ein Input das tut, Radix warnt im Development, und der Hook von Wingo UI folgt einfach dem, was er bekommt, und zeigt dann einen veralteten Wert, wenn die Kontrolle zurückgeht. Leg dich pro Instanz auf einen Modus fest und nimm null statt undefined für einen leeren controlled Wert.
Warum aktualisiert sich meine Komponente nicht, wenn sich defaultValue ändert?
defaultValue wird einmal gelesen, beim Mount, genau wie der Startwert von useState. Um mit einem neuen Default neu zu starten, gib der Komponente einen neuen key oder steuere sie über value.
Wie unterscheidet sich der Hook von Wingo UI von Radix useControllableState?
Radix nennt die Optionen prop, defaultProp, onChange und caller, vergleicht mit !== und ruft onChange bei uncontrolled Änderungen in einem Effect nach dem Render auf. Der Hook von Wingo UI nimmt value, defaultValue und onChange, vergleicht mit Object.is und ruft onChange synchron im Setter auf, ohne Warnung im Development.
Ist der Hook useControllableState kostenlos?
Ja. Der Hook ist kostenlos, ebenso Switch und Tabs, die auf ihm aufbauen. Installier ihn mit npx wingo-ui@latest add use-controllable-state.
- React
- Hooks
- State
- TypeScript
- Komponenten-API


