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 feed = useConvexPaginatedQuery(api.posts.list, {}, { initialNumItems: 20 })
let observer: IntersectionObserver | undefined
function loadNext() {
if (feed.canLoadMore.value && !feed.isLoading.value) {
feed.loadMore(20)
}
}
onMounted(() => {
observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) loadNext()
},
{ rootMargin: '300px' },
)
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer?.disconnect())
</script>
<template>
<PostList :posts="feed.data.value ?? []" />
<div ref="sentinel" aria-hidden="true" />
<button v-if="feed.canLoadMore.value" :disabled="feed.isLoading.value" @click="loadNext">
{{ feed.isLoading.value ? 'Loading…' : 'Load more' }}
</button>
<button v-if="feed.status.value === 'error'" @click="feed.refresh">Retry</button>
</template>Guardrails
- Disconnect the observer with the component scope.
- Check both
canLoadMoreandisLoading. - Do not auto-retry an error in an observer loop.
- 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.