A component is controlled when the value it shows lives in its parent and arrives as a prop, and uncontrolled when the component owns that value itself. React's own <input> is both, and it works out which by looking at its props: hand it a value and you are driving, hand it only a defaultValue and it drives itself. Every component library ships this duality, because a picker that only does one of the two is a picker half its users cannot adopt. useControllableValue(props) is that switch, in one hook.
function useControllableValue<T>(props?: {
value?: T; // passed -> controlled: the parent owns the value
defaultValue?: T; // seeds the hook's own state when uncontrolled
onChange?: (value: T) => void; // called on every setValue, in BOTH modes
}): [T, (next: T | ((current: T) => T)) => void];
props may be omitted entirely. setValue keeps one identity for the life of the component.
Uncontrolled — no value prop, so the hook owns it. setValue moves the value and tells the parent it happened:
const [value, setValue] = useControllableValue({ defaultValue: 'a', onChange: log });
setValue('b'); // value is 'b' on the next render, and log('b') fired
Controlled — a value prop, so the parent owns it. setValue does not move the value; it asks:
const [value, setValue] = useControllableValue({ value: 'a', onChange: log });
setValue('b'); // value is STILL 'a'. log('b') fired — it is the parent's move now.
Wired to a real parent, that request comes back around as a new prop:
function NameField(props) {
const [value, setValue] = useControllableValue(props);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
function Parent() {
const [name, setName] = useState('Ada');
return <NameField value={name} onChange={setName} />; // controlled
}
function Standalone() {
return <NameField defaultValue="Ada" />; // same component, uncontrolled
}
controlled: true argument. The hook works out which mode it is in from what it was handed, the same way <input> does.0 on a number input, '' on a field the user just cleared, false on a checkbox — a parent passing any of these is controlling the component, and the hook has to keep answering to it.setValue does not change what the hook returns. It calls onChange and stops. If the parent ignores onChange, the value never changes. That is the contract, not a bug.onChange fires in both modes, with the resolved value, and is optional.setValue takes an updater, like useState's setter. setValue(v => v + 1) resolves against the current value — which in controlled mode is the prop.defaultValue seeds once. Like useState, a later defaultValue does not move a value the hook already owns.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.