useControllableState
A useState-shaped hook that lets one component serve both controlled (value/onChange) and uncontrolled (defaultValue) callers, with a stable setter and prop-accurate updaters.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-controllable-state.jsonPrompt
Build a React + TypeScript "useControllableState" hook (React only, no other dependencies).
Contract
- `useControllableState<T>({ value, defaultValue, onChange }):
[state: T, setState: (next: T | ((prev: T) => T)) => void]`.
- `value?: T` — the controlled value. Anything other than `undefined` puts that instance in
controlled mode. `undefined` is reserved as the "uncontrolled" sentinel (the same convention a
native <input> uses), so a controlled *empty* value must be expressed as `null`.
- `defaultValue: T` — required, read once on mount exactly like `useState`'s initial argument.
Required on purpose: an optional default would quietly hand back `undefined` state and
reintroduce the very ambiguity the hook exists to remove.
- `onChange?: (value: T) => void` — called with the requested next value.
- Returns a 2-tuple shaped like `useState`'s, so a component that already calls `useState` can
migrate by swapping the call and adding three props.
Behavior
- Controlled (`value !== undefined`): the returned state IS the prop, and the internal state does
not participate in rendering at all. `setState` resolves an updater against the prop and, if the
result differs (`Object.is`), calls `onChange` — and nothing else. If the parent ignores that
call, or answers it with a different value, the rendered output does not move. That is what makes
"reject any rating above 3" or "wait for the server to confirm" expressible instead of a fight.
- Uncontrolled (`value === undefined`): `setState` forwards the raw `SetStateAction` to the internal
`useState` setter, so React's own queue keeps consecutive updater calls inside one tick correct —
`setState(n => n + 1)` twice really does add 2. `onChange` is then emitted from a commit effect
that compares the committed value with the last emitted one; it must NOT be called from inside
the state updater, because an updater has to stay pure (StrictMode invokes it twice, which would
emit twice).
- `setState` keeps one identity for the component's lifetime, so consumers can drop it into a
dependency array or hand it to a memoized child. Everything it needs (current value, mode, latest
`onChange`) is read at CALL time out of a single latest-ref that is synced in
`useInsertionEffect` — the earliest commit-phase hook React offers, so a handler firing after
that commit already sees the new value. Do not put `onChange` in a dependency array: consumers
pass inline arrows, so that would mint a new `setState` every render and re-run every consumer
effect depending on it. Do not read or write the ref during render.
- Switching modes mid-life (`value` going from `undefined` to defined or back) logs exactly one
`console.warn` when `process.env.NODE_ENV !== "production"`, then proceeds — whichever value
takes over is stale, but a development warning must never throw.
- Limits to state rather than hide: in controlled mode two `setState` calls in the same tick both
resolve against the same prop, because there is no queue to append to (React's own controlled
inputs behave identically); and if `T` is itself a function type, wrap it — `setState(() => fn)` —
exactly as with `useState`.
Rendering & styling
- The hook renders nothing and touches no DOM, so it carries no styles. It is SSR-safe: no
`window`/`document`/`matchMedia` access, and the first server and client render agree
(`defaultValue` uncontrolled, the prop controlled). Consuming components own all markup — style
selected/unselected states with semantic tokens (`bg-primary`, `fill-primary`, `bg-muted`,
`text-muted-foreground`, `border`, `ring`), keep a real native control (radio / checkbox / input)
in the tree so keyboard and screen-reader behaviour comes for free, and respect
`prefers-reduced-motion` for any transition tied to the value.
Customization levers
- Rename the triad to match the host convention — `checked`/`defaultChecked`/`onCheckedChange`,
`open`/`defaultOpen`/`onOpenChange`, `selected`/`onSelectionChange`. Only labels change.
- Swap the equality used before emitting `onChange`: `Object.is` is right for primitives; for
object or array values compare a derived key instead, or drop the guard entirely so every
request is reported even when it resolves to an equal value.
- Make `onChange` synchronous in uncontrolled mode by resolving the updater against the latest-ref
and calling the setter plus `onChange` inline — in exchange, two updater calls in one tick
collapse into one. Only worth it when a caller must observe the change before the commit.
- Add a `caller`/`name` option interpolated into the mode-switch warning when a page has many
instances and the bare message is ambiguous.
- Return a third element (`isControlled`) only if consumers genuinely need to disable UI that makes
no sense while a parent owns the value; leaving it out keeps components from branching on mode.Concepts
- Controlled means the prop is the only source of truth — under control the hook's internal state is deliberately not rendered, so
setStatedegrades to "ask the parent". A component that quietly updated itself here would be advertising a controlled API while behaving uncontrolled, and every parent trying to veto, clamp, or await a value would be fighting it. undefinedis the mode sentinel,nullis an empty value — the mode is decided byvalue !== undefined, matching native<input>. That is whydefaultValueis required and why a controlled component with nothing selected must passnull: an accidentalundefinedsilently demotes the instance to uncontrolled.- Latest-ref, so the setter is identity-stable — the current value, the mode, and the newest
onChangeall live in one ref that is written inuseInsertionEffect(the earliest commit-phase hook, before layout effects and paint). Closing over them in a dependency array instead would produce a freshsetStateon every render, because consumers passonChangeas an inline arrow — and every effect depending on that setter would re-run. - Updaters resolve against what is rendered — in controlled mode
setState(v => v + 1)reads thevalueprop, not the internal state. Read the internal copy instead and the arithmetic is done on a number that has not been on screen since mount; the classic symptom issetState(v => !v)"doing nothing" in a controlled component. - The uncontrolled
onChangeis emitted from a commit effect — the state updater stays pure (StrictMode calls it twice, so emitting inside it would double-report), and the rawSetStateActiongoes straight to React's queue, which is what keeps twosetState(n => n + 1)calls in one tick adding 2. The cost is that uncontrolledonChangelands one commit after the change, not inline. - Mode switching warns, never throws — flipping
valuebetweenundefinedand defined mid-life makes whichever value takes over stale. That earns oneconsole.warnin development and nothing in production; crashing the page over a prop mistake would be worse than the mistake.