React Controllable State Hook: Code and 3 Pitfalls

Every reusable input ends up with the same request. One screen wants to own the value (value plus onChange), another just wants a starting point (defaultValue) and to be told when it changes. A React controllable state hook lets you support both from one component without two code paths: you write the component against a single [value, setValue] pair and the hook decides who owns the state. This tutorial builds that hook, shows the bugs that appear when a prop switches modes, and walks through how Switch and Tabs use the same pattern.
What is the difference between a controlled and an uncontrolled component?
The difference is who owns the value: the parent (controlled) or the component itself (uncontrolled). The React docs on sharing state (opens in a new tab) put it this way: a component is uncontrolled when its important state lives inside it, and controlled when that state is driven by props from its parent.
The native <input> shows both shapes:
That is the trade-off in every React controlled vs uncontrolled component decision. Uncontrolled is less code for the caller. Controlled is what you need when the parent has to validate, reset, sync with the URL, or drive several controls from one place. A library component should not force either choice, so it accepts controlled and uncontrolled props side by side: value, defaultValue and onValueChange. The names follow the value: checked / defaultChecked / onCheckedChange for a switch, open / defaultOpen / onOpenChange for a dialog.
What does a React controllable state hook do?
It gives the component one value and one setter, and hides the ownership question:
- While the
valueprop is defined, the hook returns it and never stores its own copy. The setter only callsonChange, and the parent decides whether to accept the change. - While
valueis undefined, the hook keeps the state itself, seeded fromdefaultValue, and the setter updates it and callsonChange.
Here is the whole hook as it ships in Wingo UI (hooks/use-controllable-state.ts):
Three details carry most of the weight. The setter is stable (useCallback with no dependencies), so it is safe in effect dependency arrays and in memoized children. It reads the latest value and callback from a ref, so a parent that passes a new inline onChange on every render never gets a stale one. And it accepts an updater like useState, so setValue((prev) => !prev) works in both modes.
The ref is written in useInsertionEffect, not during render. Assigning latest.current = ... in the render body looks equivalent, but a render that React throws away (a suspended or interrupted concurrent render) would leave the ref pointing at values that never committed, and the React Compiler lint flags ref writes during render. The insertion effect runs before layout effects, so even a child's useLayoutEffect that calls the setter sees the committed values.
Install it on its own with:
The useControllableState docs list the parameters and the return value.
How do I write a component that supports value, defaultValue and onChange?
Declare the three props, pass them to the hook, and write the rest of the component as if it owned the state. A pressable toggle:
Both call sites now work with the same component:
The demo below runs the same quantity stepper twice. Press plus and minus on both cards: the left one keeps its own count, the right one is controlled by a parent that caps it at 5 and can reset it.
$ npx wingo-ui@latest add use-controllable-stateNotice what the cap costs on the controlled side: nothing inside the stepper. The parent receives onChange(6), stores 5, and the stepper renders whatever value it gets.
What goes wrong when a component switches between controlled and uncontrolled?
The component changes owner without telling anyone, and shows a value nobody expects. The hook decides the mode on every render with value !== undefined, so the caller can flip it by accident. The React reference for input (opens in a new tab) states the rule for native inputs: an input should not switch between controlled and uncontrolled over its lifetime, and React logs a warning when one does. Two of the pitfalls below come from that switch; the third comes from treating defaultValue as a value.
Clearing a value with undefined shows stale internal state
A tab bar bound to optional data is the classic case:
While user.plan is a string, the parent owns the tab, and the hook never writes its internal state. That state still holds whatever it had before the parent took over, often the original defaultValue (for Tabs, the first enabled option). The moment user.plan becomes undefined, the hook returns that old value, and no onChange fires to tell the parent.
The fix is to keep the value defined. Coalesce it (value={user.plan ?? ""}, which Tabs renders with no active tab), or use null for "nothing selected" in your own components: the Wingo UI hook only treats undefined as uncontrolled, so null stays a controlled empty value. Radix makes the same prop !== undefined check. Native inputs are stricter: React wants a string value, never null, so coalesce to "" there.
Loading data starts the component uncontrolled
The mirror image happens on first load:
While the request runs, value is undefined, so the tabs own the state and the user can click around. When the data arrives, the component becomes controlled and jumps to the server value, discarding the tab the user picked. If savePlan already saved that pick, the screen can now show a stale value from the first response. Render the control after the data loads, or keep it uncontrolled and mount it with key={settings.data.plan} and defaultValue={settings.data.plan} once the data is there.
A new defaultValue is ignored
defaultValue seeds useState, so it is read once, on mount. Changing it later does nothing, which is correct (it is a default, not a value) and surprising the first time. When a new default should start the component over, change its key. The stepper demo above does exactly that: the playground's defaultValue control is also the stepper's key. The same trick is how a parent stops controlling a component cleanly: remount it with a new key and pass the current value as defaultValue.
When does the setter call onChange?
Only when the value actually changes. The setter resolves the next value, compares it with the current one using Object.is, and returns early when they match. So pressing an already active tab does not fire onValueChange, and a parent that logs changes sees real changes only. Objects compare by reference, so a new object with the same contents still counts as a change.
Object.is instead of !== matters for numbers. NaN !== NaN is true, so with a !== check a number field that parses its empty text to NaN would fire onChange(NaN) every time it parses that empty text again. Object.is(NaN, NaN) is true, so it reports nothing. It is the same comparison useState uses to skip a re-render.
The call is synchronous, inside the setter, in both modes. In an event handler that means the parent's setState inside onChange is batched with the component's own update, and both render together.
One subtlety is worth knowing. In uncontrolled mode, two updaters in the same event build on each other, like useState: setValue((n) => n + 1) twice adds 2. In controlled mode the value only changes when the parent re-renders, so both updaters see the same prev and report the same number twice. Radix behaves the same way, because its controlled setter also resolves the updater against the current prop. If a component needs to apply several steps at once, compute the final value and call the setter once.
How do Switch and Tabs use the hook?
152 of the 227 files in components/ui import useControllableState (counted in the repository in October 2026). The convention is a value prop, a default prop and a change callback named after the value. Tabs and Switch show the two ways to wire it.
Tabs: the hook owns the value
The simple Tabs API passes value, defaultValue and onValueChange straight to the hook, with one library-specific twist: when there is no defaultValue, it starts on the first enabled option. Uncontrolled, it is one line:
Controlled, the parent holds the tab, for example to keep it in sync with something else on the page (same options as above):
Because the setter goes through the hook, Tabs can also change the value on its own and still keep a controlled parent informed: when the active tab is removed from options, it calls the setter with the first enabled tab, and a controlled parent receives that through onValueChange.
Switch: the hook stores, the component notifies
Switch uses the hook without an onChange:
The reason is the return value. onCheckedChange is typed (checked: boolean) => void | Promise<unknown>, and the switch needs that promise: it flips at once, shows a spinner in the thumb while the save runs, and flips back, shakes and shows the error text when the promise rejects. The hook calls onChange and throws away what it returns, so the switch calls onCheckedChange itself and uses the hook only for storage. That is a useful rule for your own components: when the callback's result matters, call it directly and let the hook keep the value.
In the demo below, the Simulate an error button flips the first switch with a save that fails. Flip any switch yourself and the change sticks.
$ npx wingo-ui@latest add switchIn this uncontrolled setup the switch holds the value and the parent never stores it; the promise only decides whether the flip sticks. For a form that needs the value in parent state, pass checked and onCheckedChange instead and set checked after the save succeeds.
How is it different from Radix useControllableState?
Radix useControllableState has the same shape with different details. It is the hook Radix Primitives use internally, published on npm as @radix-ui/react-use-controllable-state (version 1.3.0 as of October 2026), so every shadcn/ui component built on Radix inherits its behavior. Compared against the Radix source on GitHub (opens in a new tab) as of October 2026:
Two rows change how you write code around it. Radix's setter lists prop in its useCallback dependencies, so in controlled mode it is a new function every time the value changes; an effect that depends on it re-runs on every change. And because Radix reports uncontrolled changes from an effect, a parent that stores the value in onChange renders one commit later than the component.
The Radix warning is the one thing we would point to as a reason to prefer it: it catches the mode switches described above during development. Our hook stays a single file you own, with names that match the props it serves.
Next steps
Copy the hook with npx wingo-ui@latest add use-controllable-state and use it in your next component that needs a value prop. Switch and Tabs are free too: the Tabs component docs list the value, defaultValue and onValueChange props, and the Switch component docs cover the async onCheckedChange save. If you are moving components to React 19 at the same time, ref as a prop in React 19 covers the other half of a modern component API, and the Tailwind v4 design tokens guide is the starting point for the rest of the Next.js and Tailwind CSS articles.
FAQ
What is the difference between a controlled and an uncontrolled component in React?
A controlled component shows the value its parent passes and reports changes through a callback, so the parent owns the state. An uncontrolled component keeps its own state, usually seeded from a defaultValue prop, and the parent only hears about changes.
What does useControllableState do?
It returns a value and a setter like useState. While the value prop is defined it returns that prop and the setter only calls onChange; while it is undefined it stores the value itself, starting from defaultValue.
Can a component switch from uncontrolled to controlled?
It should not. React warns when an input does it, Radix warns in development, and the Wingo UI hook simply follows whatever it gets, which shows a stale value when control is handed back. Pick one mode per instance and use null, not undefined, for an empty controlled value.
Why does changing defaultValue not update my component?
defaultValue is read once, on mount, exactly like the initial value of useState. To start over from a new default, give the component a new key, or control it with value.
How is the Wingo UI hook different from Radix useControllableState?
Radix names the options prop, defaultProp, onChange and caller, compares with !== and calls onChange for uncontrolled changes in an effect after render. The Wingo UI hook takes value, defaultValue and onChange, compares with Object.is and calls onChange synchronously from the setter, with no development warning.
Is the useControllableState hook free?
Yes. The hook is free, and so are Switch and Tabs, which depend on it. Install it with npx wingo-ui@latest add use-controllable-state.
- React
- Hooks
- State
- TypeScript
- Component API


