Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hot-sides-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-form': patch
---

Use fresh FieldApi in current render on name change

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mention form changes as well

The implementation refreshes FieldApi when either form or name changes, but this changeset only documents name changes. Please update the note so the published changelog accurately describes the fix.

Proposed wording
-Use fresh FieldApi in current render on name change
+Use fresh FieldApi in current render on form or name change
📝 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
Use fresh FieldApi in current render on name change
Use fresh FieldApi in current render on form or name change
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/hot-sides-fetch.md at line 5, Update the changeset note to
mention that a fresh FieldApi is used when either the form or field name
changes, accurately covering both triggers addressed by the implementation.

18 changes: 12 additions & 6 deletions packages/react-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down
80 changes: 80 additions & 0 deletions packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> = []

function Comp() {
const form = useForm({
defaultValues: {
sections: [] as Array<{ id: string; title: string }>,
},
})

let nextId = 0

return (
<form.Field name="sections" mode="array">
{(field) => {
return (
<div>
{field.state.value.map((section, i) => {
return (
<form.Field key={section.id} name={`sections[${i}].title`}>
{(subField) => {
observedValues.push(subField.state.value)
return (
<label>
<div>Title for section {section.id}</div>
<input
value={subField.state.value}
onChange={(e) =>
subField.handleChange(e.target.value)
}
/>
</label>
)
}}
</form.Field>
)
})}
<button
type="button"
onClick={() =>
field.pushValue({
id: `id-${(nextId += 1)}`,
title: `Section ${field.state.value.length + 1}`,
})
}
>
Add section
</button>
<button type="button" onClick={() => field.removeValue(0)}>
Remove first section
</button>
</div>
)
}}
</form.Field>
)
}

const { getByText, findByLabelText } = render(<Comp />)

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() {
Expand Down
Loading