# React Controllable State Hook: Code and 3 Pitfalls

> Build a React controllable state hook so one component takes value or defaultValue. Full TypeScript code, three real pitfalls, and how the Radix hook differs.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-controllable-state-hook

## TL;DR

A React controllable state hook returns [value, setValue] like useState, but follows the value prop whenever the parent passes one and keeps its own state, seeded from defaultValue, when it does not. The component calls the setter on every interaction, and the hook stores the value only while uncontrolled and calls onChange in both modes. Keep each instance in one mode for its whole life: clearing a value with undefined silently hands control back to stale internal state.

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](https://wingo-ui.com/components/switch) and [Tabs](https://wingo-ui.com/components/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](https://react.dev/learn/sharing-state-between-components) 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:

```tsx
// uncontrolled: the input keeps the text, the parent only sets where it starts
<input defaultValue="Emma Carter" />

// controlled: the parent keeps the text and must update it on every keystroke
<input value={name} onChange={(event) => setName(event.target.value)} />
```

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 `value` prop is defined, the hook returns it and never stores its own copy. The setter only calls `onChange`, and the parent decides whether to accept the change.
- While `value` is undefined, the hook keeps the state itself, seeded from `defaultValue`, and the setter updates it and calls `onChange`.

Here is the whole hook as it ships in Wingo UI (`hooks/use-controllable-state.ts`):

```ts
import { useCallback, useInsertionEffect, useRef, useState, type SetStateAction } from "react";

export type UseControllableStateParams<T> = {
  value?: T;
  defaultValue: T;
  onChange?: (value: T) => void;
};

export function useControllableState<T>({
  value,
  defaultValue,
  onChange,
}: UseControllableStateParams<T>): [T, (next: SetStateAction<T>) => void] {
  const [uncontrolled, setUncontrolled] = useState(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : uncontrolled;

  // the latest render's values, read by the stable setter
  const latest = useRef({ current, controlled, onChange });
  useInsertionEffect(() => {
    latest.current = { current, controlled, onChange };
  });

  const setValue = useCallback((next: SetStateAction<T>) => {
    const { current: prev, controlled: isControlled, onChange: notify } = latest.current;
    const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
    if (Object.is(resolved, prev)) return;
    if (!isControlled) {
      latest.current = { ...latest.current, current: resolved };
      setUncontrolled(resolved);
    }
    notify?.(resolved);
  }, []);

  return [current, setValue];
}
```

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:

```bash
npx wingo-ui@latest add use-controllable-state
```

The [useControllableState docs](https://wingo-ui.com/components/use-controllable-state) 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:

```tsx
"use client";

import { useControllableState } from "@/hooks/use-controllable-state";

type ToggleProps = {
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
};

export function Toggle({ pressed, defaultPressed = false, onPressedChange }: ToggleProps) {
  const [on, setOn] = useControllableState({
    value: pressed,
    defaultValue: defaultPressed,
    onChange: onPressedChange,
  });

  return (
    <button type="button" aria-pressed={on} onClick={() => setOn((prev) => !prev)}>
      {on ? "On" : "Off"}
    </button>
  );
}
```

Both call sites now work with the same component:

```tsx
// uncontrolled: starts on, owns its state
<Toggle defaultPressed onPressedChange={(on) => console.log(on)} />

// controlled: the parent owns it and can refuse a change
<Toggle pressed={muted} onPressedChange={(next) => setMuted(next)} />
```

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.

> Live demo (useControllableState): Press plus on both steppers: the uncontrolled one counts to 9, the controlled one stops at 5 because its parent refuses higher values. Try it at [useControllableState](https://wingo-ui.com/components/use-controllable-state) and install it with `npx wingo-ui@latest add use-controllable-state`.

Notice 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](https://react.dev/reference/react-dom/components/input) 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:

```tsx
// wrong: when the user has no plan, the tabs become uncontrolled
<Tabs options={plans} value={user.plan} onValueChange={setPlan} />
```

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:

```tsx
// wrong: uncontrolled until the request finishes, then the server value takes over
<Tabs options={plans} value={settings.data?.plan} onValueChange={savePlan} />
```

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:

```tsx
import { Tabs, type TabsOption } from "@/components/ui/tabs";

const options: TabsOption[] = [
  { value: "active", label: "Active", badge: 12, content: <p>12 listings live in New York.</p> },
  { value: "pending", label: "Pending", badge: 3, content: <p>3 listings waiting for review.</p> },
  { value: "sold", label: "Sold", content: <p>Last sale: Sep 2, 2026.</p> },
];

export function MyListings() {
  return <Tabs options={options} defaultValue="pending" aria-label="My listings" />;
}
```

Controlled, the parent holds the tab, for example to keep it in sync with something else on the page (same `options` as above):

```tsx
"use client";

import { useState } from "react";
import { Tabs } from "@/components/ui/tabs";

export function ListingsPanel() {
  const [tab, setTab] = useState("active");
  return <Tabs options={options} value={tab} onValueChange={setTab} aria-label="My listings" />;
}
```

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](https://wingo-ui.com/components/switch) uses the hook without an `onChange`:

```ts
const [checked, setChecked] = useControllableState({ value: checkedProp, defaultValue: defaultChecked });
```

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.

```tsx
"use client";

import { Switch } from "@/components/ui/switch";

async function saveSetting(key: string, on: boolean) {
  const res = await fetch("/api/settings", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ [key]: on }),
  });
  if (!res.ok) throw new Error("save failed");
}

export function OffersSetting() {
  return (
    <Switch
      label="Price offers"
      defaultChecked
      errorText="Could not save. Try again."
      onCheckedChange={(on) => saveSetting("offers", on)}
    />
  );
}
```

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.

> Live demo (Switch): Press Simulate an error: the first switch flips at once, spins in its thumb, then rolls back and shows the error. Try it at [Switch](https://wingo-ui.com/components/switch) and install it with `npx wingo-ui@latest add switch`.

In 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](https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx) as of October 2026:

| | Wingo UI `useControllableState` | Radix `useControllableState` |
| --- | --- | --- |
| Options | `value`, `defaultValue`, `onChange` | `prop`, `defaultProp`, `onChange`, `caller` |
| Controlled when | `value !== undefined` | `prop !== undefined` |
| Change check | `Object.is` | `!==` |
| Uncontrolled `onChange` | called in the setter | called from an effect after the render |
| Setter identity | stable for the component's life | new whenever the prop or the mode changes |
| Mode switch warning | none | a `console.warn` in development, naming `caller` |
| Install | copied into your project as source | an npm package |

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](https://wingo-ui.com/components/tabs) list the `value`, `defaultValue` and `onValueChange` props, and the [Switch component docs](https://wingo-ui.com/components/switch) cover the async `onCheckedChange` save. If you are moving components to React 19 at the same time, [ref as a prop in React 19](https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop) covers the other half of a modern component API, and the [Tailwind v4 design tokens guide](https://wingo-ui.com/blog/tailwind-v4-design-tokens) is the starting point for the rest of the [Next.js and Tailwind CSS articles](https://wingo-ui.com/blog/category/nextjs-tailwind).

## Components in this post

- [useControllableState](https://wingo-ui.com/components/use-controllable-state) (Free): A React hook for controlled and uncontrolled state: it follows a value prop when you pass one and keeps its own state when you do not. Install: `npx wingo-ui@latest add use-controllable-state`
- [Switch](https://wingo-ui.com/components/switch) (Free): A React toggle switch for settings that apply at once: a spring thumb that stretches and drags, thumb icons, and async saves that roll back. Install: `npx wingo-ui@latest add switch`
- [Tabs](https://wingo-ui.com/components/tabs) (Free): React tabs with a sliding indicator that switch views of one page: underline for sections, pill for filters, segmented for a compact switch. Install: `npx wingo-ui@latest add tabs`

## 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.

---

Source: https://wingo-ui.com/blog/react-controllable-state-hook
