Avoiding Needless Work

What actually triggers a re-render in Vue, and how stable props, computeds, keys and v-memo stop work that no one needs.

Syntax<div v-memo="[valueA, valueB]">...</div>

A Vue component does not re-render because its parent rendered. It re-renders because a reactive value it read while rendering changed. Reading count.value during render registers that component's render effect as a subscriber of that one property. Change something the component never read and it does no work at all.

Where the work goes

A re-render runs the render function and compares the result with the previous one. The comparison is usually cheap; the render function is where your code runs. So the wins come from running render functions less often, and from making each one do less.

Keep props stable. :active-id="activeId" changes on every row when the selection moves, so every row re-renders. :active="task.id === activeId" changes for two rows only. (:active is shorthand for v-bind:active and @select for v-on:select; same directives, shorter spelling.)

Put derived values in a computed rather than a method called from the template. A method re-runs on every render. A computed is cached until one of its dependencies changes, and since Vue 3.4 it only notifies watchers when the result actually differs.

Skipping subtrees

v-once renders a subtree once and never updates it. v-memo="[a, b]" skips updates for a subtree while every value in the array is unchanged, and v-memo="[]" is the same thing as v-once. Both are micro-optimisations for large lists; reach for them after a profile, not before.

Example

Example · vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import TaskRow from './TaskRow.vue'

interface Task {
  id: number
  title: string
}

const tasks = ref<Task[]>([
  { id: 1, title: 'Write the migration' },
  { id: 2, title: 'Review the diff' },
  { id: 3, title: 'Deploy' }
])
const query = ref('')
const selectedId = ref<number | null>(null)

const visible = computed(() =>
  tasks.value.filter((task) => task.title.includes(query.value))
)
</script>

<template>
  <section>
    <input v-model="query" type="search" aria-label="Filter tasks" />
    <ul>
      <task-row
        v-for="task in visible"
        :key="task.id"
        :title="task.title"
        :active="task.id === selectedId"
        @select="selectedId = task.id"
      />
    </ul>
  </section>
</template>

<style scoped>
ul {
  margin: 0;
  padding: 0;
}
</style>

When to use it

  • A list where selecting one row visibly re-renders every row
  • A template calling formatPrice(item) in three places and recomputing it on every render
  • A chart component that redraws whenever an unrelated part of the page updates
  • Deciding whether a slow interaction needs v-memo or just a stable prop

More examples

The row component that receives the stable prop

TaskRow.vue reads only title and active, so moving the selection re-renders the row losing it and the row gaining it, and nothing else. Had the parent passed activeId down and compared inside the row, every row would read activeId and every row would re-render. This is the whole stable-props idea in one file: compare in the parent, pass the answer.

Example · vue
<script setup lang="ts">
defineProps<{
  title: string
  active: boolean
}>()

const emit = defineEmits<{
  select: []
}>()
</script>

<template>
  <li>
    <button type="button" :aria-current="active" @click="emit('select')">
      {{ title }}
    </button>
  </li>
</template>

<style scoped>
li {
  list-style: none;
}
</style>

v-memo on a long list

Clicking a row changes selectedId, which the list read during render, so the list re-renders all 2000 rows. v-memo skips the virtual DOM work for every row whose label and selected state are unchanged, which is all but two of them. The dependency array lists everything the subtree reads — label as well as the selected flag — because a value left out of the array will not cause an update. v-memo has been available since Vue 3.2 and is worth trying only on lists of roughly a thousand rows or more.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'

interface Entry {
  id: number
  label: string
}

const entries = ref<Entry[]>(
  Array.from({ length: 2000 }, (_, index) => ({
    id: index,
    label: `Entry ${index + 1}`
  }))
)
const selectedId = ref<number | null>(null)
</script>

<template>
  <ul>
    <li
      v-for="entry in entries"
      :key="entry.id"
      v-memo="[entry.label, entry.id === selectedId]"
    >
      <button type="button" @click="selectedId = entry.id">
        {{ entry.label }}
      </button>
    </li>
  </ul>
</template>

<style scoped>
ul {
  max-height: 20rem;
  overflow-y: auto;
}
</style>

A computed only notifies when the value changes

Since Vue 3.4 a computed compares its new result with the old one and stops the update there if they match, so a component reading isEven does not re-render while the parity holds. Before 3.4 each change to count notified every dependent. This is why a narrow computed in the middle is often cheaper than letting components read the raw state.

Example · ts
// src/features/counter.ts
import { computed, ref, watchEffect } from 'vue'

const count = ref(0)
const isEven = computed(() => count.value % 2 === 0)

watchEffect(() => console.log('isEven:', isEven.value), { flush: 'sync' })
// Output: isEven: true

count.value = 2
// Output: nothing, the computed still evaluates to true

count.value = 3
// Output: isEven: false

Discussion

  • Be the first to comment on this lesson.