fix: Call onChange with initial state in useEffect for FormSpy#1076
fix: Call onChange with initial state in useEffect for FormSpy#1076
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReworks useFormState to avoid calling onChange during render by deferring subscription-triggered callbacks using refs and a post-render effect; ensures subscription still updates state at least once and deduplicates emitted onChange values. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
No actionable comments were generated in the recent review. 🎉 Comment |
a7625e8 to
c6f644f
Compare
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/useFormState.ts (1)
29-45: 🧹 Nitpick | 🔵 TrivialSide effect inside
setStateupdater function is non-idiomatic.Calling
onChangeRef.current(newState)inside the functional setState updater is a side effect within what React expects to be a pure computation. While this works in practice due to React 18+'s automatic batching, it's not a clean pattern.Consider extracting the side effect outside the updater:
♻️ Proposed refactor to separate state computation from side effects
React.useEffect(() => { // Subscribe to form state changes after initial render const unsubscribe = form.subscribe((newState) => { - setState((prevState) => { - // Always call onChange on first subscription (initial state), even if equal - // This fixes `#809` - ensures onChange is called after render, not during - const isFirstCall = firstCallRef.current; - if (isFirstCall) { - firstCallRef.current = false; - } - - const stateChanged = !shallowEqual(newState, prevState); - if (stateChanged || isFirstCall) { - if (onChangeRef.current) { - onChangeRef.current(newState); - } - return newState; - } - return prevState; - }); + const isFirstCall = firstCallRef.current; + if (isFirstCall) { + firstCallRef.current = false; + } + + setState((prevState) => { + if (!shallowEqual(newState, prevState) || isFirstCall) { + return newState; + } + return prevState; + }); + + // Call onChange outside the updater to avoid side effects in pure function + if (isFirstCall || !shallowEqual(newState, stateRef.current)) { + if (onChangeRef.current) { + onChangeRef.current(newState); + } + } }, subscription); return unsubscribe;Note: The suggested refactor would require adding a
stateRefto track previous state for comparison outside the updater. Alternatively, if the current approach passes all tests and React's batching handles it correctly, it may be acceptable to keep as-is with a comment explaining the trade-off.
c6f644f to
8ad4775
Compare
d8f3d91 to
61ce9b2
Compare
|
Thanks for the review @coderabbitai! I initially tried implementing your suggestion to move the onChange call outside the setState updater, but it caused test failures. The issue is that when we track state in a ref (), the ref doesn't update until after the component re-renders, which happens asynchronously after the subscription callback fires. This creates a race condition where we compare against an outdated , causing incorrect behavior when the onChange callback itself depends on the parent component's render state (as tested in the 'should not rerender when onChange changes' test). While calling onChange inside the setState updater is not the cleanest pattern, it works correctly because:
I've added a comment explaining this trade-off. Happy to discuss alternative approaches if you see a better solution! |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 2126
You're absolutely right about the race condition with refs – they only update after the component re-renders, which would indeed cause
While calling side effects inside The implementation looks solid – it successfully addresses issue |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/useFormState.ts (2)
29-46:⚠️ Potential issue | 🟡 MinorSide effect in setState updater is an anti-pattern.
React explicitly documents that setState updater functions should be pure. While the comment acknowledges this is "not ideal," this pattern can cause issues:
- In Strict Mode, React may call updaters twice to surface impure logic
- In Concurrent Mode, updaters may be interrupted and re-executed
- Calling
onChange(which may trigger parent state updates) inside an updater can lead to unpredictable schedulingThe fix does solve the original issue (
#809) by deferring onChange until after render. However, consider an alternative approach that separates the side effect from the state updater:♻️ Alternative: separate effect-based onChange invocation
React.useEffect(() => { const unsubscribe = form.subscribe((newState) => { setState((prevState) => { - const isFirstCall = firstCallRef.current; - if (isFirstCall) { - firstCallRef.current = false; - } - - const stateChanged = !shallowEqual(newState, prevState); - if (stateChanged || isFirstCall) { - // Side effect inside setState updater - not ideal but works correctly - // and ensures onChange is called with the correct state synchronously - if (onChangeRef.current) { - onChangeRef.current(newState); - } + if (!shallowEqual(newState, prevState)) { return newState; } return prevState; }); }, subscription); return unsubscribe; }, []); + + // Call onChange when state changes (including initial state) + const prevStateRef = React.useRef<FormState<FormValues> | null>(null); + React.useEffect(() => { + if (onChangeRef.current && !shallowEqual(state, prevStateRef.current)) { + onChangeRef.current(state); + } + prevStateRef.current = state; + }, [state]);This separates concerns: setState handles state updates purely, and a dedicated effect handles the onChange side effect.
50-52:⚠️ Potential issue | 🟠 MajorCleanup function should reset
firstCallRef.The current cleanup only returns
unsubscribe, butfirstCallRef.currentremainsfalseafter the effect runs. This is problematic for Strict Mode remounting behavior.🐛 Proposed fix
- return unsubscribe; + return () => { + firstCallRef.current = true; + unsubscribe(); + };
🤖 Fix all issues with AI agents
In `@src/useFormState.ts`:
- Around line 23-24: The ref firstCallRef in useFormState is not reset during
effect cleanup, so after a Strict Mode remount the initial-onChange behavior is
lost; update the effect that subscribes to form state (the effect that reads
firstCallRef.current and calls the initial onChange) to return a cleanup
function that sets firstCallRef.current = true (resetting the ref) so each mount
treats the next callback as the first call; leave the useRef initialization
as-is and only reset the .current value in the effect's cleanup.
61ce9b2 to
44d1022
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/useFormState.ts`:
- Around line 38-46: The post‑render effect now suppresses per‑update onChange
calls due to React 18 batching; restore per‑update notifications by invoking
onChangeRef.current(newState) from inside the form.subscribe updater whenever
the subscription produces a new state that differs (use the same shallowEqual
logic used with prevStateRef), and keep the existing React.useEffect block only
for the initial post‑render call (make the effect bail out if
prevStateRef.current is non‑null so it doesn't duplicate subscription
notifications). Reference: prevStateRef, onChangeRef, state, form.subscribe, and
shallowEqual.
d1a7b99 to
1556bfc
Compare
Fixes #809 Problem: When using FormSpy with an onChange callback that calls setState in a parent component, React throws a warning: 'Cannot update a component while rendering a different component' Additionally, the current implementation doesn't call onChange with the initial form state because the shallowEqual check in the subscription callback prevents it (initial state === subscribed state). Root Cause: 1. The original JS implementation called onChange during the useState initialization (during render), causing React warnings 2. The current TS implementation fixed the warning but broke onChange for initial state due to shallowEqual check Solution: Added a separate useEffect (with empty deps) that explicitly calls onChange with the initial state after the first render completes. This ensures: - onChange is called AFTER render (no React warnings) - onChange IS called with initial state (expected behavior) - Subsequent changes still trigger onChange via subscription Changes: - src/useFormState.ts: - Added new useEffect to call onChange(state) after initial render - Runs only once with empty dependency array - Uses onChangeRef to always call the latest onChange Impact: ✅ Fixes 'Cannot update while rendering' warning ✅ onChange called with initial state as expected ✅ FormSpy tests pass ✅ No breaking changes Originally reported in issue #809, attempted fix in PR #965 (closed). Co-authored-by: Jinxuan Zhu <zhujinxuan@users.noreply.github.com>
1556bfc to
c6f8eb8
Compare
|
Pushed an update that fixes the CI failures and addresses the onChange timing concerns without calling onChange during render. Key changes in :
Local: now passes. CI should be green on this push. |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Fixes #809
Problem:
When using FormSpy with an onChange callback that calls setState in a parent component, React throws a warning: 'Cannot update a component while rendering a different component'
Additionally, the current implementation doesn't call onChange with the initial form state because the shallowEqual check in the subscription callback prevents it (initial state === subscribed state).
Root Cause:
Solution:
Added a separate useEffect (with empty deps) that explicitly calls onChange with the initial state after the first render completes. This ensures:
Changes:
Impact:
✅ Fixes 'Cannot update while rendering' warning
✅ onChange called with initial state as expected
✅ FormSpy tests pass
✅ No breaking changes
Originally reported in issue #809, attempted fix in PR #965 (closed).
Co-authored-by: Jinxuan Zhu zhujinxuan@users.noreply.github.com
Summary by CodeRabbit