Skip to content

fix(useControlledState): apply function updates in order when uncontrolled - #484

Merged
hyesungoh merged 3 commits into
mainfrom
fix/useControlledState-multiple-functional-updates
Sep 9, 2026
Merged

fix(useControlledState): apply function updates in order when uncontrolled#484
hyesungoh merged 3 commits into
mainfrom
fix/useControlledState-multiple-functional-updates

Conversation

@hyesungoh

@hyesungoh hyesungoh commented Sep 9, 2026

Copy link
Copy Markdown
Member

Overview

Supersedes #344. Co-authored with @kyukyu-dev, whose test case this builds on.

Problem

useControlledState computed function updates from the value captured at render, so two updates in the same tick both saw the same prev:

const [count, setCount] = useControlledState({ defaultValue: 5 });

setCount(prev => prev + 3);
setCount(prev => prev + 3);
// useState: 11, useControlledState: 8

This affected both modes.

What changed

Uncontrolled mode now hands the updater to React's own useState queue, so multiple setValue calls in the same tick apply in order. This covers plain values too: setValue('b') followed by setValue('a') from 'a' now settles on 'a' instead of 'b'.

onChange is called once after commit with the final value. It used to be called synchronously on every setValue call, so:

  • a parent that mirrors onChange into its own state renders once more per update
  • no call is made when the final value equals the previous one
  • a change made in the same commit the component unmounts is not reported
  • under StrictMode a single change is reported once, not twice

In exchange the updater stays pure, which matters under StrictMode and the React Compiler.

Controlled mode is unchanged. It still computes from the committed value, so two function updates in the same tick see the same prev.

Why not fix controlled mode too

#344 fixed both modes by tracking the pending value in a ref and forcing a re-render to reset it. While reviewing it we found that the forced re-render fires even when the parent rejects the change, so any caller that re-issues setValue on every render never settles:

function App() {
  const [value, setValue] = useState(10);
  const [state, setState] = useControlledState({
    value,
    onChange: next => setValue(Math.min(next, 10)), // parent clamps, effectively rejects 12
  });

  useLayoutEffect(function push() {
    setState(12); // no deps: runs on every render
  });

  return <p>{state}</p>;
}
// main: settles at 10
// #344: "Maximum update depth exceeded"

A fresh object in an effect's deps triggers the same loop, and a useEffect variant does not hit React's guard at all. The controlled-mode double update is rare in practice, while the loop hits ordinary code, so we took the same trade-off Radix makes in useControllableState: fix uncontrolled mode with React's queue, accept the limitation in controlled mode. React Aria's useControlledState takes the opposite trade-off and has the loop.

A test pins the choice: a parent that rejects a change while the caller re-issues it on every render must not re-render.

Follow-up commits

  • setValue(undefined) in controlled mode also writes the internal state so the value stays undefined once the parent hands control back. The notify effect then reported that change a second time; the ref it compares against is now synced at the write site.
  • The notify effect compared with equalityFn, so a non-reflexive comparator reported a change on mount and an inline one re-ran the effect every render. The updater already folds equal values into prev, so the effect now uses a reference check. Its controlled early return only delayed the notification when the parent took control in the same event as the change, so it is gone.

Tests

Uncontrolled:

  • two function updates in the same tick apply in order and onChange fires once with the final value
  • onChange is not called on mount, also with a non-reflexive equalityFn
  • a value equalityFn treats as equal neither calls onChange nor replaces the state reference
  • the latest onChange is used after the parent swaps it
  • a change back to the initial value is reported
  • a change made in the same event the parent takes control is reported, with no late call when control is released

Controlled:

  • the parent changing value externally is reflected, and a function update toggles through the parent (both ported from Radix's useControllableState tests)
  • a function update computes from the value prop, not the internal state
  • a rejected change re-issued on every render does not re-render
  • setValue(undefined) handing control back reports to onChange once, not twice

Checklist

  • Did you write the test code?
  • Have you run yarn run fix to format and lint the code and docs?
  • Have you run yarn run test:coverage to make sure there is no uncovered line?
  • Did you write the JSDoc?

…olled

Two function updates in the same tick used to compute from the value captured
at render, so `setValue(p => p + 3)` twice yielded +3 instead of +6.

Uncontrolled mode now passes the updater to React's own queue and calls
`onChange` once after commit with the final value. Controlled mode still
computes from the committed `value` on purpose: tracking the pending value
in a ref needs a forced re-render, which loops when the parent rejects a
change the caller re-issues every render (see #344).

Co-authored-by: kyukyu-dev <gkrbdud1388@gmail.com>
@hyesungoh
hyesungoh requested a review from mnxmnz as a code owner September 9, 2026 13:54
Copilot AI lite review requested due to automatic review settings September 9, 2026 13:54
@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e8715e1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
react-simplikit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Size Change: +318 B (+0.29%)

Total Size: 109 kB

📦 View Changed
Filename Size Change
packages/react-simplikit/dist/hooks/useControlledState/useControlledState.cjs 1.03 kB +161 B (+18.61%) ⚠️
packages/react-simplikit/dist/hooks/useControlledState/useControlledState.mjs 1.01 kB +157 B (+18.36%) ⚠️
ℹ️ View Unchanged
Filename Size
packages/codemod/dist/cli.mjs 6.47 kB
packages/react-simplikit/dist/components/ImpressionArea/ImpressionArea.cjs 1.01 kB
packages/react-simplikit/dist/components/ImpressionArea/ImpressionArea.mjs 984 B
packages/react-simplikit/dist/components/Separated/Separated.cjs 686 B
packages/react-simplikit/dist/components/Separated/Separated.mjs 684 B
packages/react-simplikit/dist/components/SwitchCase/SwitchCase.cjs 701 B
packages/react-simplikit/dist/components/SwitchCase/SwitchCase.mjs 699 B
packages/react-simplikit/dist/hooks/useAsyncEffect/useAsyncEffect.cjs 625 B
packages/react-simplikit/dist/hooks/useAsyncEffect/useAsyncEffect.mjs 614 B
packages/react-simplikit/dist/hooks/useAvoidKeyboard/useAvoidKeyboard.cjs 985 B
packages/react-simplikit/dist/hooks/useAvoidKeyboard/useAvoidKeyboard.mjs 963 B
packages/react-simplikit/dist/hooks/useBodyScrollLock/useBodyScrollLock.cjs 546 B
packages/react-simplikit/dist/hooks/useBodyScrollLock/useBodyScrollLock.mjs 523 B
packages/react-simplikit/dist/hooks/useBooleanState/useBooleanState.cjs 546 B
packages/react-simplikit/dist/hooks/useBooleanState/useBooleanState.mjs 536 B
packages/react-simplikit/dist/hooks/useCallbackOncePerRender/useCallbackOncePerRender.cjs 790 B
packages/react-simplikit/dist/hooks/useCallbackOncePerRender/useCallbackOncePerRender.mjs 762 B
packages/react-simplikit/dist/hooks/useConditionalEffect/useConditionalEffect.cjs 955 B
packages/react-simplikit/dist/hooks/useConditionalEffect/useConditionalEffect.mjs 934 B
packages/react-simplikit/dist/hooks/useCounter/useCounter.cjs 1.03 kB
packages/react-simplikit/dist/hooks/useCounter/useCounter.mjs 1.01 kB
packages/react-simplikit/dist/hooks/useDebounce/debounce.cjs 463 B
packages/react-simplikit/dist/hooks/useDebounce/debounce.mjs 462 B
packages/react-simplikit/dist/hooks/useDebounce/useDebounce.cjs 973 B
packages/react-simplikit/dist/hooks/useDebounce/useDebounce.mjs 954 B
packages/react-simplikit/dist/hooks/useDebouncedCallback/useDebouncedCallback.cjs 1.15 kB
packages/react-simplikit/dist/hooks/useDebouncedCallback/useDebouncedCallback.mjs 1.12 kB
packages/react-simplikit/dist/hooks/useDebouncedValue/useDebouncedValue.cjs 1.11 kB
packages/react-simplikit/dist/hooks/useDebouncedValue/useDebouncedValue.mjs 1.08 kB
packages/react-simplikit/dist/hooks/useDoubleClick/useDoubleClick.cjs 965 B
packages/react-simplikit/dist/hooks/useDoubleClick/useDoubleClick.mjs 950 B
packages/react-simplikit/dist/hooks/useGeolocation/useGeolocation.cjs 2.02 kB
packages/react-simplikit/dist/hooks/useGeolocation/useGeolocation.mjs 2.02 kB
packages/react-simplikit/dist/hooks/useImpressionRef/useImpressionRef.cjs 1.22 kB
packages/react-simplikit/dist/hooks/useImpressionRef/useImpressionRef.mjs 1.19 kB
packages/react-simplikit/dist/hooks/useInputState/useInputState.cjs 672 B
packages/react-simplikit/dist/hooks/useInputState/useInputState.mjs 662 B
packages/react-simplikit/dist/hooks/useIntersectionObserver/useIntersectionObserver.cjs 1.07 kB
packages/react-simplikit/dist/hooks/useIntersectionObserver/useIntersectionObserver.mjs 1.04 kB
packages/react-simplikit/dist/hooks/useInterval/useInterval.cjs 837 B
packages/react-simplikit/dist/hooks/useInterval/useInterval.mjs 811 B
packages/react-simplikit/dist/hooks/useIsClient/useIsClient.cjs 585 B
packages/react-simplikit/dist/hooks/useIsClient/useIsClient.mjs 574 B
packages/react-simplikit/dist/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.cjs 577 B
packages/react-simplikit/dist/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.mjs 575 B
packages/react-simplikit/dist/hooks/useKeyboardHeight/useKeyboardHeight.cjs 685 B
packages/react-simplikit/dist/hooks/useKeyboardHeight/useKeyboardHeight.mjs 666 B
packages/react-simplikit/dist/hooks/useList/useList.cjs 952 B
packages/react-simplikit/dist/hooks/useList/useList.mjs 930 B
packages/react-simplikit/dist/hooks/useLoading/useLoading.cjs 919 B
packages/react-simplikit/dist/hooks/useLoading/useLoading.mjs 909 B
packages/react-simplikit/dist/hooks/useLongPress/useLongPress.cjs 1.71 kB
packages/react-simplikit/dist/hooks/useLongPress/useLongPress.mjs 1.68 kB
packages/react-simplikit/dist/hooks/useMap/useMap.cjs 730 B
packages/react-simplikit/dist/hooks/useMap/useMap.mjs 713 B
packages/react-simplikit/dist/hooks/useNetworkStatus/useNetworkStatus.cjs 1.2 kB
packages/react-simplikit/dist/hooks/useNetworkStatus/useNetworkStatus.mjs 1.18 kB
packages/react-simplikit/dist/hooks/useOutsideClickEffect/useOutsideClickEffect.cjs 789 B
packages/react-simplikit/dist/hooks/useOutsideClickEffect/useOutsideClickEffect.mjs 758 B
packages/react-simplikit/dist/hooks/usePageVisibility/usePageVisibility.cjs 931 B
packages/react-simplikit/dist/hooks/usePageVisibility/usePageVisibility.mjs 912 B
packages/react-simplikit/dist/hooks/usePreservedCallback/usePreservedCallback.cjs 691 B
packages/react-simplikit/dist/hooks/usePreservedCallback/usePreservedCallback.mjs 675 B
packages/react-simplikit/dist/hooks/usePreservedReference/usePreservedReference.cjs 805 B
packages/react-simplikit/dist/hooks/usePreservedReference/usePreservedReference.mjs 789 B
packages/react-simplikit/dist/hooks/usePrevious/usePrevious.cjs 643 B
packages/react-simplikit/dist/hooks/usePrevious/usePrevious.mjs 635 B
packages/react-simplikit/dist/hooks/useRefEffect/useRefEffect.cjs 790 B
packages/react-simplikit/dist/hooks/useRefEffect/useRefEffect.mjs 775 B
packages/react-simplikit/dist/hooks/useSafeAreaInset/useSafeAreaInset.cjs 942 B
packages/react-simplikit/dist/hooks/useSafeAreaInset/useSafeAreaInset.mjs 927 B
packages/react-simplikit/dist/hooks/useScrollDirection/useScrollDirection.cjs 955 B
packages/react-simplikit/dist/hooks/useScrollDirection/useScrollDirection.mjs 944 B
packages/react-simplikit/dist/hooks/useSet/useSet.cjs 1.03 kB
packages/react-simplikit/dist/hooks/useSet/useSet.mjs 1 kB
packages/react-simplikit/dist/hooks/useStorageState/storage.cjs 543 B
packages/react-simplikit/dist/hooks/useStorageState/storage.mjs 527 B
packages/react-simplikit/dist/hooks/useStorageState/useStorageState.cjs 973 B
packages/react-simplikit/dist/hooks/useStorageState/useStorageState.mjs 964 B
packages/react-simplikit/dist/hooks/useThrottle/throttle.cjs 431 B
packages/react-simplikit/dist/hooks/useThrottle/throttle.mjs 422 B
packages/react-simplikit/dist/hooks/useThrottle/useThrottle.cjs 864 B
packages/react-simplikit/dist/hooks/useThrottle/useThrottle.mjs 838 B
packages/react-simplikit/dist/hooks/useThrottledCallback/useThrottledCallback.cjs 1.02 kB
packages/react-simplikit/dist/hooks/useThrottledCallback/useThrottledCallback.mjs 993 B
packages/react-simplikit/dist/hooks/useThrottledValue/useThrottledValue.cjs 1.15 kB
packages/react-simplikit/dist/hooks/useThrottledValue/useThrottledValue.mjs 1.12 kB
packages/react-simplikit/dist/hooks/useTimeout/useTimeout.cjs 627 B
packages/react-simplikit/dist/hooks/useTimeout/useTimeout.mjs 600 B
packages/react-simplikit/dist/hooks/useToggle/useToggle.cjs 524 B
packages/react-simplikit/dist/hooks/useToggle/useToggle.mjs 506 B
packages/react-simplikit/dist/hooks/useVisibilityEvent/useVisibilityEvent.cjs 692 B
packages/react-simplikit/dist/hooks/useVisibilityEvent/useVisibilityEvent.mjs 674 B
packages/react-simplikit/dist/hooks/useVisualViewport/useVisualViewport.cjs 1.24 kB
packages/react-simplikit/dist/hooks/useVisualViewport/useVisualViewport.mjs 1.22 kB
packages/react-simplikit/dist/index.cjs 1.39 kB
packages/react-simplikit/dist/index.mjs 1.01 kB
packages/react-simplikit/dist/utils/buildContext/buildContext.cjs 809 B
packages/react-simplikit/dist/utils/buildContext/buildContext.mjs 788 B
packages/react-simplikit/dist/utils/disableBodyScrollLock/disableBodyScrollLock.cjs 677 B
packages/react-simplikit/dist/utils/disableBodyScrollLock/disableBodyScrollLock.mjs 668 B
packages/react-simplikit/dist/utils/enableBodyScrollLock/enableBodyScrollLock.cjs 672 B
packages/react-simplikit/dist/utils/enableBodyScrollLock/enableBodyScrollLock.mjs 662 B
packages/react-simplikit/dist/utils/getKeyboardHeight/getKeyboardHeight.cjs 609 B
packages/react-simplikit/dist/utils/getKeyboardHeight/getKeyboardHeight.mjs 602 B
packages/react-simplikit/dist/utils/getSafeAreaInset/getSafeAreaInset.cjs 902 B
packages/react-simplikit/dist/utils/getSafeAreaInset/getSafeAreaInset.mjs 893 B
packages/react-simplikit/dist/utils/isAndroid/isAndroid.cjs 543 B
packages/react-simplikit/dist/utils/isAndroid/isAndroid.mjs 534 B
packages/react-simplikit/dist/utils/isIOS/isIOS.cjs 753 B
packages/react-simplikit/dist/utils/isIOS/isIOS.mjs 746 B
packages/react-simplikit/dist/utils/isKeyboardVisible/isKeyboardVisible.cjs 431 B
packages/react-simplikit/dist/utils/isKeyboardVisible/isKeyboardVisible.mjs 419 B
packages/react-simplikit/dist/utils/isServer/isServer.cjs 381 B
packages/react-simplikit/dist/utils/isServer/isServer.mjs 379 B
packages/react-simplikit/dist/utils/mergeProps/mergeProps.cjs 671 B
packages/react-simplikit/dist/utils/mergeProps/mergeProps.mjs 670 B
packages/react-simplikit/dist/utils/mergeRefs/mergeRefs.cjs 616 B
packages/react-simplikit/dist/utils/mergeRefs/mergeRefs.mjs 614 B
packages/react-simplikit/dist/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.cjs 1.12 kB
packages/react-simplikit/dist/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.mjs 1.1 kB

compressed-size-action

@codecov-commenter

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (825b5db) to head (e8715e1).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #484   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           69        69           
  Lines         2242      2261   +19     
  Branches       720       728    +8     
=========================================
+ Hits          2242      2261   +19     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new uncontrolled onChange effect can double-fire onChange when transitioning from uncontrolled → controlled → uncontrolled due to a stale prevUncontrolledRef while controlled.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adjusts useControlledState so uncontrolled functional updates (setValue(prev => ...)) are applied via React’s internal update queue (preserving update ordering within the same tick) and moves uncontrolled onChange notifications to run after commit (via an effect), while intentionally leaving controlled functional updates computed from the committed value prop to avoid re-render loops when the parent rejects updates.

Changes:

  • Route uncontrolled setValue through setUncontrolledState(prev => ...) so multiple functional updates in the same tick apply in order.
  • Trigger uncontrolled onChange once after commit with the final value (instead of synchronously per setValue call).
  • Add/extend test coverage for the new uncontrolled semantics and for the controlled “parent rejects change” non-rerender behavior; add a patch changeset.
File summaries
File Description
packages/react-simplikit/src/hooks/useControlledState/useControlledState.ts Implements queued functional updates in uncontrolled mode and post-commit onChange notification via an effect.
packages/react-simplikit/src/hooks/useControlledState/useControlledState.spec.tsx Adds tests for unordered functional updates fix (uncontrolled), onChange timing, and controlled rejection behavior.
.changeset/fix-controlled-state-function-updates.md Publishes a patch note describing the uncontrolled functional-update ordering change and onChange timing change.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…once

In controlled mode `setValue(undefined)` also writes the internal state so the
value stays `undefined` once the parent hands control back. The notify effect
skips controlled renders, so its last-reported ref went stale and reported the
same change a second time when the hook became uncontrolled. Sync the ref at
the write site instead. Also rename three tests that stacked two "when" clauses.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new uncontrolled-change effect currently depends on equalityFn identity, which can re-run the effect (and potentially fire onChange) even when the uncontrolled state did not change.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

packages/react-simplikit/src/hooks/useControlledState/useControlledState.ts:69

  • notifyUncontrolledChange depends on the equalityFn function identity, so if a caller passes an inline comparator (or changes comparator logic) the effect will re-run even when uncontrolledState hasn't changed; in the worst case this can trigger an onChange call without any state change. Consider preserving equalityFn (like onChange) and depending on the preserved wrapper instead, so the effect only runs when controlled/uncontrolledState change.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@hyesungoh
hyesungoh marked this pull request as draft September 9, 2026 14:43
… drop the controlled guard

The notify effect compared with `equalityFn`, so a non-reflexive comparator
reported a change on mount, and an inline comparator re-ran the effect every
render. The uncontrolled updater already folds equal values into `prev`, so a
reference check is enough. The `controlled` early return only delayed the
notification when the parent took control in the same event as the change;
without it the change is reported at that commit.

Also pin behaviour the previous tests left uncovered: the latest `onChange`
is used after the parent swaps it, a change back to the initial value is
reported, a controlled function update computes from the `value` prop, and an
equal value keeps the previous state reference.

Widen the changeset to cover plain values, the extra parent render, skipped
calls for an unchanged final value, and the dropped notification on unmount.
@hyesungoh
hyesungoh marked this pull request as ready for review September 9, 2026 14:59
@hyesungoh
hyesungoh merged commit c974fdc into main Sep 9, 2026
19 checks passed
@hyesungoh
hyesungoh deleted the fix/useControlledState-multiple-functional-updates branch September 9, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants