Skip to content
← Todas las preguntas
Intermedio

¿Cómo implementarías el scroll infinito con Vue?

RendimientoComposables

El scroll infinito carga más contenido a medida que el usuario se desplaza cerca del fondo de la página. El enfoque estándar usa IntersectionObserver sobre un elemento centinela al final de la lista. Cuando el centinela entra en el viewport, se obtiene la siguiente página.

Implementación básica

vue
<script setup lang="ts">
interface Post {
  id: number
  title: string
}

const posts = ref<Post[]>([])
const page = ref(1)
const isLoading = ref(false)
const hasMore = ref(true)
const sentinel = ref<HTMLElement | null>(null)

async function loadMore() {
  if (isLoading.value || !hasMore.value) return

  isLoading.value = true
  const newPosts = await $fetch<Post[]>('/api/posts', {
    params: { page: page.value, limit: 20 }
  })

  posts.value.push(...newPosts)
  hasMore.value = newPosts.length === 20
  page.value++
  isLoading.value = false
}

onMounted(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) loadMore()
    },
    { rootMargin: '200px' }
  )

  watchEffect(() => {
    if (sentinel.value) observer.observe(sentinel.value)
  })

  onUnmounted(() => observer.disconnect())
})

loadMore()
</script>

<template>
  <div>
    <div v-for="post in posts" :key="post.id" class="post">
      <h3>{{ post.title }}</h3>
    </div>

    <div ref="sentinel" />

    <p v-if="isLoading">Cargando...</p>
    <p v-if="!hasMore">No hay más publicaciones.</p>
  </div>
</template>

El rootMargin: '200px' activa la carga 200px antes de que el centinela sea visible, de modo que el contenido aparece antes de que el usuario llegue al fondo.

Versión con composable

Extrae la lógica para que cualquier lista pueda usarla:

ts
// composables/useInfiniteScroll.ts
export function useInfiniteScroll<T>(
  fetchFn: (page: number) => Promise<T[]>,
  options: { pageSize?: number; rootMargin?: string } = {}
) {
  const { pageSize = 20, rootMargin = '200px' } = options

  const items = ref<T[]>([]) as Ref<T[]>
  const page = ref(1)
  const isLoading = ref(false)
  const hasMore = ref(true)
  const sentinel = ref<HTMLElement | null>(null)

  async function loadMore() {
    if (isLoading.value || !hasMore.value) return
    isLoading.value = true

    const newItems = await fetchFn(page.value)
    items.value.push(...newItems)
    hasMore.value = newItems.length === pageSize
    page.value++
    isLoading.value = false
  }

  function reset() {
    items.value = []
    page.value = 1
    hasMore.value = true
    loadMore()
  }

  onMounted(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) loadMore()
      },
      { rootMargin }
    )

    watchEffect(() => {
      if (sentinel.value) observer.observe(sentinel.value)
    })

    onUnmounted(() => observer.disconnect())
  })

  loadMore()

  return { items, isLoading, hasMore, sentinel, reset }
}
vue
<script setup>
const { items: posts, isLoading, hasMore, sentinel } = useInfiniteScroll(
  (page) => $fetch('/api/posts', { params: { page, limit: 20 } })
)
</script>

<template>
  <div v-for="post in posts" :key="post.id">{{ post.title }}</div>
  <div ref="sentinel" />
  <p v-if="isLoading">Cargando...</p>
  <p v-if="!hasMore">Fin de la lista.</p>
</template>

Con VueUse

VueUse proporciona useIntersectionObserver que simplifica la configuración del observer:

vue
<script setup>
import { useIntersectionObserver } from '@vueuse/core'

const sentinel = ref<HTMLElement | null>(null)

useIntersectionObserver(
  sentinel,
  ([entry]) => {
    if (entry.isIntersecting) loadMore()
  },
  { rootMargin: '200px' }
)
</script>

Paginación basada en cursor

Para APIs que usan cursores en lugar de números de página:

ts
const cursor = ref<string | null>(null)

async function loadMore() {
  if (isLoading.value || !hasMore.value) return
  isLoading.value = true

  const response = await $fetch('/api/posts', {
    params: { cursor: cursor.value, limit: 20 }
  })

  posts.value.push(...response.data)
  cursor.value = response.nextCursor
  hasMore.value = !!response.nextCursor
  isLoading.value = false
}

Combinado con virtualización de listas

Para listas muy largas (miles de elementos), el scroll infinito por sí solo causa problemas de rendimiento porque todos los elementos cargados permanecen en el DOM. Combínalo con una lista virtualizada:

vue
<script setup>
import { useVirtualList } from '@vueuse/core'

const { items, isLoading, hasMore, sentinel } = useInfiniteScroll(fetchPosts)

const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 80
})
</script>

<template>
  <div v-bind="containerProps" style="height: 600px; overflow-y: auto">
    <div v-bind="wrapperProps">
      <div v-for="{ data, index } in list" :key="data.id" style="height: 80px">
        {{ data.title }}
      </div>
    </div>
    <div ref="sentinel" />
  </div>
</template>

Así cargas datos de forma incremental Y solo renderizas los elementos visibles.

Scroll infinito vs paginación

Scroll infinitoPaginación
UXNavegación continuaControl explícito de página
Botón atrásPierde la posición de scrollFácil volver a una página
SEOMás difícil (contenido no en el HTML inicial)Cada página tiene una URL
RendimientoRiesgo de DOM grande con el tiempoTamaño de DOM constante
Ideal paraFeeds sociales, galerías de imágenesResultados de búsqueda, tablas de datos

Publicado bajo la licencia MIT.