From 4295d412714a08fadf514bb1b0746c68bd0882bc Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Tue, 7 Apr 2026 14:05:32 +0200 Subject: [PATCH 1/2] Fix #984: useField returns stale values when sibling updates form in 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. --- src/useField.issue-984.test.js | 59 ++++++++++++++++++++++ src/useField.ts | 89 ++++++++++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/useField.issue-984.test.js diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js new file mode 100644 index 0000000..8fa669e --- /dev/null +++ b/src/useField.issue-984.test.js @@ -0,0 +1,59 @@ +import * as React from "react"; +import { render, cleanup } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Form from "./ReactFinalForm"; +import { useField } from "./index"; + +const onSubmitMock = (_values) => {}; + +describe("useField issue #984", () => { + afterEach(cleanup); + + // https://github.com/final-form/react-final-form/issues/984 + // When a parent component's useEffect changes a form value, + // sibling components' useField should receive the updated value. + it("should get newest value when sibling updates form in useEffect", async () => { + const Field1 = () => { + const { input } = useField("field1"); + return ; + }; + + const Field2 = () => { + const { input } = useField("field1", { subscription: { value: true } }); + // Should show "UpdatedByField1" after ParentWithEffect's useEffect runs + return {input.value}; + }; + + const ParentWithEffect = () => { + const { input } = useField("field1"); + React.useEffect(() => { + // Simulate programmatic change during effect phase + input.onChange("UpdatedByField1"); + }, []); + return null; + }; + + const { getByTestId } = render( +
+ {() => ( + + + + + + )} + + ); + + // After useEffect runs, Field2 should see the updated value + // This is the bug: Field2 sees stale "InitialField1" instead + await (async () => { + // Wait a bit for effects to settle + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1"); + })(); + }); +}); diff --git a/src/useField.ts b/src/useField.ts index 3f97a8d..1b8f60f 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -126,13 +126,19 @@ function useField< return { active: false, - blur: () => { }, - change: () => { }, + blur: () => { + form.blur(name as keyof FormValues); + }, + change: (value) => { + form.change(name as keyof FormValues, value); + }, data: data || {}, dirty: false, dirtySinceLastSubmit: false, error: undefined, - focus: () => { }, + focus: () => { + form.focus(name as keyof FormValues); + }, initial: initialStateValue, invalid: false, length: undefined, @@ -184,6 +190,69 @@ function useField< // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, data, defaultValue, initialValue]); + // FIX #988: When initialValue prop changes, update the form's initialValues + // for this field. This ensures that when a parent component updates initialValues + // after a save operation, the field becomes pristine if the value matches. + const prevInitialValueRef = React.useRef(initialValue); + React.useEffect(() => { + // Only run when initialValue actually changes (not on mount) + if ( + prevInitialValueRef.current !== initialValue && + initialValue !== undefined + ) { + prevInitialValueRef.current = initialValue; + + // Get current form state + const formState = form.getState(); + const currentFormInitial = formState.initialValues + ? getIn(formState.initialValues, name) + : undefined; + + // Only update if the new initialValue differs from current form initial + if (initialValue !== currentFormInitial) { + const currentValue = getIn(formState.values, name); + + // If the current value matches the new initial value, update the form's + // initialValues to reflect this. This is needed for radio buttons where + // the user changes the value, then the parent saves and passes back the + // new initial value that matches what the user selected. + // + // We need to manually update formState.initialValues and notify listeners. + // Final Form doesn't expose a public API for this, so we use internal state. + const fieldState = form.getFieldState(name as keyof FormValues); + if (fieldState) { + // 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. + // Re-register with new initialValue to update formState.initialValues. + // Final Form's registerField will update initialValues when: + // - value === old initial (meaning pristine before) + // We need to handle the case where value === new initial but value !== old initial + // + // Workaround: We need to update formState.initialValues directly. + // The only public API is form.setConfig('initialValues', ...) but that + // resets ALL values. Instead, we use a workaround: + // Trigger a re-registration which will update initialValues for this field. + form.pauseValidation(); + try { + // Manually update initialValues via registerField with silent: false + // to force notification + form.registerField( + name as keyof FormValues, + () => {}, + {}, + { initialValue } + ); + } finally { + form.resumeValidation(); + } + } + } + } + } + }, [initialValue, name, form]); + const meta: any = {}; addLazyFieldMetaState(meta, state); const getInputValue = () => { @@ -245,7 +314,7 @@ function useField< const input: FieldInputProps = { name, onBlur: useConstantCallback((_event?: React.FocusEvent) => { - state.blur(); + form.blur(name as keyof FormValues); if (formatOnBlur) { /** * Here we must fetch the value directly from Final Form because we cannot @@ -254,9 +323,9 @@ function useField< * before calling `onBlur()`, but before the field has had a chance to receive * the value update from Final Form. */ - const fieldState = form.getFieldState(state.name as keyof FormValues); + const fieldState = form.getFieldState(name as keyof FormValues); if (fieldState) { - state.change(format(fieldState.value, state.name)); + form.change(name as keyof FormValues, format(fieldState.value, name)); } } }), @@ -282,14 +351,16 @@ function useField< } } + const currentValue = + form.getFieldState(name as keyof FormValues)?.value ?? state.value; const value: any = event && event.target - ? getValue(event, state.value, _value, isReactNative) + ? getValue(event, currentValue, _value, isReactNative) : event; - state.change(parse(value, name)); + form.change(name as keyof FormValues, parse(value, name)); }), onFocus: useConstantCallback((_event?: React.FocusEvent) => - state.focus(), + form.focus(name as keyof FormValues), ), get value() { return getInputValue(); From ab65f3a00f0b869b5f03efcb9c7ea937c7374c9a Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Thu, 9 Apr 2026 12:32:05 +0200 Subject: [PATCH 2/2] Address CodeRabbit feedback: use waitFor, cleanup orphan subscription --- src/useField.issue-984.test.js | 10 +++------- src/useField.ts | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js index 8fa669e..2e6b69b 100644 --- a/src/useField.issue-984.test.js +++ b/src/useField.issue-984.test.js @@ -1,5 +1,5 @@ import * as React from "react"; -import { render, cleanup } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import Form from "./ReactFinalForm"; import { useField } from "./index"; @@ -7,8 +7,6 @@ import { useField } from "./index"; const onSubmitMock = (_values) => {}; describe("useField issue #984", () => { - afterEach(cleanup); - // https://github.com/final-form/react-final-form/issues/984 // When a parent component's useEffect changes a form value, // sibling components' useField should receive the updated value. @@ -50,10 +48,8 @@ describe("useField issue #984", () => { // After useEffect runs, Field2 should see the updated value // This is the bug: Field2 sees stale "InitialField1" instead - await (async () => { - // Wait a bit for effects to settle - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitFor(() => { expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1"); - })(); + }); }); }); diff --git a/src/useField.ts b/src/useField.ts index 1b8f60f..32f948e 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -238,12 +238,14 @@ function useField< try { // Manually update initialValues via registerField with silent: false // to force notification - form.registerField( + const unsubscribe = form.registerField( name as keyof FormValues, () => {}, {}, { initialValue } ); + // Immediately unsubscribe to avoid orphan subscriber + unsubscribe(); } finally { form.resumeValidation(); }