The name useVirtualList refers to different hooks in VueUse, ahooks, and Rooks. In each case, the central idea is to render a small window of rows while preserving the scroll range of the complete collection.
useVirtualListcalculates which items intersect a scrollable viewport, adds buffered items around that range, and positions the resulting DOM window inside a spacer that represents the full list.
Which useVirtualList Are You Looking For?
VueUse provides useVirtualList for Vue. Its hook returns list, scrollTo, containerProps, and wrapperProps. The worked library example below uses this API.
The ahooks package has a React hook with the same name. It accepts a numeric item height or an item-height callback, includes overscan, and returns a rendered slice with a scrollTo function.
Rooks also exports useVirtualList for React from rooks/experimental. Its documentation describes fixed-size vertical and horizontal lists and warns that the experimental hook may change in a minor release.
These hooks resemble one another, but their imports, returned values, and component bindings are not interchangeable. A VueUse example copied into a React interview project will not become valid by changing the import path.
The range calculation itself does not depend on a framework. That calculation is the useful interview problem, so the implementation later in this article separates a TypeScript range calculator from its Vue adapter. The related Virtualized List exercise is useful when the interview focuses on component behavior rather than a named hook.
How Virtual List Rendering Works
A viewport is the visible area of the scroll container. Its edges determine the visible range:
visibleEnd = ceil((scrollTop + viewportHeight) / itemHeight)
visibleCount = visibleEnd - firstVisible
If the viewport begins on a row boundary, is 240 pixels tall, and every row is 40 pixels tall, six rows fit inside it.
For a fixed row height, the scroll position identifies the first visible index:
firstVisible = floor(scrollTop / itemHeight)
Suppose scrollTop is 410 and each row is 40 pixels tall. The calculation returns index 10 because the first 400 pixels contain ten complete rows.
Overscan adds buffered items outside the visible window. With an overscan value of 2, the rendered boundaries are:
start = max(0, firstVisible - overscan)
end = min(itemCount, visibleEnd + overscan)
end is exclusive. For scrollTop 410 and a 240-pixel viewport, the range from 8 to 19 therefore contains indexes 8 through 18.
The browser still needs a scrollbar that represents the entire collection. A spacer or wrapper supplies that logical size:
totalSize = itemCount * itemHeight
The rendered rows begin at the position represented by start. Their offset inside the spacer is:
itemOffset = start * itemHeight
A collection of 10,000 fixed-height rows can therefore have a full-height spacer while only the current visible range and its buffers exist as row elements. The Virtual Scroll List exercise develops the same spacer technique without relying on a library hook.
Overscan trades DOM work for scroll tolerance. A larger buffer keeps more rows ready when the scroll position changes quickly. It also mounts more elements and performs more rendering work. The right value depends on row cost, viewport size, and scrolling behavior, so it should be tested rather than selected from a universal threshold.
Using VueUse useVirtualList
Install VueUse through the package manager used by the project:
npm install @vueuse/core
This Vue component virtualizes a filtered collection. Each record has a stable id, so filtering does not turn the current array index into a misleading component identity.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useVirtualList } from '@vueuse/core'
type Result = {
id: string
name: string
}
const query = ref('')
const results = ref<Result[]>(
Array.from({ length: 1000 }, (_, index) => ({
id: `result-${index + 1}`,
name: `Interview question ${index + 1}`,
})),
)
const filteredResults = computed(() => {
const term = query.value.trim().toLowerCase()
return term
? results.value.filter(result =>
result.name.toLowerCase().includes(term),
)
: results.value
})
const {
list,
scrollTo,
containerProps,
wrapperProps,
} = useVirtualList(filteredResults, {
itemHeight: 40,
overscan: 5,
})
function jumpToStart() {
scrollTo(0)
}
</script>
<template>
<label>
Filter questions
<input v-model="query">
</label>
<button type="button" @click="jumpToStart">
Go to first result
</button>
<div v-bind="containerProps" class="result-viewport">
<ul v-bind="wrapperProps" role="list">
<li
v-for="row in list"
:key="row.data.id"
:aria-posinset="row.index + 1"
:aria-setsize="filteredResults.length"
>
{{ row.data.name }}
</li>
</ul>
</div>
</template>
<style scoped>
.result-viewport {
height: 240px;
overflow-y: auto;
}
.result-viewport ul {
margin: 0;
padding: 0;
list-style: none;
}
.result-viewport li {
box-sizing: border-box;
height: 40px;
}
</style>
containerProps connects the constrained scrolling element to the hook. wrapperProps gives the inner element the size and offset needed to represent the full list. Each entry in the returned list contains the source item in data and its current source position in index.
filteredResults is reactive. When the filter changes its length or contents, the hook recalculates the virtual range. A hand-written virtualizer must establish the same dependency. Caching a range by scroll position alone leaves stale indexes after filtering.
VueUse uses itemWidth instead of itemHeight for a horizontal list:
const horizontal = useVirtualList(filteredResults, {
itemWidth: 160,
overscan: 5,
})
The horizontal container also needs a constrained width and horizontal overflow. Only one axis option should describe a given list.
Both size options may receive a callback based on the item index. Such a callback must already know the size. It does not measure the rendered element after layout.
Implement useVirtualList From Scratch
The canonical implementation starts with a framework-neutral fixed-height calculator. It validates its inputs, clamps the scroll position, and returns an exclusive end index.
// virtual-range.ts
export type VirtualRange = {
start: number
end: number
firstVisible: number
visibleCount: number
totalSize: number
itemOffset: number
}
function requirePositiveSize(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${name} must be a positive finite number`)
}
}
function requireCount(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${name} must be a non-negative integer`)
}
}
export function calculateVirtualRange(
itemCount: number,
itemHeight: number,
viewportHeight: number,
scrollTop: number,
overscan = 0,
): VirtualRange {
requireCount('itemCount', itemCount)
requirePositiveSize('itemHeight', itemHeight)
requireCount('overscan', overscan)
if (!Number.isFinite(viewportHeight) || viewportHeight < 0) {
throw new RangeError(
'viewportHeight must be a non-negative finite number',
)
}
const totalSize = itemCount * itemHeight
if (itemCount === 0) {
return {
start: 0,
end: 0,
firstVisible: 0,
visibleCount: 0,
totalSize: 0,
itemOffset: 0,
}
}
const normalizedScrollTop =
Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0
const maximumScrollTop = Math.max(0, totalSize - viewportHeight)
const clampedScrollTop = Math.min(
normalizedScrollTop,
maximumScrollTop,
)
const firstVisible = Math.floor(clampedScrollTop / itemHeight)
const visibleEnd =
viewportHeight === 0
? firstVisible
: Math.min(
itemCount,
Math.ceil(
(clampedScrollTop + viewportHeight) / itemHeight,
),
)
const visibleCount = visibleEnd - firstVisible
const start = Math.max(0, firstVisible - overscan)
const end = Math.min(itemCount, visibleEnd + overscan)
return {
start,
end,
firstVisible,
visibleCount,
totalSize,
itemOffset: start * itemHeight,
}
}
export function scrollOffsetForIndex(
index: number,
itemCount: number,
itemHeight: number,
viewportHeight: number,
): number {
requireCount('itemCount', itemCount)
requirePositiveSize('itemHeight', itemHeight)
if (!Number.isFinite(viewportHeight) || viewportHeight < 0) {
throw new RangeError(
'viewportHeight must be a non-negative finite number',
)
}
if (itemCount === 0) return 0
const normalizedIndex = Number.isFinite(index)
? Math.trunc(index)
: 0
const clampedIndex = Math.min(
itemCount - 1,
Math.max(0, normalizedIndex),
)
const maximumScrollTop = Math.max(
0,
itemCount * itemHeight - viewportHeight,
)
return Math.min(clampedIndex * itemHeight, maximumScrollTop)
}
The Vue adapter keeps browser behavior outside the calculator. ResizeObserver updates the viewport height when the container changes size. Scroll events update scrollTop, and changes to the source length clamp a position that may no longer exist.
// useFixedVirtualList.ts
import {
computed,
onScopeDispose,
ref,
watch,
type CSSProperties,
type Ref,
} from 'vue'
import {
calculateVirtualRange,
scrollOffsetForIndex,
} from './virtual-range'
type FixedVirtualListOptions = {
itemHeight: number
overscan?: number
}
export function useFixedVirtualList<T>(
source: () => readonly T[],
options: FixedVirtualListOptions,
) {
const containerRef: Ref<HTMLElement | null> = ref(null)
const viewportHeight = ref(0)
const scrollTop = ref(0)
const range = computed(() =>
calculateVirtualRange(
source().length,
options.itemHeight,
viewportHeight.value,
scrollTop.value,
options.overscan ?? 0,
),
)
const list = computed(() =>
source()
.slice(range.value.start, range.value.end)
.map((data, localIndex) => ({
data,
index: range.value.start + localIndex,
})),
)
const wrapperProps = computed(() => ({
style: {
height: `${range.value.totalSize}px`,
position: 'relative',
} satisfies CSSProperties,
}))
function itemStyle(index: number): CSSProperties {
return {
boxSizing: 'border-box',
height: `${options.itemHeight}px`,
position: 'absolute',
top: `${index * options.itemHeight}px`,
width: '100%',
}
}
function onScroll(event: Event): void {
scrollTop.value = (event.currentTarget as HTMLElement).scrollTop
}
function clampCurrentScroll(): void {
const element = containerRef.value
if (!element) return
const next = scrollOffsetForIndex(
source().length - 1,
source().length,
options.itemHeight,
viewportHeight.value,
)
if (element.scrollTop > next) {
element.scrollTop = next
}
scrollTop.value = element.scrollTop
}
function scrollTo(index: number): void {
const element = containerRef.value
if (!element) return
const next = scrollOffsetForIndex(
index,
source().length,
options.itemHeight,
viewportHeight.value,
)
element.scrollTop = next
scrollTop.value = next
}
const containerProps = {
ref: containerRef,
onScroll,
}
let observer: ResizeObserver | undefined
watch(
containerRef,
element => {
observer?.disconnect()
observer = undefined
if (!element) return
const updateViewport = () => {
viewportHeight.value = element.clientHeight
clampCurrentScroll()
}
updateViewport()
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(updateViewport)
observer.observe(element)
}
},
{ flush: 'post' },
)
watch(
() => source().length,
() => clampCurrentScroll(),
{ flush: 'post' },
)
onScopeDispose(() => observer?.disconnect())
return {
containerProps,
itemStyle,
list,
range,
scrollTo,
wrapperProps,
}
}
This component consumes the adapter. It contains the constrained container, stable keys, full spacer, absolute row positions, and collection-position attributes.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFixedVirtualList } from './useFixedVirtualList'
type Question = {
id: string
title: string
}
const allQuestions = ref<Question[]>([])
const query = ref('')
const filteredQuestions = computed(() => {
const term = query.value.trim().toLowerCase()
return allQuestions.value.filter(question =>
question.title.toLowerCase().includes(term),
)
})
const {
containerProps,
itemStyle,
list,
scrollTo,
wrapperProps,
} = useFixedVirtualList(
() => filteredQuestions.value,
{ itemHeight: 44, overscan: 4 },
)
</script>
<template>
<label>
Filter questions
<input v-model="query">
</label>
<button type="button" @click="scrollTo(0)">
Go to first result
</button>
<div v-bind="containerProps" class="question-viewport">
<ul v-bind="wrapperProps" role="list">
<li
v-for="row in list"
:key="row.data.id"
:style="itemStyle(row.index)"
:aria-posinset="row.index + 1"
:aria-setsize="filteredQuestions.length"
>
{{ row.data.title }}
</li>
</ul>
</div>
</template>
<style scoped>
.question-viewport {
height: 264px;
overflow-y: auto;
}
.question-viewport ul {
margin: 0;
padding: 0;
list-style: none;
}
</style>
The adapter requires ResizeObserver for automatic resize updates. In an environment without that browser API, it records the initial clientHeight but will not notice later container resizes unless the application supplies another update mechanism.
Test the Boundaries Interviewers Look For
The following TypeScript test imports the calculator readers will use. Run it with a TypeScript-capable test runner. It covers empty data, a viewport smaller than one row, a non-row-aligned viewport, a partial final row, overscan at both boundaries, invalid inputs, resize inputs, filtered data, and clamped scroll targets.
import * as assert from 'node:assert/strict'
import {
calculateVirtualRange,
scrollOffsetForIndex,
} from './virtual-range'
const empty = calculateVirtualRange(0, 30, 90, 0, 2)
assert.deepEqual(empty, {
start: 0,
end: 0,
firstVisible: 0,
visibleCount: 0,
totalSize: 0,
itemOffset: 0,
})
console.log('empty:', empty.start, empty.end, empty.totalSize)
const unaligned = calculateVirtualRange(100, 40, 240, 410, 0)
assert.equal(unaligned.start, 10)
assert.equal(unaligned.end, 17)
assert.equal(unaligned.visibleCount, 7)
assert.throws(
() => scrollOffsetForIndex(0, -1, 40, 240),
RangeError,
)
assert.throws(
() => scrollOffsetForIndex(0, 1.5, 40, 240),
RangeError,
)
assert.throws(
() => scrollOffsetForIndex(0, 10, 40, -1),
RangeError,
)
const shortViewport = calculateVirtualRange(10, 30, 10, 0)
assert.equal(shortViewport.visibleCount, 1)
console.log(
'short viewport:',
shortViewport.start,
shortViewport.end,
)
const partialLastRow = calculateVirtualRange(5, 30, 65, 60)
assert.deepEqual(
[partialLastRow.start, partialLastRow.end, partialLastRow.itemOffset],
[2, 5, 60],
)
console.log(
'partial last row:',
partialLastRow.start,
partialLastRow.end,
partialLastRow.itemOffset,
)
const top = calculateVirtualRange(100, 20, 60, 0, 2)
const bottom = calculateVirtualRange(100, 20, 60, 9999, 2)
assert.deepEqual([top.start, top.end], [0, 5])
assert.deepEqual(
[bottom.start, bottom.end, bottom.itemOffset],
[95, 100, 1900],
)
console.log('top overscan:', top.start, top.end)
console.log(
'bottom overscan:',
bottom.start,
bottom.end,
bottom.itemOffset,
)
assert.throws(
() => calculateVirtualRange(10, 0, 60, 0),
RangeError,
)
console.log('invalid itemHeight: rejected')
const beforeResize = calculateVirtualRange(20, 25, 50, 125)
const afterResize = calculateVirtualRange(20, 25, 100, 125)
assert.deepEqual(
[beforeResize.start, beforeResize.end],
[5, 7],
)
assert.deepEqual(
[afterResize.start, afterResize.end],
[5, 9],
)
console.log(
'resize:',
`${beforeResize.start}-${beforeResize.end}`,
`${afterResize.start}-${afterResize.end}`,
)
const beforeFilter = calculateVirtualRange(10, 30, 60, 240, 1)
const afterFilter = calculateVirtualRange(3, 30, 60, 240, 1)
assert.deepEqual(
[beforeFilter.start, beforeFilter.end],
[7, 10],
)
assert.deepEqual(
[afterFilter.start, afterFilter.end],
[0, 3],
)
console.log(
'filter:',
`${beforeFilter.start}-${beforeFilter.end}`,
`${afterFilter.start}-${afterFilter.end}`,
)
const lowTarget = scrollOffsetForIndex(-4, 10, 30, 60)
const highTarget = scrollOffsetForIndex(99, 10, 30, 60)
const emptyTarget = scrollOffsetForIndex(3, 0, 30, 60)
assert.deepEqual(
[lowTarget, highTarget, emptyTarget],
[0, 240, 0],
)
console.log(
'scrollTo:',
lowTarget,
highTarget,
emptyTarget,
)
empty: 0 0 0
short viewport: 0 1
partial last row: 2 5 60
top overscan: 0 5
bottom overscan: 95 100 1900
invalid itemHeight: rejected
resize: 5-7 5-9
filter: 7-10 0-3
scrollTo: 0 240 0
These tests assert indexes, offsets, and total size instead of relying only on a DOM-node count. That distinction matters because two incorrect ranges can contain the same number of rows.
The resize case calls the pure calculator with the height that a ResizeObserver callback would record. The filter case keeps the old scroll position while reducing the collection length. Clamping converts that stale position into a valid bottom range.
Variable Heights, Accessibility, and Focus
Fixed-height virtualization can calculate an item offset with multiplication. Variable-height virtualization has two distinct cases.
Predetermined variable heights are known before the rows render. Examples include sizes stored with the data or produced by a reliable size function. Build a prefix-sum array where each entry contains the combined height of all earlier rows:
prefix[0] = 0
prefix[i + 1] = prefix[i] + height[i]
prefix[itemCount] is the spacer height. A binary search over this array can find the first row whose bottom edge crosses scrollTop. The same search can find the final visible boundary. If a stored height changes, the affected prefix sums must also be updated.
VueUse accepts an itemHeight(index) or itemWidth(index) callback for known sizes. Its variable-size path determines distances by summing those supplied values. The callback is not automatic DOM measurement.
Dynamically measured heights are unknown until browser layout. Wrapped text, loaded media, and expandable content can create this case. A measurement-based virtualizer observes rendered elements, stores their actual sizes, updates later offsets, and compensates when a changed row sits before the current viewport. TanStack Virtual supports dynamic measurement through measureElement, so it is one option when a fixed or predetermined-size hook no longer fits.
Use native <ul> and <li> semantics for a list when those elements match the content. For widget collections where only part of the set exists in the DOM, aria-posinset gives each rendered item its one-based position and aria-setsize gives the complete set size. The canonical component applies both values to each rendered row.
Focus needs its own policy. A keyboard-focused row can leave the virtual range and be unmounted. The simple adapter does not preserve that focused element. A production component can keep the active row mounted, move focus to a stable container before removal, or implement managed focus with an active item model. The selected behavior must also work when filtering removes the active record.
The useDynamicList exercise is relevant when insertions and removals must preserve stable record identity.
How to Explain the Tradeoffs in an Interview
A clear explanation starts with the assumptions and follows the calculation:
- Rows have one fixed height, so division finds the first visible index and multiplication finds every offset.
- Range calculation has constant cost. Creating the returned slice and rendering it still costs time proportional to the number of visible and overscanned rows.
- Overscan reduces exposed blank space during fast scrolling but mounts additional nodes.
- Stable record IDs preserve component identity when data is filtered or reordered.
- Container resize and source-length changes trigger recalculation and scroll clamping.
- Predetermined variable sizes require accumulated offsets, with binary search when fast lookup matters.
- Content-dependent sizes require element measurement and correction after layout.
- Focus preservation is additional behavior, not a consequence of virtualization itself.
VueUse is suitable when its Vue bindings and known-size model cover the component. A fuller virtualizer such as TanStack Virtual fits measurement or control requirements beyond that model. For structured practice across this implementation and related frontend problems, UIReady Premium Lifetime provides the site's extended study material.