In a Vue 3 table of sortable rows, each a child component with its own input draft, why do drafts land on the wrong rows after sorting with :key="index"?
answer
- index is a position
- the instance is reused
- props change, setup does not rerun
- key by the row id
basics
~20 sWith :key="index" Vue reuses the row component at each position after sorting; only its props change, and a draft initialised once in setup stays with the position. Keying by row.id moves each instance with its row.
solid answer
~40 sAn index key is a **position**, so after a sort the same keys exist in the same places and Vue **reuses every row component instance where it stands**, only patching its props. The child's draft was created once in `setup`, for example `const draft = ref(props.row.name)`, and `setup` does not run again when props change, so each draft stays with its slot while the row data under it moves. Focus and uncontrolled input values do the same. The fix is `:key="row.id"`: Vue then matches instances by id and **moves** them, so each draft travels with its row. Resetting the draft with a watcher on the prop hides the symptom by throwing edits away; lifting drafts into a map keyed by id works too, but the key is still required.
code
vue · 11 lines<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ row: { id: number; name: string } }>()
// runs once per instance: the draft does not follow later prop changes
const draft = ref(props.row.name)
</script>
<template>
<label>{{ props.row.name }} <input v-model="draft" /></label>
</template>go deeper
Know that a key must identify the item, not its position, and that :key="index" on a sortable list is a bug.
Walk through why the instance at each index is reused and why a ref initialised from a prop in setup does not follow that prop.
Diagnose the report from symptoms, reject the watcher and composite-key patches, and harden with id keys, owned drafts and a reorder test.
Decide where row-level draft state should live across the app, local per instance or lifted by id, and make id keys a reviewed convention.
## The scenario A settings table lets the user sort rows by name or date. Each row is a `<RowEditor>` child component that copies its row's name into a local `draft` ref so the user can edit it before saving. The parent renders `<RowEditor v-for="(row, index) in sortedRows" :key="index" :row="row" />`. A user edits two rows, clicks the Name header to sort, and the unsaved drafts now sit next to **different** rows. Saving writes one row's text into another. ## Why it happens, step by step 1. Before the sort, keys are `0, 1, 2, ...`, one per position. 2. After the sort, `sortedRows` has the same length, so the new keys are again `0, 1, 2, ...`. 3. Vue matches old and new nodes by key. Key `0` still exists, so the component instance at position 0 is **reused**, and so on for every position. Nothing moves. 4. Vue patches each reused instance with its new `row` prop. The prop is reactive, so anything that reads `props.row` updates. 5. The draft does not read the prop reactively. It was initialised by `ref(props.row.name)` inside `setup`, and **`setup` runs once per instance**, not on every prop change. The draft therefore keeps the text typed at that position. The result: labels move with the data, drafts stay with positions. Any other per-instance state behaves the same way, including focus, uncontrolled input values, expanded or collapsed flags and child timers. ## The fix Key by identity from the data: ```vue-html <RowEditor v-for="row in sortedRows" :key="row.id" :row="row" /> ``` Now the old and new key sets match by id. Vue patches each instance and **moves** its DOM to the row's new position, so the draft, focus and every other local state travel with the row. Rows removed from the list are unmounted, and new ids get new instances. ## Fixes that look right but are not | Attempt | What actually happens | |---|---| | `watch(() => props.row, r => draft.value = r.name)` | Drafts are **overwritten** on every sort, so unsaved edits are lost instead of misplaced. | | `:key="row.name"` | Works until two rows share a name or the user saves a rename, which changes the key and remounts the row. | | a key built from `index` plus `row.id` | The index part still changes on sort, so every row remounts and all drafts are wiped. | | `:key="Math.random()"` | Every render remounts every row, losing drafts and focus even without a sort. | | Removing `:key` entirely | The in-place patch reuses by position: the original bug. | ## Hardening the design - **Key by a stable, unique primitive id.** If the data has none (for example rows added locally before saving), assign a client id once when the row is created and keep it. - **Decide who owns the draft.** Keeping drafts inside the row is fine once keys are correct. Alternatively lift them into the parent as a `Map` or object keyed by row id, which also survives filtering a row out of view and back. - **Test the reorder.** A component test that edits a row, re-sorts and asserts the draft is still next to the same id catches the regression cheaply. - **Treat `index` keys as a review finding** on any list that sorts, filters, inserts at the top or removes from the middle. ## Diagnosing it in the wild The telltale sign in a bug report is "my edit jumped to another row" or "the wrong row is focused after sorting". In Vue DevTools the component instances keep their identity while their `row` prop changes underneath them. Searching the template for `:key="index"` or `:key="i"` usually finds the cause in minutes.
- Would `watch(() => props.row, ...)` resetting the draft solve the sortable-rows bug in Vue 3?It removes the misplacement but destroys the edits: every sort would overwrite each draft with the new row's name. The instance is still the wrong one for that row. Keying by `row.id` keeps the correct instance, and its draft, attached to the row.
- The rows are created client-side and have no id until saved. What key do you use in Vue 3?Generate a client id once when the row is created, for example a counter or `crypto.randomUUID()`, store it on the row object and key by it. Generating it inside the template would change it every render and remount every row.
saying these in an interview costs you the question
- Index keys are fine as long as each key is unique.
- setup re-runs when the component's props change.
- Watching the prop to reset the draft is the correct fix.
- Combining index and id in the key gives the best of both.
- The bug is in the sort function, not the key.