Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Next.js and Tailwind CSS
  4. React Controllable State Hook: Code and 3 Pitfalls
Auf Deutsch lesen
  1. Blog
  2. Next.js and Tailwind CSS
  3. React Controllable State Hook: Code and 3 Pitfalls
Next.js and Tailwind CSS
Next.js and Tailwind CSS

React Controllable State Hook: Code and 3 Pitfalls

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. What is the difference between a controlled and an uncontrolled component?
  2. What does a React controllable state hook do?
  3. How do I write a component that supports value, defaultValue and onChange?
  4. What goes wrong when a component switches between controlled and uncontrolled?
    1. Clearing a value with undefined shows stale internal state
    2. Loading data starts the component uncontrolled
    3. A new defaultValue is ignored
  5. When does the setter call onChange?
  6. How do Switch and Tabs use the hook?
    1. Tabs: the hook owns the value
    2. Switch: the hook stores, the component notifies
  7. How is it different from Radix useControllableState?
  8. Next steps
  9. FAQ
    1. What is the difference between a controlled and an uncontrolled component in React?
    2. What does useControllableState do?
    3. Can a component switch from uncontrolled to controlled?
    4. Why does changing defaultValue not update my component?
    5. How is the Wingo UI hook different from Radix useControllableState?
    6. Is the useControllableState hook free?

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.

Published Oct 16, 2026

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:

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

Press plus on both steppers: the uncontrolled one counts to 9, the controlled one stops at 5 because its parent refuses higher values
$ npx wingo-ui@latest add use-controllable-state
FreeuseControllableState docs

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 (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:

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

Press Simulate an error: the first switch flips at once, spins in its thumb, then rolls back and shows the error
$ npx wingo-ui@latest add switch
FreeSwitch docs

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 (opens in a new tab) as of October 2026:

Wingo UI useControllableStateRadix useControllableState
Optionsvalue, defaultValue, onChangeprop, defaultProp, onChange, caller
Controlled whenvalue !== undefinedprop !== undefined
Change checkObject.is!==
Uncontrolled onChangecalled in the settercalled from an effect after the render
Setter identitystable for the component's lifenew whenever the prop or the mode changes
Mode switch warningnonea console.warn in development, naming caller
Installcopied into your project as sourcean 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 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.

Components in this post

  • useControllableState

    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.

    Free
  • Switch

    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.

    Free
  • Tabs

    React tabs with a sliding indicator that switch views of one page: underline for sections, pill for filters, segmented for a compact switch.

    Free

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

React Settings Page: Section Nav, Save Bar, Danger Zone

Build a React settings page with a section nav on desktop, an iOS-style push list on phones, a save bar for unsaved changes and a typed danger zone.

SRSerban Rusu·Oct 16, 2026·11 min read
Mobile UI in React

React Swipeable Tabs: Pages That Follow the Thumb

Build React swipeable tabs on native scroll snap: an indicator that tracks the finger, vertical scroll that never flips the tab, keyboard support, no old deps.

SRSerban Rusu·Oct 16, 2026·10 min read
Component guides

Framer Motion Spring Presets: Stiffness and Damping

Four framer motion spring presets with real stiffness and damping values, the UI each one fits, and how to share them across a React component library.

SRSerban Rusu·Oct 13, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI