Async Content
How to build a feature like this
Rows whose content is fetched asynchronously - an API call per row that resolves to text of a different length each time - combine two concerns: when a row fetches (only rows in the window should, since only they are mounted) and how the engine knows a row's height before and after its content arrives. Virtualization makes the data model the anchor: keep the list as index-only items (new Array(n)) and let each visible row mount a small child component that fetches its own content, keyed by id. Sizes are dynamic: the row renders a skeleton placeholder at a sensible estimated height, and ResizeObserver updates the measurement once the real content paints. Because rows unmount when they scroll away, both the fetch result cache and the “is this still mounted?” guard must live outside the row's ephemeral DOM.
1. Model the list by index; fetch per visible row
When the payloads come from per-row requests you do not have the data up front, so the main (index-only) list is the fit: items only supplies a length, the slot provides index, and each mounted row renders a child component with that index. Fetching therefore happens only for mounted rows - never for the other ~99,990 - which is exactly the cost profile virtualization is meant to deliver. This is the index-only variant of the library's data-less rows: if you already hold full objects you would pass them and render from item instead; index-only rows defer all data work to the visible window and scale to very large counts.
<script setup lang="ts">
import { computed } from 'vue';
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import AsyncRow from './AsyncRow.vue';
// The array is only a length: real content is fetched by each visible row's
// child component, keyed by index, so nothing else is materialized even for
// 100k rows. Fetching happens only for rows that actually mount.
const itemCount = 100_000;
const items = computed(() => new Array(itemCount));
</script>
<template>
<VirtualScroll
class="feed"
:items="items"
:buffer-before="4"
:buffer-after="4"
>
<template #item="{ index }">
<AsyncRow :id="index" />
</template>
</VirtualScroll>
</template>2. Fetch in the row, but guard against unmounting
The row component fetches when its id changes (watch + immediate): it nulls the post to show the skeleton again, awaits the load, and stores the result. The critical detail is lifecycle safety - a row can scroll out of the window (and be unmounted) while its request is in flight. Keep an alive flag set false in onUnmounted and only assign the resolved post when it is still true, so a recycled row never receives a stale write. Rendering the skeleton (v-if="post" / v-else) gives the row something stable to measure from frame one; when the content lands it swaps in and the measured height updates.
<script setup lang="ts">
import { onUnmounted, shallowRef, watch } from 'vue';
import { loadPost, type Post } from './post-feed';
const props = defineProps<{ id: number }>();
const post = shallowRef<Post | null>(null);
let alive = true; // discard the result if the row scrolled away mid-fetch
watch(
() => props.id,
async () => {
post.value = null; // back to the skeleton for the new id
const result = await loadPost(props.id);
if (alive) post.value = result; // never write to an unmounted component
},
{ immediate: true },
);
onUnmounted(() => {
alive = false;
});
</script>
<template>
<div class="row">
<div v-if="post" class="content">
<strong>{{ post.author }}</strong>
<p>{{ post.excerpt }}</p>
</div>
<div
v-else
class="skeleton"
role="status"
aria-label="Loading"
>
<!-- placeholder sized like the content it will become, so the row's
measured height barely changes when the real content lands -->
</div>
</div>
</template>3. Cache and dedupe outside the rows
Virtualization mounts a row when it enters the window and unmounts it when it leaves - so a naive component-level cache is wiped every time the row scrolls away, and coming back would refetch. Keep the store at module scope, shared by every mount: a resolved Map makes a revisit resolve from memory (instant), and a second map of in-flight promises dedupes concurrent mounts of the same id (two overscanned rows requesting once). Because rows recycle, all authoritative state lives in this model layer, never in recycled row DOM. If you later need to invalidate (for example a “clear cache” control), clear the maps and bump a version prop the rows watch, so only the currently mounted rows refetch.
import type { Post } from './post-feed';
// Module-scope store, OUTSIDE any row component. Rows are recycled (mounted and
// unmounted as they scroll), so per-row state would be lost on scroll-away; the
// cache is shared and survives every unmount, making a revisit instant.
const posts = new Map<number, Post>();
const inFlight = new Map<number, Promise<Post>>();
// Your real HTTP/stream loader goes here; this stands in for it.
function fetchPost(id: number): Promise<Post> {
return new Promise((resolve) => setTimeout(() => resolve({ id } as Post), 250));
}
export function loadPost(id: number): Promise<Post> {
const cached = posts.get(id);
if (cached) return Promise.resolve(cached);
// An in-flight map dedupes concurrent mounts of the same id: if two rows for
// one id mount at once (buffer overscan), only one network request runs.
let pending = inFlight.get(id);
if (!pending) {
pending = fetchPost(id).then((p) => {
posts.set(id, p);
return p;
});
inFlight.set(id, pending);
}
return pending;
}4. Let heights be measured - skeleton first, then the real row
Each row's final height is unknown until its content renders, so leave item-size unset (or pass 0/null) to select dynamic mode: the engine measures every mounted row with ResizeObserver and updates the offset tree when content arrives. Make the placeholder approximate the content it precedes (a min-height close to the expected row, matching avatar/title/excerpt blocks) so the first frame and the initial scroll estimate are sane and the measured correction is small; you can also provide a default-item-size estimate for the engine until the first measurements land. Because the fetch is client-only work, render the skeleton during SSR and fetch on the client after mount - never run (or schedule) the simulated request server-side or server-rendered output becomes nondeterministic. Buffers above the default help because a still-loading row keeps a small skeleton height; buffer-before/buffer-after of a few rows give the engine time to measure before a row scrolls fully into view.
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- DOM Items #—
- Total Size (px)0h
- Viewport Size (px)0h
- Scroll Offset (px)0y