Infinite scroll
Load paginated data near the viewport while preserving an accessible manual control.
Result
The first page renders through SSR. An intersection observer requests the next page near the end, and a visible button remains available for keyboard users and observer failures.
<script setup lang="ts">
import { api } from '#convex/api'
const sentinel = ref<HTMLElement | null>(null)
const {
data: posts,
status,
error,
canLoadMore,
isLoadingMore,
isExhausted,
loadMore,
refresh,
} = useConvexPaginatedQuery(api.posts.list, {}, { initialNumItems: 20 })
let observer: IntersectionObserver | undefined
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
// loadMore() does nothing unless canLoadMore is true.
if (entry?.isIntersecting && !error.value) void loadMore(20)
},
{ rootMargin: '300px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer?.disconnect())
</script>
<template>
<p v-if="status === 'pending'">Loading posts…</p>
<section v-else-if="status === 'error'">
<p>Posts could not be loaded.</p>
<button @click="refresh">Retry</button>
</section>
<PostList v-else :posts="posts ?? []" />
<div ref="sentinel" aria-hidden="true" />
<button v-if="canLoadMore || isLoadingMore" :disabled="!canLoadMore" @click="loadMore(20)">
{{ isLoadingMore ? 'Loading…' : error ? 'Retry loading more' : 'Load more' }}
</button>
<p v-else-if="isExhausted">You reached the end.</p>
</template>Guardrails
- Render the sentinel outside the status branches so the observer finds it at mount.
- Disconnect the observer with the component scope.
- Let
loadMore()guard itself: it does nothing unlesscanLoadMoreistrue, so only one later page loads at a time. statusdescribes the first page. Userefresh()for a first-page error andloadMore()to retry a failed later page.- A failed later page keeps the loaded posts. Do not auto-retry it from the observer; the example checks
errorand leaves the retry to the button. - Keep the manual control visible to assistive technology.
- Let reactive filters start a fresh pagination boundary; use
keepPreviousDataonly when the same identity should retain old pages during that transition.