Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseControllableStateOptions<T> {
  /**
   * The controlled value. Anything other than `undefined` puts the hook into
   * controlled mode: the returned state is always this value, and the internal
   * state stops taking part in rendering entirely.
   *
   * Model "no value" with `null`, never `undefined` — `undefined` is the
   * sentinel for "this instance is uncontrolled", the same convention a native
   * `<input>` uses.
   */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-controllable-state.json

Prompt

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 setState degrades 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.
  • undefined is the mode sentinel, null is an empty value — the mode is decided by value !== undefined, matching native <input>. That is why defaultValue is required and why a controlled component with nothing selected must pass null: an accidental undefined silently demotes the instance to uncontrolled.
  • Latest-ref, so the setter is identity-stable — the current value, the mode, and the newest onChange all live in one ref that is written in useInsertionEffect (the earliest commit-phase hook, before layout effects and paint). Closing over them in a dependency array instead would produce a fresh setState on every render, because consumers pass onChange as 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 the value prop, 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 is setState(v => !v) "doing nothing" in a controlled component.
  • The uncontrolled onChange is emitted from a commit effect — the state updater stays pure (StrictMode calls it twice, so emitting inside it would double-report), and the raw SetStateAction goes straight to React's queue, which is what keeps two setState(n => n + 1) calls in one tick adding 2. The cost is that uncontrolled onChange lands one commit after the change, not inline.
  • Mode switching warns, never throws — flipping value between undefined and defined mid-life makes whichever value takes over stale. That earns one console.warn in development and nothing in production; crashing the page over a prop mistake would be worse than the mistake.

On This Page