Skip to main content

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.

vue
<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 canLoadMore and isLoading.
  • 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 keepPreviousData only when the same identity should retain old pages during that transition.