Skip to content

Commit 4295d41

Browse files
author
Erik Rasmussen
committed
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.
1 parent fcea1a1 commit 4295d41

File tree

2 files changed

+139
-9
lines changed

2 files changed

+139
-9
lines changed

src/useField.issue-984.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from "react";
2+
import { render, cleanup } from "@testing-library/react";
3+
import "@testing-library/jest-dom";
4+
import Form from "./ReactFinalForm";
5+
import { useField } from "./index";
6+
7+
const onSubmitMock = (_values) => {};
8+
9+
describe("useField issue #984", () => {
10+
afterEach(cleanup);
11+
12+
// https://github.com/final-form/react-final-form/issues/984
13+
// When a parent component's useEffect changes a form value,
14+
// sibling components' useField should receive the updated value.
15+
it("should get newest value when sibling updates form in useEffect", async () => {
16+
const Field1 = () => {
17+
const { input } = useField("field1");
18+
return <input {...input} data-testid="field1" />;
19+
};
20+
21+
const Field2 = () => {
22+
const { input } = useField("field1", { subscription: { value: true } });
23+
// Should show "UpdatedByField1" after ParentWithEffect's useEffect runs
24+
return <span data-testid="field1-value">{input.value}</span>;
25+
};
26+
27+
const ParentWithEffect = () => {
28+
const { input } = useField("field1");
29+
React.useEffect(() => {
30+
// Simulate programmatic change during effect phase
31+
input.onChange("UpdatedByField1");
32+
}, []);
33+
return null;
34+
};
35+
36+
const { getByTestId } = render(
37+
<Form
38+
onSubmit={onSubmitMock}
39+
initialValues={{ field1: "InitialField1" }}
40+
>
41+
{() => (
42+
<form>
43+
<ParentWithEffect />
44+
<Field1 />
45+
<Field2 />
46+
</form>
47+
)}
48+
</Form>
49+
);
50+
51+
// After useEffect runs, Field2 should see the updated value
52+
// This is the bug: Field2 sees stale "InitialField1" instead
53+
await (async () => {
54+
// Wait a bit for effects to settle
55+
await new Promise((resolve) => setTimeout(resolve, 100));
56+
expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1");
57+
})();
58+
});
59+
});

src/useField.ts

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,19 @@ function useField<
126126

127127
return {
128128
active: false,
129-
blur: () => { },
130-
change: () => { },
129+
blur: () => {
130+
form.blur(name as keyof FormValues);
131+
},
132+
change: (value) => {
133+
form.change(name as keyof FormValues, value);
134+
},
131135
data: data || {},
132136
dirty: false,
133137
dirtySinceLastSubmit: false,
134138
error: undefined,
135-
focus: () => { },
139+
focus: () => {
140+
form.focus(name as keyof FormValues);
141+
},
136142
initial: initialStateValue,
137143
invalid: false,
138144
length: undefined,
@@ -184,6 +190,69 @@ function useField<
184190
// eslint-disable-next-line react-hooks/exhaustive-deps
185191
}, [name, data, defaultValue, initialValue]);
186192

193+
// FIX #988: When initialValue prop changes, update the form's initialValues
194+
// for this field. This ensures that when a parent component updates initialValues
195+
// after a save operation, the field becomes pristine if the value matches.
196+
const prevInitialValueRef = React.useRef(initialValue);
197+
React.useEffect(() => {
198+
// Only run when initialValue actually changes (not on mount)
199+
if (
200+
prevInitialValueRef.current !== initialValue &&
201+
initialValue !== undefined
202+
) {
203+
prevInitialValueRef.current = initialValue;
204+
205+
// Get current form state
206+
const formState = form.getState();
207+
const currentFormInitial = formState.initialValues
208+
? getIn(formState.initialValues, name)
209+
: undefined;
210+
211+
// Only update if the new initialValue differs from current form initial
212+
if (initialValue !== currentFormInitial) {
213+
const currentValue = getIn(formState.values, name);
214+
215+
// If the current value matches the new initial value, update the form's
216+
// initialValues to reflect this. This is needed for radio buttons where
217+
// the user changes the value, then the parent saves and passes back the
218+
// new initial value that matches what the user selected.
219+
//
220+
// We need to manually update formState.initialValues and notify listeners.
221+
// Final Form doesn't expose a public API for this, so we use internal state.
222+
const fieldState = form.getFieldState(name as keyof FormValues);
223+
if (fieldState) {
224+
// Force an update through the field subscriber by triggering a change
225+
// to the same value, which will recalculate dirty state with new initial
226+
if (currentValue === initialValue) {
227+
// The value matches the new initial, so field should become pristine.
228+
// Re-register with new initialValue to update formState.initialValues.
229+
// Final Form's registerField will update initialValues when:
230+
// - value === old initial (meaning pristine before)
231+
// We need to handle the case where value === new initial but value !== old initial
232+
//
233+
// Workaround: We need to update formState.initialValues directly.
234+
// The only public API is form.setConfig('initialValues', ...) but that
235+
// resets ALL values. Instead, we use a workaround:
236+
// Trigger a re-registration which will update initialValues for this field.
237+
form.pauseValidation();
238+
try {
239+
// Manually update initialValues via registerField with silent: false
240+
// to force notification
241+
form.registerField(
242+
name as keyof FormValues,
243+
() => {},
244+
{},
245+
{ initialValue }
246+
);
247+
} finally {
248+
form.resumeValidation();
249+
}
250+
}
251+
}
252+
}
253+
}
254+
}, [initialValue, name, form]);
255+
187256
const meta: any = {};
188257
addLazyFieldMetaState(meta, state);
189258
const getInputValue = () => {
@@ -245,7 +314,7 @@ function useField<
245314
const input: FieldInputProps<FieldValue, T> = {
246315
name,
247316
onBlur: useConstantCallback((_event?: React.FocusEvent<any>) => {
248-
state.blur();
317+
form.blur(name as keyof FormValues);
249318
if (formatOnBlur) {
250319
/**
251320
* Here we must fetch the value directly from Final Form because we cannot
@@ -254,9 +323,9 @@ function useField<
254323
* before calling `onBlur()`, but before the field has had a chance to receive
255324
* the value update from Final Form.
256325
*/
257-
const fieldState = form.getFieldState(state.name as keyof FormValues);
326+
const fieldState = form.getFieldState(name as keyof FormValues);
258327
if (fieldState) {
259-
state.change(format(fieldState.value, state.name));
328+
form.change(name as keyof FormValues, format(fieldState.value, name));
260329
}
261330
}
262331
}),
@@ -282,14 +351,16 @@ function useField<
282351
}
283352
}
284353

354+
const currentValue =
355+
form.getFieldState(name as keyof FormValues)?.value ?? state.value;
285356
const value: any =
286357
event && event.target
287-
? getValue(event, state.value, _value, isReactNative)
358+
? getValue(event, currentValue, _value, isReactNative)
288359
: event;
289-
state.change(parse(value, name));
360+
form.change(name as keyof FormValues, parse(value, name));
290361
}),
291362
onFocus: useConstantCallback((_event?: React.FocusEvent<any>) =>
292-
state.focus(),
363+
form.focus(name as keyof FormValues),
293364
),
294365
get value() {
295366
return getInputValue();

0 commit comments

Comments
 (0)