Speed: when a component renders again
In short: In Vue the component that reads a changed value renders again, and a child does when its props are new; you measure first, then computed keeps the same result.
Problem
Every letter renders the whole list
At the top there is a search field and a "Search" button. The list is filtered only after "Search". ProductList writes to the console every time it renders. Type "milk" in the field and watch the console.
Console
Four letters, four new "rendering ProductList" lines. The list is the same: you have not pressed "Search" yet. Here there are eight products and you do not feel it. In a real catalogue there are 2000, each with an image and a price, and the field starts to lag behind your fingers.
The idea
When a component renders again
A component renders again when the data it shows change. But what "its data" means, the two libraries decide differently. Both give the right result; the difference is how much work they do.
Vue tracks which reactive values each component reads while it renders. When draft changes, Vue renders again only the components that read draft: here App. A child renders again if its props are different from the previous ones (it compares them one by one).
The counter in ProductList.vue is in onBeforeMount and onBeforeUpdate: Vue calls them before the first and before every next render of the component.
Here the template passes :products="visible()": a function that filters. It runs on every render of App and returns a new array. For Vue that is a new prop, so it renders ProductList too.
Measure first, fix after. A counter in the console, like here, is the simplest measure. Vue DevTools have a Timeline with the renders of the components and their times. You fix only a render that is both extra and slow.
See it
See who lights up
<script setup>const draft = ref('');const query = ref('');const visible = computed(() => filterByName(PRODUCTS, query.value));</script>
<template> <input v-model="draft" /> <button @click="query = draft">Search</button> <ProductList :products="visible" @add="add" /></template>
<!-- ProductList.vue --><script setup>defineProps(['products']);onBeforeUpdate(() => console.log('rendering ProductList'));</script>Step by step, in words
- App keeps the text of the field (draft) and the search (query). ProductList draws the cards. The console counts how many times the list renders.
- Before the fix the template called a function, visible(). Every render of App made a new array, so Vue saw a new prop and rendered ProductList again.
- computed keeps the same array while query is the same. Vue tracks what each component reads and compares the props of the child.
- Now a key renders only App and the field. The props of ProductList are the same, so it is skipped, and the counter stays.
- "Search" changes query. The array is new, so ProductList renders. This render is needed: the list shows two products now.
- Measure first: a counter in the console or the Profiler in DevTools. Fix only the renders that are both extra and slow.
computed keeps the array, while a function in the template gives a new one on every render.Try it
Only "Search" renders the list
Make typing in the field and "Add" not render ProductList again. Only "Search" should render it, because only then does the list change. The check types "milk", adds two products, presses "Search" and counts the "rendering ProductList" lines in the console.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import ProductList from './ProductList.vue';
import { PRODUCTS } from './products.js';
const draft = ref('');
const query = ref('');
const count = ref(0);
const visible = computed(() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.value.toLowerCase())));
function add() {
count.value++;
}
function search() {
query.value = draft.value;
}
</script>
<template>
<header>
<form role="search" @submit.prevent="search">
<input type="search" aria-label="Search" v-model="draft" />
<button>Search</button>
</form>
<p>In the cart: {{ count }}</p>
</header>
<main>
<ProductList :products="visible" @add="add" />
</main>
</template>
ProductList.vue<script setup>
import { onBeforeMount, onBeforeUpdate } from 'vue';
import { money } from './products.js';
defineProps(['products']);
const emit = defineEmits(['add']);
let renders = 0;
const log = () => console.log(`rendering ProductList: ${++renders}`);
onBeforeMount(log);
onBeforeUpdate(log);
</script>
<template>
<ul>
<li v-for="p in products" :key="p.id">
<span>{{ p.name }}, {{ money(p.price) }}</span>
<button type="button" :aria-label="`Add ${p.name}`" @click="emit('add', p)">
Add
</button>
</li>
</ul>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Wholegrain bread', price: 1.9 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 6, name: 'Skimmed milk', price: 0.9 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 8, name: 'Yellow cheese', price: 9.5 },
{ id: 9, name: 'Butter', price: 3.4 },
{ id: 10, name: 'Eggs (10)', price: 2.8 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
The function visible() becomes computed: const visible = computed(() => PRODUCTS.filter(…));, and the template passes :products="visible", without brackets. computed recalculates only when query changes and until then returns the same array. add is a plain function: it is the same on every render, and Vue also keeps the arrows in @add="…".
The check also catches :products="[...visible]": the copy is a new array on every render.
Try it
Find the bug
visible is computed, and the list still renders on every letter. Which line is to blame?Pick the line with the bug.
[...visible] makes a new array on every render of App, although visible is the same. Pass it directly: :products="visible".Try it
Fill in
How does the expensive list stay the same between renders?
const visible = (
() => PRODUCTS.filter((p) => p.name.includes(query.value)),
);computed: runs the function when something it reads (query.value) changes. Otherwise it returns the same array.The idea
A long list: render less
The fastest element is the one that is not drawn. A person sees 10–20 products on the screen, not 2000. So a long list draws a part:
- "Show more": the first 20, then 20 more on a click. Only how many are shown is remembered.
- Pages: "1, 2, 3 …", often with the number in the address (
/?page=2), so it can be shared. - A virtual list (windowing): draws only the rows that are visible and swaps them while scrolling. It is hard to write, so you use a library: TanStack Virtual or vue-virtual-scroller.
The keys from the lesson on lists help here too: with key={p.id} (:key="p.id") the 20 added products are new elements, and the old ones stay without being made again.
The idea
Lazy loading
Not every part of the page is needed at once. The product reviews, the map of the shops, the profile editor: they can load only when the person opens them. Then the first load downloads less code.
import { defineAsyncComponent } from 'vue';
const Reviews = defineAsyncComponent(() => import('./Reviews.vue'));import('./Reviews.vue') is a function call that loads the file and returns a Promise. defineAsyncComponent turns it into a component that is used like any other: <Reviews v-if="open" />.
Console
Open the console: the line from reviews.js comes only after the click. To be honest, in the sandbox all files are already in the page, so here import() only runs the file later. In a real project Vite puts each such file apart and the browser downloads it from the server on the first click. The same goes for pages: in Vue Router component: () => import('./CartPage.vue') loads the page on the first move to it.
More precisely, if you are curious
v-memo. For a very long v-for Vue has v-memo="[p.id === selected]": the row renders again only if the values in the array change. The docs call it a micro-optimisation for rare cases. A wrong array means a missed update.
State down. The field with its draft can be a separate component SearchBox that emits search up only on "Search". Then the letters change only SearchBox.
computed with a stable result. From Vue 3.4 on, a computed that returns the same primitive value (say true) does not trigger what depends on it. For an object you can return the previous one (computed((old) => …)) if the content is the same.
Development mode is slower. Warnings and checks cost time. Measure the speed of the built version (vite build and vite preview), not only in development.
Exercise
Show more
The catalogue has 10 products and draws them all. Show the first 4, and below them "Showing 4 of 10" and a "Show more" button that adds 4 more. When all are shown, the button goes away. The check counts the products after every click and looks at the text and the button.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import { PRODUCTS, money } from './products.js';
const PAGE = 4;
const limit = ref(PAGE);
const shown = computed(() => PRODUCTS.slice(0, limit.value));
</script>
<template>
<main>
<ul>
<li v-for="p in shown" :key="p.id">
<span>{{ p.name }}, {{ money(p.price) }}</span>
</li>
</ul>
<p>{{ `Showing ${shown.length} of ${PRODUCTS.length}` }}</p>
<button v-if="limit < PRODUCTS.length" type="button" @click="limit += PAGE">Show more</button>
</main>
</template>
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Wholegrain bread', price: 1.9 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 6, name: 'Skimmed milk', price: 0.9 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 8, name: 'Yellow cheese', price: 9.5 },
{ id: 9, name: 'Butter', price: 3.4 },
{ id: 10, name: 'Eggs (10)', price: 2.8 },
{ id: 11, name: 'Apples (1 kg)', price: 2.2 },
{ id: 12, name: 'Bananas (1 kg)', price: 2.6 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
You keep only how many are shown: const limit = ref(4);. The shown ones are calculated: computed(() => PRODUCTS.slice(0, limit.value)). The button has v-if="limit < PRODUCTS.length", and the click is limit += 4. A plain variable let limit = 4 is not reactive and the template does not render again: the check catches it.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Derived state · Lifting state up · Effects · Routing
- Tasks on this topic: A whole app