Large Lists
Why ten thousand rows are slow, and how pagination, virtual scrolling and shallowRef each remove a different part of the cost.
const rows = shallowRef<Row[]>([])Ten thousand rows are slow for three reasons, and they stack. Every row is a DOM subtree the browser must lay out and paint. Every row rendered as a component is a component instance, which costs far more than a plain element. And every object in a ref array gets wrapped in a reactive proxy the first time it is touched.
Render fewer rows
The only fix that scales is not rendering what nobody can see. Pagination is the simple version: slice the array in a computed and render fifty rows. Virtual scrolling keeps the scrollbar honest by rendering only the visible window and reusing those elements as you scroll; the Vue documentation points at vue-virtual-scroller for this. Both keep the DOM small no matter how long the data is.
Make the data cheaper
shallowRef stores its value as-is, so no proxies are created for the rows; only replacing .value is tracked. It suits a dataset you load once and then treat as immutable. Vue 3.5 reduced reactivity memory by 56% and made operations on large, deeply reactive arrays up to ten times faster, but the cheapest reactivity is still none.
What trips people up
With a shallowRef, rows.value.push(row) updates nothing. Replace the array with rows.value = [...rows.value, row], or call triggerRef(rows) after mutating in place. And take :key from the data: using the array index means an insert at the top makes Vue patch every row instead of moving one.
Example
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
interface Row {
id: number
label: string
}
const pageSize = 50
const rows = shallowRef<Row[]>([])
const page = ref(0)
const pageCount = computed(() =>
Math.max(1, Math.ceil(rows.value.length / pageSize))
)
const visible = computed(() =>
rows.value.slice(page.value * pageSize, (page.value + 1) * pageSize)
)
function load(): void {
rows.value = Array.from({ length: 10000 }, (_, index) => ({
id: index,
label: `Row ${index + 1}`
}))
page.value = 0
}
</script>
<template>
<section>
<button type="button" @click="load">Load 10,000 rows</button>
<ul>
<li v-for="row in visible" :key="row.id">{{ row.label }}</li>
</ul>
<p>Page {{ page + 1 }} of {{ pageCount }}</p>
<button type="button" :disabled="page === 0" @click="page--">Previous</button>
<button type="button" :disabled="page + 1 >= pageCount" @click="page++">Next</button>
</section>
</template>
<style scoped>
ul {
padding-left: 1rem;
}
</style>When to use it
- An admin table that loads every record and freezes the tab while it renders
- A log viewer that must scroll smoothly through tens of thousands of lines
- A large JSON payload held in state that is never edited, only displayed
- Choosing between pagination and virtual scrolling for a search results page
More examples
Virtual scrolling with vue-virtual-scroller
RecycleScroller renders only the rows in view and recycles their DOM nodes as the scroll position moves, so the page holds a few dozen elements for a list of any length. It needs item-size, the pixel height of one row, and key-field, the property that identifies an item (id by default). You must give the scroller element a height in CSS; without one it has nothing to compute a window from. Install it with npm install vue-virtual-scroller@next for Vue 3.
<script setup lang="ts">
import { shallowRef } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
interface Row {
id: number
label: string
}
const rows = shallowRef<Row[]>(
Array.from({ length: 10000 }, (_, index) => ({
id: index,
label: `Row ${index + 1}`
}))
)
</script>
<template>
<recycle-scroller
:items="rows"
:item-size="32"
key-field="id"
v-slot="{ item }"
>
<div>{{ item.label }}</div>
</recycle-scroller>
</template>
<style scoped>
.vue-recycle-scroller {
height: 20rem;
}
</style>What a shallowRef does and does not track
Only .value access is reactive on a shallowRef, so the first push is invisible to anything watching. Replacing the array is the normal fix and keeps the data immutable, which is what makes the shallow version safe. triggerRef is the escape hatch for a bulk mutation you do not want to copy: mutate, then trigger once. flush: 'sync' is used here only so the logs line up with the statements.
// src/data/rows.ts
import { shallowRef, triggerRef, watchEffect } from 'vue'
interface Row {
id: number
label: string
}
export const rows = shallowRef<Row[]>([{ id: 1, label: 'One' }])
watchEffect(() => console.log('rows:', rows.value.length), { flush: 'sync' })
// Output: rows: 1
rows.value.push({ id: 2, label: 'Two' })
// Output: nothing, the array itself was not replaced
rows.value = [...rows.value, { id: 3, label: 'Three' }]
// Output: rows: 3
rows.value.push({ id: 4, label: 'Four' })
triggerRef(rows)
// Output: rows: 4Plain elements instead of a component per row
A component instance carries props, slots, an effect scope and lifecycle bookkeeping, all of which a table row does not need. Inlining the row markup in the list removes that per-row cost, and creating the Intl.NumberFormat once outside the template avoids building a formatter for every cell. This trade is worth making only in long lists, where it buys real time; everywhere else the component is easier to read.
<script setup lang="ts">
import { shallowRef } from 'vue'
interface Row {
id: number
label: string
amount: number
}
const rows = shallowRef<Row[]>([
{ id: 1, label: 'Hosting', amount: 42 },
{ id: 2, label: 'Domains', amount: 18 }
])
const money = new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
})
</script>
<template>
<table>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.label }}</td>
<td>{{ money.format(row.amount) }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
td {
padding: 0.25rem 0.5rem;
}
</style>
Discussion