skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. which prop changed for every row
  2. compare in the parent
  3. audit the remaining props
  4. v-memo or fewer instances

basics

~20 s

Stop 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 s

Each 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
vue
<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

for a junior

Recall that a row updates when one of its props changes, so a prop that changes for every row updates them all.

for a middle

Explain moving the comparison into the parent and why declared emits and computed props keep the other props stable.

for a senior

Diagnose the storm, fix props first, measure, then decide whether v-memo or flattening components is justified.

for a principal

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.