Provide a general summary of the issue here
useControlledState tracks the current value in valueRef and resyncs it after each render via useEarlyEffect. That effect is a no-op whenever there is no DOM:
const useEarlyEffect = typeof document !== 'undefined'
? (React['useInsertionEffect'] ?? React.useLayoutEffect)
: () => {};
typeof document reads as an "am I server-rendering?" test, but it isn't one — React Native, react-test-renderer under node, Ink and any other custom reconciler are real, interactive renderers with no DOM. In all of them valueRef is only ever advanced from inside setValue. Once the parent changes the controlled value on its own, the ref is stale, Object.is(valueRef.current, newValue) starts matching for the wrong reason, and setValue returns early without calling onChange.
🤔 Expected Behavior?
After the parent changes the controlled value, calling setValue with a different value calls onChange.
😯 Current Behavior
onChange is never called again, and the control is stuck permanently — every later setValue computes the same value that is sitting in the stale ref, so it keeps short-circuiting.
Via useToggleState, a checkbox that the user checks once and the parent then unchecks can no longer be checked at all.
💁 Possible Solution
Fall back to useEffect rather than to a no-op — the usual useIsomorphicLayoutEffect shape:
const useEarlyEffect = typeof document !== 'undefined'
? (React['useInsertionEffect'] ?? React.useLayoutEffect)
- : () => {};
+ : useEffect;
Effects don't run during SSR, so that stays warning-free, and the ref stays synced anywhere effects do run. I've confirmed this makes the repro below pass.
🔦 Context
Hit through useToggleState in a React Native app: a checkbox is checked by the user, the parent later resets it to false in response to an unrelated edit, and from then on tapping it does nothing. A submit button was gated on that checkbox, so there was no way forward on the screen.
🖥️ Steps to Reproduce
No React Native needed — any environment without a DOM shows it. react-test-renderer in a plain node process is enough.
import {useState} from 'react';
import TestRenderer, {act} from 'react-test-renderer';
import {useControlledState} from 'react-stately/useControlledState';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
test('onChange still fires after the parent changes the controlled value', () => {
const calls = [];
let setParent;
let setInner;
function Child({value, onChange}) {
const [, setValue] = useControlledState(value, false, onChange);
setInner = setValue;
return null;
}
function Parent() {
const [value, setValue] = useState(false);
setParent = setValue;
return <Child value={value} onChange={(v) => { calls.push(v); setValue(v); }} />;
}
act(() => { TestRenderer.create(<Parent />); });
// 1. the control sets it -> onChange fires, parent goes true
act(() => { setInner(true); });
expect(calls).toEqual([true]);
// 2. the parent resets it on its own, NOT through the control
act(() => { setParent(false); });
// 3. the control sets it again -> onChange should fire again
act(() => { setInner(true); });
expect(calls).toEqual([true, true]); // fails: still [true]
});
Step 3 works if step 1 is skipped, which is what makes this easy to miss in practice.
Adding globalThis.document = {} before react-stately is first imported — changing nothing else — makes the same test pass, which isolates the guard as the cause. valueRef.current is true throughout steps 2 and 3 while the rendered value is false.
Version
react-stately 3.49.0. The guard is unchanged on main, and is present in 3.46.0–3.48.0 as well, so this is not fixed by upgrading.
What browsers are you seeing the problem on?
Other
If other, please specify.
Not a browser — any renderer without a DOM. Verified on React Native (0.81, Hermes, both iOS and Android) and with react-test-renderer in node. The web is unaffected.
What operating system are you using?
macOS (host); reproduced on iOS and Android
Provide a general summary of the issue here
useControlledStatetracks the current value invalueRefand resyncs it after each render viauseEarlyEffect. That effect is a no-op whenever there is no DOM:typeof documentreads as an "am I server-rendering?" test, but it isn't one — React Native,react-test-rendererunder node, Ink and any other custom reconciler are real, interactive renderers with no DOM. In all of themvalueRefis only ever advanced from insidesetValue. Once the parent changes the controlled value on its own, the ref is stale,Object.is(valueRef.current, newValue)starts matching for the wrong reason, andsetValuereturns early without callingonChange.🤔 Expected Behavior?
After the parent changes the controlled value, calling
setValuewith a different value callsonChange.😯 Current Behavior
onChangeis never called again, and the control is stuck permanently — every latersetValuecomputes the same value that is sitting in the stale ref, so it keeps short-circuiting.Via
useToggleState, a checkbox that the user checks once and the parent then unchecks can no longer be checked at all.💁 Possible Solution
Fall back to
useEffectrather than to a no-op — the usualuseIsomorphicLayoutEffectshape:Effects don't run during SSR, so that stays warning-free, and the ref stays synced anywhere effects do run. I've confirmed this makes the repro below pass.
🔦 Context
Hit through
useToggleStatein a React Native app: a checkbox is checked by the user, the parent later resets it tofalsein response to an unrelated edit, and from then on tapping it does nothing. A submit button was gated on that checkbox, so there was no way forward on the screen.🖥️ Steps to Reproduce
No React Native needed — any environment without a DOM shows it.
react-test-rendererin a plain node process is enough.Step 3 works if step 1 is skipped, which is what makes this easy to miss in practice.
Adding
globalThis.document = {}beforereact-statelyis first imported — changing nothing else — makes the same test pass, which isolates the guard as the cause.valueRef.currentistruethroughout steps 2 and 3 while the rendered value isfalse.Version
react-stately 3.49.0. The guard is unchanged on
main, and is present in 3.46.0–3.48.0 as well, so this is not fixed by upgrading.What browsers are you seeing the problem on?
Other
If other, please specify.
Not a browser — any renderer without a DOM. Verified on React Native (0.81, Hermes, both iOS and Android) and with
react-test-rendererin node. The web is unaffected.What operating system are you using?
macOS (host); reproduced on iOS and Android