In a Vue 3 list of 1,000 row components, changing the selected id makes every row update; how do you make only the affected rows re-render?
answer
- which prop changed for every row
- compare in the parent
- audit the remaining props
- v-memo or fewer instances
basics
~20 sStop passing selectedId to every row; pass a per-row boolean such as :selected="row.id === selectedId", keep the other props stable and declare the row's events, so only the two rows whose boolean flipped fail Vue's props check.
solid answer
~40 sEach row receives `selectedId` and compares it internally, so when it changes every row's props differ and all 1,000 update. I move the comparison into the parent: with `:selected="row.id === selectedId"` only the previously and newly selected rows see a changed prop. Then I audit the other props: no inline object or array literals, the row object passed by reference, and events declared with `defineEmits` so fresh inline listeners don't count. The parent still re-renders and creates 1,000 row vnodes to run that check. If a profile shows that is still too slow, `v-memo="[row.id === selectedId]"` on the `v-for` element skips vnode creation for unchanged rows, and flattening a thin row component into plain elements removes 1,000 instances. I measure before and after each step.
code
vue · 24 lines<script setup lang="ts">
import { ref } from 'vue'
import OrderRow from './OrderRow.vue'
interface Order { id: number; label: string }
const orders = ref<Order[]>([])
const selectedId = ref<number | null>(null)
function select(id: number) {
selectedId.value = id
}
</script>
<template>
<!-- Before: :selected-id="selectedId" changed for every row. -->
<OrderRow
v-for="o in orders"
:key="o.id"
:order="o"
:selected="o.id === selectedId"
@select="select(o.id)"
/>
</template>go deeper
Recall that a row updates when one of its props changes, so a prop that changes for every row updates them all.
Explain moving the comparison into the parent and why declared emits and computed props keep the other props stable.
Diagnose the storm, fix props first, measure, then decide whether v-memo or flattening components is justified.
Turn the lesson into list-component guidelines: primitive per-row props, declared events, and a measured budget for row cost.
## Why every row updates The usual starting point: ```html <OrderRow v-for="o in orders" :key="o.id" :order="o" :selected-id="selectedId" /> ``` Inside, each row computes `order.id === selectedId`. When `selectedId` changes, the parent re-renders, and for each of the 1,000 row vnodes Vue's child-update check compares props by identity. The `selectedId` prop is different for **every** row, so every row updates - even though only two rows change what they show. This is exactly the pattern the Vue performance guide uses to explain **props stability**. ## Fix 1: derive the per-row value in the parent Pass what the row actually needs, as a primitive: ```html <OrderRow v-for="o in orders" :key="o.id" :order="o" :selected="o.id === selectedId" /> ``` Now when the selection moves from row 17 to row 42, rows 17 and 42 see `selected` flip; the other 998 see `false` again and are skipped. ## Fix 2: audit the remaining props One unstable prop undoes the fix, because any changed prop forces the update: - **Inline literals**: `:options="{ dense: true, showTotals }"` or `:tags="o.tags.slice(0, 3)"` build new objects every parent render. Move them into `computed` values or pass primitives. - **The item itself**: `:order="o"` is fine - a reactive array returns the same proxy for the same item on every render. - **Listeners**: declare the row's events with `defineEmits`. The update check skips listeners for declared emits, so `@select="select(o.id)"` does not count; an undeclared listener would. - **Directives or transitions on the row component** force an update on every parent render; keep them inside the row instead. ## What still costs after the fix The parent reads `selectedId`, so it re-renders on every selection change. That means: 1. The parent's render builds 1,000 row vnodes. 2. The renderer runs the props check on each of them. 3. Only two row components actually re-render. For most lists this is fast enough. For very large lists, or rows that are plain elements rather than components, two further tools apply. ## Fix 3: v-memo or fewer components | Option | What it removes | Cost | |---|---|---| | `:selected` primitive prop | re-rendering 998 row components | nothing, just better props | | `v-memo="[o.id === selectedId]"` on the `v-for` element | vnode creation and diffing for 998 rows | a manual dependency list that must include every rendered value | | flatten a thin row component into plain elements | 1,000 component instances | less encapsulation | The Vue docs note that component instances are **much more expensive than plain DOM nodes**, and that large lists are where removing an unnecessary component abstraction pays off: a row that wraps several small child components multiplies instances by the row count. ## Verifying the result - Measure the selection interaction before and after, in a production build. - Count row updates, for example with a dev-only counter in the row's update hook, and expect two per selection. - Re-check after later changes: one inline literal added to the row's props brings all 1,000 updates back. ## Common regressions to watch for The fix is fragile in the sense that later changes can quietly undo it: - A new prop computed inline in the template, such as a formatted date object or a filtered list, brings back all 1,000 updates. - Wrapping the row component in a transition or attaching a custom directive to it forces an update on every parent render. - Removing an event from `defineEmits` during a refactor turns its inline listener back into a changing prop. - Passing the whole selection state object instead of a boolean reintroduces a prop that changes for every row. A small dev-only update counter, or the devtools, makes these regressions visible in review rather than in production.
- After the :selected fix, one row prop is :tags="o.tags.slice(0, 3)". What happens on a selection change?`slice` returns a new array on every parent render, so every row fails the identity check on `tags` and all 1,000 update again. Pass `o.tags` and slice inside the row, or precompute the short list once in the data or a computed value.
- When would you still add v-memo after fixing the props?When profiling shows the parent's own work - creating 1,000 row vnodes and checking each one - is still significant, usually with very long lists or rows rendered as plain elements. `v-memo` on the `v-for` element reuses the cached vnode for unchanged rows, at the price of a dependency list you must keep complete.
saying these in an interview costs you the question
- Vue automatically re-renders only the two rows whose selection changed.
- Passing selectedId to each row is fine because it is a primitive.
- An inline slice() prop is harmless once the selection prop is fixed.
- Wrapping each row in more small components makes the list faster.
- After the fix the parent no longer re-renders on selection.