fix(useControlledState): apply function updates in order when uncontrolled - #484
Conversation
…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>
🦋 Changeset detectedLatest commit: e8715e1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
Size Change: +318 B (+0.29%) Total Size: 109 kB 📦 View Changed
ℹ️ View Unchanged
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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:
|
There was a problem hiding this comment.
🟡 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
setValuethroughsetUncontrolledState(prev => ...)so multiple functional updates in the same tick apply in order. - Trigger uncontrolled
onChangeonce after commit with the final value (instead of synchronously persetValuecall). - 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.
There was a problem hiding this comment.
🔵 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
notifyUncontrolledChangedepends on theequalityFnfunction identity, so if a caller passes an inline comparator (or changes comparator logic) the effect will re-run even whenuncontrolledStatehasn't changed; in the worst case this can trigger anonChangecall without any state change. Consider preservingequalityFn(likeonChange) and depending on the preserved wrapper instead, so the effect only runs whencontrolled/uncontrolledStatechange.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
… 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.
Overview
Supersedes #344. Co-authored with @kyukyu-dev, whose test case this builds on.
Problem
useControlledStatecomputed function updates from thevaluecaptured at render, so two updates in the same tick both saw the sameprev:This affected both modes.
What changed
Uncontrolled mode now hands the updater to React's own
useStatequeue, so multiplesetValuecalls in the same tick apply in order. This covers plain values too:setValue('b')followed bysetValue('a')from'a'now settles on'a'instead of'b'.onChangeis called once after commit with the final value. It used to be called synchronously on everysetValuecall, so:onChangeinto its own state renders once more per updateIn 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 sameprev.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
setValueon every render never settles:A fresh object in an effect's deps triggers the same loop, and a
useEffectvariant 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 inuseControllableState: fix uncontrolled mode with React's queue, accept the limitation in controlled mode. React Aria'suseControlledStatetakes 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 staysundefinedonce 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.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 intoprev, so the effect now uses a reference check. Itscontrolledearly return only delayed the notification when the parent took control in the same event as the change, so it is gone.Tests
Uncontrolled:
onChangefires once with the final valueonChangeis not called on mount, also with a non-reflexiveequalityFnequalityFntreats as equal neither callsonChangenor replaces the state referenceonChangeis used after the parent swaps itControlled:
valueexternally is reflected, and a function update toggles through the parent (both ported from Radix'suseControllableStatetests)valueprop, not the internal statesetValue(undefined)handing control back reports toonChangeonce, not twiceChecklist
yarn run fixto format and lint the code and docs?yarn run test:coverageto make sure there is no uncovered line?