From 09b74ac96cf9b9fb2b908e470da31c4b6bdd4c3a Mon Sep 17 00:00:00 2001 From: MarkXian Date: Sat, 11 Jul 2026 18:08:49 +0800 Subject: [PATCH 1/2] fix(react-form): use fresh FieldApi in current render on name change When a field's `name` changes (e.g. a non-last item is removed from a stably-keyed array), `useField` created a new FieldApi via `setFieldApi` during render but continued the current render pass with the stale instance. React discards that pass, but the render prop still runs to completion once with the stale FieldApi, whose store derives `undefined` for the path that no longer exists at the old index. This briefly surfaces `state.value === undefined` for surviving array items, crashing render code that treats the value strictly. Adjust state during render but also use the freshly created FieldApi for the remainder of this render, so the field reads state at its current `name`. Keeps the setState-based identity (React Compiler safe) while restoring the pre-1.27.0 behavior. Closes #2238 --- packages/react-form/src/useField.tsx | 18 +++-- packages/react-form/tests/useField.test.tsx | 80 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index b0d3eff9f..b40b1b42b 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -169,22 +169,28 @@ export function useField< name: opts.name, })) - const [fieldApi, setFieldApi] = useState(() => { + const [fieldApiState, setFieldApi] = useState(() => { return new FieldApi({ ...opts, }) }) + let fieldApi = fieldApiState + // We only want to // update on name changes since those are at risk of becoming stale. The field // state must be up to date for the internal JSX render. // The other options can freely be in `fieldApi.update` if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { - setFieldApi( - new FieldApi({ - ...opts, - }), - ) + // Adjusting state during render: create the new FieldApi and use it for the + // rest of this render so the render prop reads state at the current `name`. + // Otherwise the discarded render still runs to completion with the stale + // instance, briefly surfacing `undefined` for shifted array items on removal. + // See: https://github.com/TanStack/form/issues/2238 + fieldApi = new FieldApi({ + ...opts, + }) + setFieldApi(fieldApi) setPrevOptions({ form: opts.form, name: opts.name }) } diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index d45412fe9..445416594 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -962,6 +962,86 @@ describe('useField', () => { expect(fn).toHaveBeenCalledWith({ people: [{ name: 'John', age: 0 }] }) }) + it('should not surface undefined for shifted items when a non-last stably-keyed array item is removed', async () => { + // Regression test for https://github.com/TanStack/form/issues/2238 + // When array items are keyed by a stable id (not index), removing a + // non-last item changes the `name` prop of the surviving items. The field + // must read state at its current `name` on that render, never `undefined`. + const observedValues: Array = [] + + function Comp() { + const form = useForm({ + defaultValues: { + sections: [] as Array<{ id: string; title: string }>, + }, + }) + + let nextId = 0 + + return ( + + {(field) => { + return ( +
+ {field.state.value.map((section, i) => { + return ( + + {(subField) => { + observedValues.push(subField.state.value) + return ( + + ) + }} + + ) + })} + + +
+ ) + }} +
+ ) + } + + const { getByText, findByLabelText } = render() + + await user.click(getByText('Add section')) + await user.click(getByText('Add section')) + + await findByLabelText('Title for section id-1') + await findByLabelText('Title for section id-2') + + observedValues.length = 0 + await user.click(getByText('Remove first section')) + + // The surviving item (previously `sections[1]`) shifts to `sections[0]`. + // Its value must remain "Section 2" and never render as `undefined`. + const survivingInput = await findByLabelText('Title for section id-2') + expect((survivingInput as HTMLInputElement).value).toBe('Section 2') + expect(observedValues).not.toContain(undefined) + }) + it('should handle sync linked fields', async () => { const fn = vi.fn() function Comp() { From 374b4d96c212f8023b491b19c9944032dddafed2 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Sun, 12 Jul 2026 21:06:35 -0700 Subject: [PATCH 2/2] chore: add changeset --- .changeset/hot-sides-fetch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-sides-fetch.md diff --git a/.changeset/hot-sides-fetch.md b/.changeset/hot-sides-fetch.md new file mode 100644 index 000000000..1700ea248 --- /dev/null +++ b/.changeset/hot-sides-fetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-form': patch +--- + +Use fresh FieldApi in current render on name change