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 {
  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 unless canLoadMore is true, so only one later page loads at a time.
  • status describes the first page. Use refresh() for a first-page error and loadMore() 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 error and leaves the retry to the button.
  • 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.