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.You'll write one hook that reads a component's own props and works out whether it owns its value or somebody else does — and the entire question turns on one comparison.
You are building a Select for your design system. One team wants to drop it in and let it look after its own selection. Another team needs that selection in their form state so they can validate it, reset it, and submit it. Ship two components and you maintain two forever. Add a controlled boolean prop and every caller has to remember to set it, and half of them will not.
React settled this for <input> a long time ago without making a fuss: pass a value and you are driving, pass only a defaultValue and the input drives itself. The component reads its own props and works out which job it has been given. useControllableValue is that trick, extracted, so your components can do it too.
There is only ever one value. The question is whose house it lives in. Uncontrolled, the value lives inside the component: setValue writes it, and onChange is a note the component posts upward that the parent is free to throw away. Controlled, the value lives in the parent and arrives as a prop: the component only reads it, and setValue stops being a write at all. It becomes a request — onChange — and then the component waits to see whether the parent does anything about it.
That is the shift worth sitting with, because it inverts what setValue means. Same name, same signature, same call site. In one mode it is an instruction; in the other it is a question.
The shape falls out fast. Did the parent hand us a value? Use theirs. Otherwise keep our own.
function useControllableValue(props = {}) {
const { value: controlledValue, defaultValue, onChange } = props;
const isControlled = !!controlledValue; // the parent gave us one, so it is driving
const [internalValue, setInternalValue] = useState(defaultValue);
const value = isControlled ? controlledValue : internalValue;
const latest = useRef(null);
latest.current = { value, isControlled, onChange };
const setValue = useCallback((next) => {
const box = latest.current;
const resolved = typeof next === 'function' ? next(box.value) : next;
if (!box.isControlled) setInternalValue(resolved);
if (box.onChange) box.onChange(resolved);
}, []);
return [value, setValue];
}
Unlike the naive attempts in most of these questions, this one is not a real library's code — no library ships this. It is simply what nearly everyone writes the first time, and it survives contact with the easy cases. Pass value="hello" and the parent drives. Pass only defaultValue and the hook drives. Both modes work; onChange fires; setValue is stable.
Then someone builds a number input.
<Stepper value={0} onChange={setCount} />
!!0 is false. The hook decides it is uncontrolled, quietly stops reading the prop, and starts answering to nobody. The parent still thinks it owns a value it no longer controls. Same story for value="" on a text field — which is what every text field becomes the moment a user selects-all and hits delete — and for value={false} on a checkbox, which is what half of all checkboxes are.
const { useState, useRef, useCallback } = require('react');
function useControllableValue(props = {}) {
const { value: controlledValue, defaultValue, onChange } = props;
// THE line. Not "is there a truthy value here" but "did the caller pass one
// at all". 0, '' and false all clear this bar, because all three are values
// a parent deliberately handed over.
const isControlled = controlledValue !== undefined;
// Seeded once, then ignored for as long as we stay controlled — but still
// here, because a component that starts uncontrolled needs somewhere to live.
const [internalValue, setInternalValue] = useState(defaultValue);
// The whole duality, in one expression: read their value, or read ours.
const value = isControlled ? controlledValue : internalValue;
// One box, repointed at this render's facts on every render. setValue reads
// through it, which is exactly what lets setValue itself be built only once.
const latest = useRef(null);
latest.current = { value, isControlled, onChange };
const setValue = useCallback((next) => {
const box = latest.current;
// An updater resolves against the CURRENT value, whichever mode we are in.
// Controlled that is the prop; uncontrolled it is our own state.
const resolved = typeof next === 'function' ? next(box.value) : next;
if (!box.isControlled) {
// Write the box as well as the state. Two setValue calls in one event are
// batched, so the second updater has to see the first one's result from
// here — React has not re-rendered yet, so `internalValue` is still stale.
box.value = resolved;
setInternalValue(resolved);
}
// Fires in both modes. Uncontrolled it is a courtesy. Controlled it is the
// only thing that can move the value, because we are not going to.
if (box.onChange) box.onChange(resolved);
}, []);
return [value, setValue];
}
module.exports = { useControllableValue };
Two things changed. The mode test became controlledValue !== undefined — a question about the prop, not about the value inside it — and that one comparison is the whole hook. And setValue writes box.value alongside the state, so a second updater in the same event sees the first one's answer instead of a value React has not gotten around to replacing yet.
Everything else is a consequence. useState still exists because an uncontrolled component has to keep its value somewhere. The latest box exists because setValue promises a stable identity, so it cannot close over this render's onChange or this render's value — it reads both through a box whose identity never changes while its contents change constantly. That is useLatest doing its one job, and useMethods leans on the same trick for the same reason.
This deserves its own airtime, because it is not an edge case. It is the middle of the road.
!!props.value and props.value !== undefined look like the same question asked two ways. They are not even close. One asks is this value truthy, the other asks did anybody pass a value. Those answers agree for 'hello' and 42, which is exactly why the bug ships: every test you write by hand uses a value like 'hello' or 42.
They disagree for 0, '', false, NaN — the values a real component spends most of its life holding. A quantity input at zero. A search box before anyone types. A checkbox that is off. And they disagree at the worst possible moment, because a truthiness test does not fail at mount, when you might notice. It fails mid-session: the component is controlled while the field says "Ada", the user selects-all and deletes, the parent re-renders with value="", and the hook silently changes its mind about who is in charge. The parent has now lost control of its own input, and nothing anywhere threw.
undefined is the right line to draw because it is the only value JavaScript gives you for free when a prop is absent. Read a key nobody set and you get undefined; leave a parameter off and you get undefined. So !== undefined is not an arbitrary convention — it is a question that means did this prop happen at all, and every other value, falsy or not, is an answer to a different question.
Now the part that confuses everyone who meets controlled components for the first time.
In controlled mode setValue does not touch what the hook returns. Read that again, because it sounds like a bug report. You call setValue('b'), you re-render, and the hook still says 'a'. Nothing is broken. The parent owns that value, and the parent has not changed its mind yet. All setValue did — all it can do — is call onChange('b') and wait.
If the parent honours that call, it sets its own state, re-renders, and hands down a new value prop, and the hook returns the new value on the next render. The loop closes. If the parent ignores the call, no state changes, no new prop comes down, and the value sits exactly where it was — forever. The input looks frozen. It types nothing. And it is behaving perfectly.
This is the single most confusing thing about controlled components, and it is worth naming: a frozen controlled input is almost never a bug in the input. It is a parent that forgot to wire onChange, or wired it to a handler that drops the value on the floor. React says so out loud for its own elements: give an <input> a value and no onChange and it tells you that you provided a value prop to a form field without an onChange handler, and that this will render a read-only field. Your own hook cannot see enough to warn like that, so its contract has to carry the weight instead.
Mount a stepper the way the trap would have found it: <Stepper value={0} onChange={setCount} />, with the parent holding count = 0.
props.value is 0. 0 !== undefined is true, so isControlled is true. useState(undefined) still runs — hooks are unconditional — and parks an internalValue of undefined that this component will never read. value resolves to 0, the prop. The latest box is filled with that render's facts, and setValue is built for the only time.setValue((n) => n + 1).setValue. It reads box.value, which is 0 — the prop, because that is what value resolved to on render 1. next is a function, so it runs it: 0 + 1 is 1. box.isControlled is true, so the whole setInternalValue branch is skipped. Nothing the hook owns has changed.onChange(1) fires. That is setCount(1) in the parent.count = 1, so props.value is now 1. isControlled is still true, value is 1, and the box gets repointed at 1. The hook returns 1. The stepper shows 1.Now run the same five steps against the naive version. Step 1 computes !!0 as false, so isControlled is false and value is internalValue — undefined, not 0. The stepper renders blank on its very first paint. Step 3 takes the uncontrolled branch and calls setInternalValue(NaN), because the updater is handed undefined rather than 0. Step 5's new value prop of 1 arrives and is ignored, because the hook stopped reading it four steps ago. One !! and the component never worked at all.
Every serious library ships this hook, none of them agree completely, and the disagreements are worth knowing because they are choices rather than bugs.
On deciding the mode, nobody uses truthiness. Radix uses prop !== undefined, the same test as above. MUI uses controlled !== undefined. ahooks is the odd one out: it asks Object.prototype.hasOwnProperty.call(props, 'value') — whether the key exists — which is a stricter line than !== undefined and has a real consequence. Write <Field value={maybeUndefined} /> and the key exists whatever the variable holds, so ahooks calls it controlled and the field freezes at undefined. That pattern is common enough that !== undefined is the better default.
On locking the mode, they split. MUI captures it once with useRef(controlled !== undefined) and never looks again, so a component that starts uncontrolled ignores a value prop that shows up later. Radix and ahooks re-derive it every render, like the hook above. React itself re-derives: ReactDOMComponent.js computes wasControlled from the previous props and isControlled from the next ones on every update, and only warns if they differ. Re-deriving is the better default — a hook that silently ignores a prop you passed is harder to debug than one that follows it — and the warning is what tells you off for switching.
On what React warns, the text is worth quoting because you will meet it: "A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen." There is a mirrored one for the other direction. Both fire from a dev-only block and neither changes behaviour. Radix and MUI both log their own version of the same sentence.
On null, React disagrees with everybody, including this hook. React's <input> does not test !== undefined at all — it tests props.value != null, loose, so value={null} reads to React as no value prop and the input is uncontrolled. Radix, MUI, ahooks and the solution above all call value={null} controlled. The libraries are right for their job and React is right for its own: a DOM input's value is always a string, so null there cannot mean anything except a mistake, and React treats it as absence. A generic component has no such luxury — <Select value={null}> meaning nothing is selected is a normal, deliberate controlled state, and demoting it to uncontrolled would hand the select back to itself. Hence !== undefined: undefined means the prop is absent, and null is a value like any other.
On onChange in controlled mode, Radix skips the call when the resolved value equals the current prop. The solution above always calls, which is ahooks' behaviour and the simpler contract: onChange means the user did something, and picking the already-selected option in a dropdown is still something — the parent usually wants to close the menu.
On identity, MUI and ahooks keep the setter stable; Radix does not. Its useCallback lists [isControlled, prop, setUncontrolledProp, onChangeRef], so the setter is a new function every time the value changes. That is what buys Radix a simpler body, and it costs the guarantee this hook makes.
!!props.value (or props.value ? … : …). The bug of this whole question. It passes every test you write with 'hello' and breaks the instant the value is 0, '' or false — which is most of the time, and mid-session rather than at mount. Fix: props.value !== undefined, which asks about the prop rather than the value in it.props.value || props.defaultValue. The same trap wearing a different hat, and it fails even harder: a controlled value="" falls straight through to defaultValue, so the field the user just cleared refills itself with the placeholder as they watch. Fix: pick the mode first, then read one source.internalValue instead of the current value. Uncontrolled they are the same thing, so it looks fine locally. Controlled, internalValue is a value nobody has read since mount, so setValue(n => n + 1) computes from stale garbage and onChange gets handed nonsense. Fix: resolve against value — the same thing the hook returns.setValue(n => n + 1) twice in one click moves the value by 1, not 2, because both calls read the same pre-batch value. Worse, setValue(5) followed by setValue(n => n + 1) yields 1 — the 5 is simply lost. Fix: write box.value = resolved in the uncontrolled branch so the next call in the batch sees it.setValue close over onChange. useCallback with [] and a direct reference to onChange pins render 1's handler forever, so the callback that actually fires is the one from before the parent re-rendered. Fix: read it out of the latest box, which is repointed every render.onChange only when uncontrolled. It is easy to read "the parent owns it, so leave the parent alone" backwards. Controlled mode is the one where onChange is not optional in practice — it is the only channel the component has. Fix: fire it in both modes, always with the resolved value.isControlled in a ref, compare it in an effect, and console.error when it flips — the same shape MUI and Radix use, and worth adding because a hook that switches mode silently is close to undebuggable. Keep it behind a process.env.NODE_ENV !== 'production' check so it costs nothing shipped.valuePropName, defaultValuePropName and trigger, so the same hook can drive a checkbox reading checked/defaultChecked/onChange or an editor reading content/onEdit. It is a small change — read the names out of options instead of destructuring fixed keys — and it is what makes one hook serve a whole library.onChange(value) is enough for a text field, but a Select usually wants onChange(value, option) and a date picker wants onChange(date, dateString). ahooks does this with setState(v, ...args) and passes ...args straight through to the trigger. Cheap to add, and painful to retrofit once callers depend on the one-argument shape.T | undefined, because a hook given neither value nor defaultValue genuinely starts undefined — which is correct and also miserable for every caller who did pass a defaultValue. MUI's source carries a TODO admitting exactly this. The fix is overloads: one signature for props that include a value or a defaultValue, returning T, and a fallback returning T | undefined.useControllableState that also owns the flags. Compose this with useInputControl and you get a field that is controllable and tracks dirty/touched — with the wrinkle that "dirty" has to be measured against something that survives the parent taking over. That question is worth answering before you ship it.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You'll write one hook that reads a component's own props and works out whether it owns its value or somebody else does — and the entire question turns on one comparison.
You are building a Select for your design system. One team wants to drop it in and let it look after its own selection. Another team needs that selection in their form state so they can validate it, reset it, and submit it. Ship two components and you maintain two forever. Add a controlled boolean prop and every caller has to remember to set it, and half of them will not.
React settled this for <input> a long time ago without making a fuss: pass a value and you are driving, pass only a defaultValue and the input drives itself. The component reads its own props and works out which job it has been given. useControllableValue is that trick, extracted, so your components can do it too.
There is only ever one value. The question is whose house it lives in. Uncontrolled, the value lives inside the component: setValue writes it, and onChange is a note the component posts upward that the parent is free to throw away. Controlled, the value lives in the parent and arrives as a prop: the component only reads it, and setValue stops being a write at all. It becomes a request — onChange — and then the component waits to see whether the parent does anything about it.
That is the shift worth sitting with, because it inverts what setValue means. Same name, same signature, same call site. In one mode it is an instruction; in the other it is a question.
The shape falls out fast. Did the parent hand us a value? Use theirs. Otherwise keep our own.
function useControllableValue(props = {}) {
const { value: controlledValue, defaultValue, onChange } = props;
const isControlled = !!controlledValue; // the parent gave us one, so it is driving
const [internalValue, setInternalValue] = useState(defaultValue);
const value = isControlled ? controlledValue : internalValue;
const latest = useRef(null);
latest.current = { value, isControlled, onChange };
const setValue = useCallback((next) => {
const box = latest.current;
const resolved = typeof next === 'function' ? next(box.value) : next;
if (!box.isControlled) setInternalValue(resolved);
if (box.onChange) box.onChange(resolved);
}, []);
return [value, setValue];
}
Unlike the naive attempts in most of these questions, this one is not a real library's code — no library ships this. It is simply what nearly everyone writes the first time, and it survives contact with the easy cases. Pass value="hello" and the parent drives. Pass only defaultValue and the hook drives. Both modes work; onChange fires; setValue is stable.
Then someone builds a number input.
<Stepper value={0} onChange={setCount} />
!!0 is false. The hook decides it is uncontrolled, quietly stops reading the prop, and starts answering to nobody. The parent still thinks it owns a value it no longer controls. Same story for value="" on a text field — which is what every text field becomes the moment a user selects-all and hits delete — and for value={false} on a checkbox, which is what half of all checkboxes are.
const { useState, useRef, useCallback } = require('react');
function useControllableValue(props = {}) {
const { value: controlledValue, defaultValue, onChange } = props;
// THE line. Not "is there a truthy value here" but "did the caller pass one
// at all". 0, '' and false all clear this bar, because all three are values
// a parent deliberately handed over.
const isControlled = controlledValue !== undefined;
// Seeded once, then ignored for as long as we stay controlled — but still
// here, because a component that starts uncontrolled needs somewhere to live.
const [internalValue, setInternalValue] = useState(defaultValue);
// The whole duality, in one expression: read their value, or read ours.
const value = isControlled ? controlledValue : internalValue;
// One box, repointed at this render's facts on every render. setValue reads
// through it, which is exactly what lets setValue itself be built only once.
const latest = useRef(null);
latest.current = { value, isControlled, onChange };
const setValue = useCallback((next) => {
const box = latest.current;
// An updater resolves against the CURRENT value, whichever mode we are in.
// Controlled that is the prop; uncontrolled it is our own state.
const resolved = typeof next === 'function' ? next(box.value) : next;
if (!box.isControlled) {
// Write the box as well as the state. Two setValue calls in one event are
// batched, so the second updater has to see the first one's result from
// here — React has not re-rendered yet, so `internalValue` is still stale.
box.value = resolved;
setInternalValue(resolved);
}
// Fires in both modes. Uncontrolled it is a courtesy. Controlled it is the
// only thing that can move the value, because we are not going to.
if (box.onChange) box.onChange(resolved);
}, []);
return [value, setValue];
}
module.exports = { useControllableValue };
Two things changed. The mode test became controlledValue !== undefined — a question about the prop, not about the value inside it — and that one comparison is the whole hook. And setValue writes box.value alongside the state, so a second updater in the same event sees the first one's answer instead of a value React has not gotten around to replacing yet.
Everything else is a consequence. useState still exists because an uncontrolled component has to keep its value somewhere. The latest box exists because setValue promises a stable identity, so it cannot close over this render's onChange or this render's value — it reads both through a box whose identity never changes while its contents change constantly. That is useLatest doing its one job, and useMethods leans on the same trick for the same reason.
This deserves its own airtime, because it is not an edge case. It is the middle of the road.
!!props.value and props.value !== undefined look like the same question asked two ways. They are not even close. One asks is this value truthy, the other asks did anybody pass a value. Those answers agree for 'hello' and 42, which is exactly why the bug ships: every test you write by hand uses a value like 'hello' or 42.
They disagree for 0, '', false, NaN — the values a real component spends most of its life holding. A quantity input at zero. A search box before anyone types. A checkbox that is off. And they disagree at the worst possible moment, because a truthiness test does not fail at mount, when you might notice. It fails mid-session: the component is controlled while the field says "Ada", the user selects-all and deletes, the parent re-renders with value="", and the hook silently changes its mind about who is in charge. The parent has now lost control of its own input, and nothing anywhere threw.
undefined is the right line to draw because it is the only value JavaScript gives you for free when a prop is absent. Read a key nobody set and you get undefined; leave a parameter off and you get undefined. So !== undefined is not an arbitrary convention — it is a question that means did this prop happen at all, and every other value, falsy or not, is an answer to a different question.
Now the part that confuses everyone who meets controlled components for the first time.
In controlled mode setValue does not touch what the hook returns. Read that again, because it sounds like a bug report. You call setValue('b'), you re-render, and the hook still says 'a'. Nothing is broken. The parent owns that value, and the parent has not changed its mind yet. All setValue did — all it can do — is call onChange('b') and wait.
If the parent honours that call, it sets its own state, re-renders, and hands down a new value prop, and the hook returns the new value on the next render. The loop closes. If the parent ignores the call, no state changes, no new prop comes down, and the value sits exactly where it was — forever. The input looks frozen. It types nothing. And it is behaving perfectly.
This is the single most confusing thing about controlled components, and it is worth naming: a frozen controlled input is almost never a bug in the input. It is a parent that forgot to wire onChange, or wired it to a handler that drops the value on the floor. React says so out loud for its own elements: give an <input> a value and no onChange and it tells you that you provided a value prop to a form field without an onChange handler, and that this will render a read-only field. Your own hook cannot see enough to warn like that, so its contract has to carry the weight instead.
Mount a stepper the way the trap would have found it: <Stepper value={0} onChange={setCount} />, with the parent holding count = 0.
props.value is 0. 0 !== undefined is true, so isControlled is true. useState(undefined) still runs — hooks are unconditional — and parks an internalValue of undefined that this component will never read. value resolves to 0, the prop. The latest box is filled with that render's facts, and setValue is built for the only time.setValue((n) => n + 1).setValue. It reads box.value, which is 0 — the prop, because that is what value resolved to on render 1. next is a function, so it runs it: 0 + 1 is 1. box.isControlled is true, so the whole setInternalValue branch is skipped. Nothing the hook owns has changed.onChange(1) fires. That is setCount(1) in the parent.count = 1, so props.value is now 1. isControlled is still true, value is 1, and the box gets repointed at 1. The hook returns 1. The stepper shows 1.Now run the same five steps against the naive version. Step 1 computes !!0 as false, so isControlled is false and value is internalValue — undefined, not 0. The stepper renders blank on its very first paint. Step 3 takes the uncontrolled branch and calls setInternalValue(NaN), because the updater is handed undefined rather than 0. Step 5's new value prop of 1 arrives and is ignored, because the hook stopped reading it four steps ago. One !! and the component never worked at all.
Every serious library ships this hook, none of them agree completely, and the disagreements are worth knowing because they are choices rather than bugs.
On deciding the mode, nobody uses truthiness. Radix uses prop !== undefined, the same test as above. MUI uses controlled !== undefined. ahooks is the odd one out: it asks Object.prototype.hasOwnProperty.call(props, 'value') — whether the key exists — which is a stricter line than !== undefined and has a real consequence. Write <Field value={maybeUndefined} /> and the key exists whatever the variable holds, so ahooks calls it controlled and the field freezes at undefined. That pattern is common enough that !== undefined is the better default.
On locking the mode, they split. MUI captures it once with useRef(controlled !== undefined) and never looks again, so a component that starts uncontrolled ignores a value prop that shows up later. Radix and ahooks re-derive it every render, like the hook above. React itself re-derives: ReactDOMComponent.js computes wasControlled from the previous props and isControlled from the next ones on every update, and only warns if they differ. Re-deriving is the better default — a hook that silently ignores a prop you passed is harder to debug than one that follows it — and the warning is what tells you off for switching.
On what React warns, the text is worth quoting because you will meet it: "A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen." There is a mirrored one for the other direction. Both fire from a dev-only block and neither changes behaviour. Radix and MUI both log their own version of the same sentence.
On null, React disagrees with everybody, including this hook. React's <input> does not test !== undefined at all — it tests props.value != null, loose, so value={null} reads to React as no value prop and the input is uncontrolled. Radix, MUI, ahooks and the solution above all call value={null} controlled. The libraries are right for their job and React is right for its own: a DOM input's value is always a string, so null there cannot mean anything except a mistake, and React treats it as absence. A generic component has no such luxury — <Select value={null}> meaning nothing is selected is a normal, deliberate controlled state, and demoting it to uncontrolled would hand the select back to itself. Hence !== undefined: undefined means the prop is absent, and null is a value like any other.
On onChange in controlled mode, Radix skips the call when the resolved value equals the current prop. The solution above always calls, which is ahooks' behaviour and the simpler contract: onChange means the user did something, and picking the already-selected option in a dropdown is still something — the parent usually wants to close the menu.
On identity, MUI and ahooks keep the setter stable; Radix does not. Its useCallback lists [isControlled, prop, setUncontrolledProp, onChangeRef], so the setter is a new function every time the value changes. That is what buys Radix a simpler body, and it costs the guarantee this hook makes.
!!props.value (or props.value ? … : …). The bug of this whole question. It passes every test you write with 'hello' and breaks the instant the value is 0, '' or false — which is most of the time, and mid-session rather than at mount. Fix: props.value !== undefined, which asks about the prop rather than the value in it.props.value || props.defaultValue. The same trap wearing a different hat, and it fails even harder: a controlled value="" falls straight through to defaultValue, so the field the user just cleared refills itself with the placeholder as they watch. Fix: pick the mode first, then read one source.internalValue instead of the current value. Uncontrolled they are the same thing, so it looks fine locally. Controlled, internalValue is a value nobody has read since mount, so setValue(n => n + 1) computes from stale garbage and onChange gets handed nonsense. Fix: resolve against value — the same thing the hook returns.setValue(n => n + 1) twice in one click moves the value by 1, not 2, because both calls read the same pre-batch value. Worse, setValue(5) followed by setValue(n => n + 1) yields 1 — the 5 is simply lost. Fix: write box.value = resolved in the uncontrolled branch so the next call in the batch sees it.setValue close over onChange. useCallback with [] and a direct reference to onChange pins render 1's handler forever, so the callback that actually fires is the one from before the parent re-rendered. Fix: read it out of the latest box, which is repointed every render.onChange only when uncontrolled. It is easy to read "the parent owns it, so leave the parent alone" backwards. Controlled mode is the one where onChange is not optional in practice — it is the only channel the component has. Fix: fire it in both modes, always with the resolved value.isControlled in a ref, compare it in an effect, and console.error when it flips — the same shape MUI and Radix use, and worth adding because a hook that switches mode silently is close to undebuggable. Keep it behind a process.env.NODE_ENV !== 'production' check so it costs nothing shipped.valuePropName, defaultValuePropName and trigger, so the same hook can drive a checkbox reading checked/defaultChecked/onChange or an editor reading content/onEdit. It is a small change — read the names out of options instead of destructuring fixed keys — and it is what makes one hook serve a whole library.onChange(value) is enough for a text field, but a Select usually wants onChange(value, option) and a date picker wants onChange(date, dateString). ahooks does this with setState(v, ...args) and passes ...args straight through to the trigger. Cheap to add, and painful to retrofit once callers depend on the one-argument shape.T | undefined, because a hook given neither value nor defaultValue genuinely starts undefined — which is correct and also miserable for every caller who did pass a defaultValue. MUI's source carries a TODO admitting exactly this. The fix is overloads: one signature for props that include a value or a defaultValue, returning T, and a fallback returning T | undefined.useControllableState that also owns the flags. Compose this with useInputControl and you get a field that is controllable and tracks dirty/touched — with the wrinkle that "dirty" has to be measured against something that survives the parent taking over. That question is worth answering before you ship it.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.