Skip to content

useControlledState: parent-driven value changes are ignored when there is no DOM #10512

Description

@caycecollins

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions