Repository navigation
[v2][vue] Field keeps a stale field API after ArrayField removeValue() shifts items, so shifted errors aren't rendered #2391
Description
Describe the bug
With index keys (as in examples/vue/array), after array.removeValue(0) the form creates a new field API instance for issues[0].title, but the mounted <form.Field :name="issues[0].title"> keeps the old instance. The form meta for issues[0].title contains the error that shifted from the removed row's neighbour, but the slot never renders it.
This happens with markup rendered directly inside the <form.Field> slot. It's unrelated to createFormHook.
Diagnosis (uses the internal _tryGetFieldApi only to compare instances):
| Result | |
|---|---|
Before removal: slot field === form._tryGetFieldApi('issues[0].title') |
true |
| After removal: internal instance replaced | true |
After removal: slot field === internal instance |
false |
After removal: form.getFieldMeta('issues[0].title').errors |
["Required"] |
| After removal: rendered errors | none |
useField (src/VueForm/useField.lib.ts) only recreates the field API when form, name or resetVersion changes. After removeValue, the name stays the same, so the component isn't moved to the new instance.
Your minimal, reproducible example
<script setup lang="ts">
import { useForm } from '@tanstack/vue-form'
const form = useForm({
defaultValues: { issues: [{ title: 'A' }, { title: '' }] },
errorVisibility: () => true,
validators: [
{
triggers: ['change'],
run: ({ value, createErrorMap }) => {
const errors = createErrorMap()
value.issues.forEach((issue, i) => {
if (!issue.title) (errors.fields as Record<string, string>)[`issues[${i}].title`] = 'Required'
})
return errors
},
},
],
})
</script>
<template>
<form.ArrayField name="issues" v-slot="{ field: array }">
<div v-for="(_, i) in array.value" :key="i">
<form.Field :name="`issues[${i}].title`" v-slot="{ field }">
<input :value="field.value" @input="field.handleChange(($event.target as HTMLInputElement).value)" />
<p v-for="error in field.errors" :key="error.message">{{ error.message }}</p>
</form.Field>
<button type="button" @click="array.removeValue(i)">Remove</button>
</div>
</form.ArrayField>
</template>Steps to reproduce
- Render the component and trigger validation once (for example
form.setFieldValue('issues[0].title', 'A')). Row 1 shows "Required". - Click Remove on row 0.
- The remaining row is now
issues[0]andform.getFieldMeta('issues[0].title').errorsis["Required"], but no error is rendered.
Expected behavior
After removing an item, the mounted Field for each index follows the field API the form now uses for that name, and renders its current value and errors.
How often does this bug happen?
Every time
Platform
- macOS, Node 24
- Reproduced in Vitest with happy-dom
- Vue 3.6.0-rc.8
TanStack Form adapter
vue-form
TanStack Form version
2.0.0-alpha.2, plus the alpha branch at 6e5b809 with the fix from PR #2392 applied. This issue is separate from that PR.
TypeScript version
6.0.3
Additional context
I haven't included a fix, because it touches how useField identifies its field API and I'd rather hear which direction you prefer. Happy to open a PR.
Activity
awdr74100 commentedon Sep 16, 2026
Following up with a fix: #2393.
Two things I found while working on it.
It is not Vue-specific. The React adapter resolves names the same way, so the same removal case fails there on
alpha. The PR adds a regression test topackages/react-form/tests/FormField.spec.tsxthat fails without the change.It is not only
removeValue.swapValuesandmoveValuecall_moveToas well, so a mountedFieldcan end up holding a field API that is now reachable by a different name, without anything being killed.On the direction you asked about: rather than changing how
useFieldidentifies its field API, the PR adds afieldTreeVersionatom to the form, bumped when a field API is killed or moved to a new name, and has the adapters include it when they resolve a name. Happy to take it another way if you prefer.LeCarbonator commentedon Sep 19, 2026
Is this not a problem with how you keyed data? The index key implies you're using stable data, then you're moving an element and noticing that it's stale.
awdr74100 commentedon Oct 31, 2026
Thanks for taking a look, and sorry for the slow follow-up. You're right that for object arrays, keying by a stable id is the more robust pattern. I checked that it works on
alpha(2b4fc5c): withkey={item.id},removeValue,swapValues,moveValueandinsertValuekeep values, errors and input targeting correct in both React and Vue. (In Vue, child components that readfield.namealso need #2460.)While retesting, I found that my original report understated what happens with index keys, and I missed some context that I think matters for whether index keys should be supported. I'd appreciate your view on the direction before I change anything.
What happens with index keys on current
alphaThis setup mirrors
examples/vue/arrayandexamples/react/array: a string array['A', '', 'C'], items keyed by index, fields rendered directly in the slot / render prop, and a form validator that marks empty items asRequired. After each mutation I check every row, then typeXinto row 0:Xinto row 0removeValue(0)''with no error; row 1 shows''instead ofC, soCis no longer visible['', 'C']swapValues(0, 1)A, withfield.name=items[1], so the list looks unswappeditems[1]moveValue(0, 2)A, withfield.name=items[2]items[2]insertValue(0, 'Z')A, soZis not visible andCis rendered twiceitems[1]React (normal and StrictMode) and Vue give identical results. So it isn't only a missing error message, as I first wrote. The rendered row and the field it edits can belong to different items.
Why I think index keys may still need to work
As I understand it, the core intentionally keeps nested field APIs attached to their items when the array changes. The tests in
array-methods.lib.spec.tssay so, and that's what makes stable keys work. So I don't think the core is doing anything wrong. The question is how a mounted component should map to a field API when its key is the index. I may be missing the intended design here, so please correct me:Primitive arrays have no stable key. The
examples/*/arrayexamples usestring[]. Keying by value breaks on duplicates, and since the key changes on every keystroke, the input remounts and loses focus. As far as I can tell,ArrayFielddoesn't expose a per-item key, so the index is the only option there. (Libraries that require stable keys usually generate one per item; for example, vee-validate'suseFieldArrayexposesfields[].key.)Most of the v2 docs and examples use index keys.
docs/arrays.mdrecommendsperson.idunder "Keeping item identity for objects", but these use the index:docs/migrate-from-v1.md: the v2 snippet (<form.Field key={index} name={people[${index}].name}>)docs/framework/react/reference/interfaces/FieldGroupArrayFieldComponent.md:key={index}examples/vue/array(:key="index"),examples/react/arrayandexamples/preact/array(no key),examples/angular/array(track $index)The examples only push items, so they work as shipped. The problem starts once items are removed or reordered. Right after its snippet, the migration guide points to the same
arraymethods for inserting, removing, moving and swapping.v1 handled this. The same four mutations with index keys behave correctly on
@tanstack/vue-form@1.33.5: rows show the right values and errors, and typing into row 0 writes toitems[0]. So an app that follows the migration guide and removes or reorders items keeps its index keys and starts editing the wrong items after upgrading, without any warning.Possible directions
alphaand needs rework after [v2] Vue reactivity now works #2401'suseFieldchanges. I'd also want to check that stable-keyed fields don't briefly bind to the wrong field API before their newnamearrives, which would fire extramount/unmountlisteners.ArrayFieldso primitive arrays have one, update the examples and the migration guide, and possibly warn in dev when a field's API moves while its component stays mounted.I'm happy to work on either, or on something else you'd prefer. If B is the direction, I'll close #2393 and help with the docs and examples instead. I can also update this issue's title and description so they reflect the broader behavior.