Skip to content

Fix #984: useField returns stale values when sibling updates form in useEffect#1085

Open
erikras-richard-agent wants to merge 2 commits intomainfrom
fix-usefield-stale-values-984
Open

Fix #984: useField returns stale values when sibling updates form in useEffect#1085
erikras-richard-agent wants to merge 2 commits intomainfrom
fix-usefield-stale-values-984

Conversation

@erikras-richard-agent
Copy link
Copy Markdown
Contributor

@erikras-richard-agent erikras-richard-agent commented Apr 7, 2026

Problem

When a parent/sibling component's useEffect changes a form value, other useField hooks see stale values because their subscription hasn't registered yet.

The initial state had no-op blur/change/focus handlers, so calls to input.onChange() during the effect phase were silently dropped.

Solution

Replace no-op handlers with live form-backed handlers that call form.blur/form.change/form.focus directly. This ensures effect-time changes propagate immediately, before the permanent subscription is registered.

Test

Added regression test in useField.issue-984.test.js that verifies sibling useField hooks receive updated values when another field changes the form in useEffect.

Fixes #984

Summary by CodeRabbit

  • Bug Fixes

    • Improved form field handlers to delegate to central form APIs so updates, focus/blur/change, and formatting propagate reliably across related fields and when initial values change.
  • Tests

    • Added test coverage confirming sibling field updates and initial-value synchronization during component lifecycle.

…useEffect

Problem: When a parent/sibling component's useEffect changes a form value,
other useField hooks see stale values because their subscription hasn't
registered yet. The initial state had no-op blur/change/focus handlers.

Fix: Replace no-op handlers with live form-backed handlers that call
form.blur/form.change/form.focus directly, so effect-time changes
propagate immediately before the permanent subscription is registered.

Also includes #988 fix for radio button dirty state when initialValue changes.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Added a Jest test reproducing a useEffect-driven value update and modified useField to delegate blur/focus/change to Final Form, read current values from form.getFieldState, use form.change for parses/formatOnBlur, and detect initialValue prop changes by re-registering the field to refresh Final Form internals.

Changes

Cohort / File(s) Summary
Test
src/useField.issue-984.test.js
New Jest test that mounts a Form with three components (field, subscriber, parent effect) to assert that a useEffect-triggered form.change updates sibling subscribers.
useField implementation
src/useField.ts
Handlers (blur, focus, change) now call Final Form APIs (form.blur/form.focus/form.change) instead of mutating local state; parsing/formatting now derives current value via form.getFieldState(name)?.value; added effect to detect initialValue prop changes and re-register the field (pausing/resuming validation) to force Final Form to recompute initialValues/dirty state.

Sequence Diagram(s)

sequenceDiagram
  participant Parent as ParentWithEffect
  participant Field1 as Field1 (useField)
  participant FormAPI as FinalForm (form)
  participant Field2 as Field2 (subscriber)

  Parent->>Field1: mount -> calls useField(name="field1")
  Field1->>FormAPI: registerField(name="field1")
  Parent-->>FormAPI: useEffect triggers -> form.change("field1","UpdatedByField1")
  FormAPI-->>Field2: notify subscribers (value updated)
  Field2->>Field2: render updated value "UpdatedByField1"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • erikras
  • erikras-richard-agent

Poem

🐰 In a field that slipped and hid its tune,

I nudged the form and hummed a rune.
Now values hop from root to leaf,
Subscribers wake — no stale belief.
— The Code Rabbit 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix #984: useField returns stale values when sibling updates form in useEffect' clearly and specifically describes the main change: fixing a bug where useField hooks return stale values in a particular scenario.
Linked Issues check ✅ Passed The PR directly addresses the coding objective from issue #984: enabling useField to receive the newest form value when a sibling or parent updates the form inside useEffect, with both implementation changes and a regression test provided.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing issue #984: modifications to useField.ts replace no-op handlers with form-backed handlers to propagate effect-time updates, and the new test validates the fix without introducing unrelated changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-usefield-stale-values-984

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/useField.issue-984.test.js`:
- Line 10: Remove the manual cleanup call: delete the afterEach(cleanup)
invocation (and any unused import of cleanup) from the test file since React
Testing Library (v9+) auto-cleans between tests; locate the afterEach(cleanup)
line in the test (and the cleanup import if present) and remove them to simplify
the test setup.
- Around line 51-57: The test is using a fragile async IIFE with setTimeout to
wait for state updates; replace that with React Testing Library's waitFor:
import waitFor from '@testing-library/react' (or use the exported waitFor) and
change the block that awaits the 100ms timeout to await waitFor(() =>
expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1")); remove
the setTimeout and the IIFE so the test relies on waitFor to poll for the DOM
update triggered by the useEffect.

In `@src/useField.ts`:
- Around line 237-249: The registerField call is creating an unused
subscription; capture its returned unsubscribe function from
form.registerField(name as keyof FormValues, () => {}, {}, { initialValue }) and
call that unsubscribe immediately after registering (while still inside the try
block) to avoid leaving an orphan subscriber; keep the surrounding
form.pauseValidation() and form.resumeValidation() usage and ensure the
unsubscribe is invoked before resumeValidation() is reached.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5aced691-0dfa-48f1-ad9a-0f5b563b4cb7

📥 Commits

Reviewing files that changed from the base of the PR and between fcea1a1 and 4295d41.

📒 Files selected for processing (2)
  • src/useField.issue-984.test.js
  • src/useField.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/useField.issue-984.test.js`:
- Around line 27-30: The React.useEffect in the test intentionally uses an empty
dependency array to run only on mount while calling input.onChange; to prevent
eslint from flagging this, add an ESLint disable comment above the effect (e.g.,
// eslint-disable-next-line react-hooks/exhaustive-deps) to document the intent
that input.onChange is stable (via useConstantCallback) and that the empty deps
are deliberate; place the comment immediately before the React.useEffect block
that calls input.onChange.
- Around line 51-53: The test waits for an element queried by
getByTestId("field1-value") and asserts its text via .textContent; replace that
assertion with the jest-dom matcher toHaveTextContent for clearer, idiomatic
failure messages—inside the same waitFor callback, call
expect(getByTestId("field1-value")) and assert
.toHaveTextContent("UpdatedByField1") instead, keeping the existing waitFor and
getByTestId usage.

In `@src/useField.ts`:
- Around line 224-227: The equality checks in useField that compare
prevInitialValueRef.current, initialValue, and currentValue are using strict
reference equality (!== / ===) which breaks for recreated objects/arrays; update
both comparisons to use the configured equality function: call
configRef.current.isEqual if present, otherwise fallback to (a, b) => a === b,
and use that helper when comparing prevInitialValueRef.current vs initialValue
and currentValue vs initialValue so custom equality logic is respected and
object/array initialValue props behave correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b726f19e-2091-4ebc-b954-7b8e4ad0ca1b

📥 Commits

Reviewing files that changed from the base of the PR and between 4295d41 and ab65f3a.

📒 Files selected for processing (2)
  • src/useField.issue-984.test.js
  • src/useField.ts

Comment on lines +27 to +30
React.useEffect(() => {
// Simulate programmatic change during effect phase
input.onChange("UpdatedByField1");
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding ESLint disable comment for exhaustive-deps.

The empty dependency array is intentional to simulate a mount-only effect. Since input.onChange is stable (via useConstantCallback), this is safe, but ESLint's react-hooks/exhaustive-deps rule would flag it. Adding a disable comment documents the intent.

♻️ Proposed fix
       React.useEffect(() => {
         // Simulate programmatic change during effect phase
         input.onChange("UpdatedByField1");
+        // eslint-disable-next-line react-hooks/exhaustive-deps
       }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
React.useEffect(() => {
// Simulate programmatic change during effect phase
input.onChange("UpdatedByField1");
}, []);
React.useEffect(() => {
// Simulate programmatic change during effect phase
input.onChange("UpdatedByField1");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/useField.issue-984.test.js` around lines 27 - 30, The React.useEffect in
the test intentionally uses an empty dependency array to run only on mount while
calling input.onChange; to prevent eslint from flagging this, add an ESLint
disable comment above the effect (e.g., // eslint-disable-next-line
react-hooks/exhaustive-deps) to document the intent that input.onChange is
stable (via useConstantCallback) and that the empty deps are deliberate; place
the comment immediately before the React.useEffect block that calls
input.onChange.

Comment on lines +51 to +53
await waitFor(() => {
expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1");
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Prefer toHaveTextContent matcher for clearer assertions.

Since @testing-library/jest-dom is imported, using toHaveTextContent provides better error messages on failure and is the idiomatic approach.

♻️ Proposed fix
     await waitFor(() => {
-      expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1");
+      expect(getByTestId("field1-value")).toHaveTextContent("UpdatedByField1");
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await waitFor(() => {
expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1");
});
await waitFor(() => {
expect(getByTestId("field1-value")).toHaveTextContent("UpdatedByField1");
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/useField.issue-984.test.js` around lines 51 - 53, The test waits for an
element queried by getByTestId("field1-value") and asserts its text via
.textContent; replace that assertion with the jest-dom matcher toHaveTextContent
for clearer, idiomatic failure messages—inside the same waitFor callback, call
expect(getByTestId("field1-value")) and assert
.toHaveTextContent("UpdatedByField1") instead, keeping the existing waitFor and
getByTestId usage.

Comment on lines +224 to +227
// Force an update through the field subscriber by triggering a change
// to the same value, which will recalculate dirty state with new initial
if (currentValue === initialValue) {
// The value matches the new initial, so field should become pristine.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if isEqual is used elsewhere in the codebase for value comparisons
rg -n "isEqual" --type=ts src/

Repository: final-form/react-final-form

Length of output: 413


🏁 Script executed:

# Read the useField.ts file focusing on the effect at lines 193-256
sed -n '193,256p' src/useField.ts | cat -n

Repository: final-form/react-final-form

Length of output: 3622


🏁 Script executed:

# Also check the config/types structure to understand isEqual availability
sed -n '1,100p' src/useField.ts | cat -n

Repository: final-form/react-final-form

Length of output: 3824


🏁 Script executed:

# Check how isEqual is actually used in useWhenValueChanges for comparison patterns
sed -n '1,30p' src/useWhenValueChanges.ts | cat -n

Repository: final-form/react-final-form

Length of output: 525


🏁 Script executed:

# Search for test cases using initialValue with objects/arrays or non-primitive values
rg -n "initialValue" --type=ts --type=tsx -B2 -A2 src/__tests__/ | head -100

Repository: final-form/react-final-form

Length of output: 98


🏁 Script executed:

# Check if there are examples or documentation about object/array initialValues
rg -n "initialValue.*\{|\[" --type=ts --type=tsx src/ | head -50

Repository: final-form/react-final-form

Length of output: 98


🏁 Script executed:

# Check what the default isEqual should be when config.isEqual is undefined
rg -n "isEqual.*=" --type=ts -B1 -A1 src/types.ts

Repository: final-form/react-final-form

Length of output: 167


🏁 Script executed:

# Search for how registerField is called to see how isEqual is handled
rg -n "registerField" src/useField.ts -B2 -A10 | head -60

Repository: final-form/react-final-form

Length of output: 1943


🏁 Script executed:

# Look for actual usage of initialValue with objects/arrays in tests
find src -name "*.test.ts" -o -name "*.test.js" | xargs rg -l "initialValue" 2>/dev/null | head -5

Repository: final-form/react-final-form

Length of output: 207


🏁 Script executed:

# Check final-form package to see how it handles isEqual (look at node_modules or imports)
rg -n "isEqual" src/useField.ts -B3 -A3

Repository: final-form/react-final-form

Length of output: 271


🏁 Script executed:

# Look at the test file for useField to see if there are object/array initialValue tests
head -200 src/useField.test.js | cat -n

Repository: final-form/react-final-form

Length of output: 8734


🏁 Script executed:

# Search specifically for initialValue tests with objects
rg -n "initialValue.*\{" src/useField.test.js -A5 | head -80

Repository: final-form/react-final-form

Length of output: 2529


🏁 Script executed:

# Check the issue-984 test since the comment mentions FIX `#988`
cat src/useField.issue-984.test.js | head -100

Repository: final-form/react-final-form

Length of output: 1837


The strict equality comparison may fail for object/array initialValue props.

Both the condition at line 8 (prevInitialValueRef.current !== initialValue) and line 34 (currentValue === initialValue) use strict reference equality. If initialValue is an object or array recreated on each render, these comparisons could behave unexpectedly—either always true or always false depending on whether references match.

Use configRef.current.isEqual (or a fallback like (a, b) => a === b when undefined) for consistency with how Final Form handles equality elsewhere in the codebase, and to respect custom equality functions provided by users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/useField.ts` around lines 224 - 227, The equality checks in useField that
compare prevInitialValueRef.current, initialValue, and currentValue are using
strict reference equality (!== / ===) which breaks for recreated objects/arrays;
update both comparisons to use the configured equality function: call
configRef.current.isEqual if present, otherwise fallback to (a, b) => a === b,
and use that helper when comparing prevInitialValueRef.current vs initialValue
and currentValue vs initialValue so custom equality logic is respected and
object/array initialValue props behave correctly.

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.

Bug: useField can not get newest value

1 participant