¡Link copiado!

Scroll infinito con eventos del navegador en React.js

img of Scroll infinito con eventos del navegador en React.js
Performance | | 11 min de lectura | 59 min de video

El scroll infinito es una técnica común para cargar contenido de forma progresiva, mejorando la experiencia del usuario al evitar paginación manual. En este artículo exploraremos un enfoque directo usando eventos nativos del navegador: scroll y resize.

Prueba el ejemplo usando la PokeAPI con paginación →

🎯 El Hook: useInfiniteScroll

Este hook ofrece una implementación pragmática que detecta cuándo el usuario se acerca al final de la página y también cuando un cambio de tamaño de ventana deja espacio disponible para mostrar más contenido.

   import { useEffect, useRef } from 'react'

interface UseInfiniteScrollOptions {
	onLoadMore: () => void
	offset?: number
}

export function useInfiniteScroll({ onLoadMore, offset = 200 }: UseInfiniteScrollOptions) {
	const onLoadMoreRef = useRef(onLoadMore)

	useEffect(() => {
		onLoadMoreRef.current = onLoadMore
	}, [onLoadMore])

	useEffect(() => {
		const handleScroll = () => {
			const scrollBottom =
				window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - offset

			if (scrollBottom) onLoadMoreRef.current()
		}

		const handleResize = () => {
			const hasScroll =
				document.documentElement.scrollHeight > document.documentElement.clientHeight + offset
			if (!hasScroll) onLoadMoreRef.current()
		}

		window.addEventListener('scroll', handleScroll)
		window.addEventListener('resize', handleResize)

		handleResize()

		return () => {
			window.removeEventListener('scroll', handleScroll)
			window.removeEventListener('resize', handleResize)
		}
	}, [offset])
}

💡 ¿Cómo funciona?

Propiedades clave

Entendamos las propiedades que utilizamos:

  • window.innerHeight: Altura visible de la ventana del navegador en píxeles (viewport). Es el espacio que el usuario puede ver sin hacer scroll.
  • window.scrollY: Distancia en píxeles que el usuario ha desplazado verticalmente desde la parte superior del documento. Comienza en 0 cuando estás arriba del todo.
  • document.documentElement.scrollHeight: Altura total del contenido del documento en píxeles, incluyendo todo lo que no es visible y requiere scroll.
  • document.documentElement.clientHeight: Similar a innerHeight, pero representa la altura del elemento HTML (excluyendo bordes del navegador y barras de herramientas).
  • offset: Margen de “anticipación” en píxeles. Con un offset de 200px, el contenido se cargará cuando el usuario esté a 200px del final, en lugar de esperar a llegar al fondo exacto.

💡 Clave del scroll infinito: El scrollHeight aumenta dinámicamente cada vez que se carga más contenido. Esto es fundamental porque “empuja” el punto de activación más hacia abajo con cada carga, permitiendo que el ciclo se repita indefinidamente. Sin este crecimiento automático, solo se cargaría contenido una vez.

Ejemplo visual:

   ┌─────────────────────────┐ ← Top del documento (scrollY = 0)
│                         │
│   Contenido visible     │ ← innerHeight / clientHeight (ej: 800px)
│                         │
└─────────────────────────┘ ← Límite inferior del viewport
│                         │
│   Contenido oculto      │
│   (requiere scroll)     │ ← scrollY aumenta al hacer scroll
│                         │
│                         │
└─────────────────────────┘ ← Fondo total (scrollHeight: 3000px)

    offset (200px de anticipación)

Detección de scroll

El hook calcula si el usuario ha llegado cerca del final de la página:

   const scrollBottom =
	window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - offset

Lógica: Suma la altura visible (innerHeight) más lo que se ha desplazado (scrollY). Si esto es mayor o igual a la altura total menos el offset, significa que el usuario está lo suficientemente cerca del final para cargar más contenido.

El offset permite ajustar la fluidez de la experiencia definiendo que tan cerca del final ejecutamos la petición para cargar más data.

El ciclo infinito: Cuando se cargan nuevos items, el scrollHeight aumenta automáticamente. Esto “empuja” el punto de activación más hacia abajo, permitiendo que el usuario continúe haciendo scroll y que la detección se vuelva a disparar. Sin este crecimiento dinámico del scrollHeight, solo se cargaría contenido una vez.

2. Detección de resize

Cuando el usuario redimensiona la ventana o rota el dispositivo, puede quedar espacio vacío que antes estaba fuera del viewport:

   const hasScroll =
	document.documentElement.scrollHeight > document.documentElement.clientHeight + offset

if (!hasScroll) onLoadMoreRef.current()

Lógica: Compara si la altura total del contenido (scrollHeight) es menor o igual que la altura visible (clientHeight) más el offset. Si no hay suficiente contenido para generar scroll, se dispara la carga automáticamente.

Caso de uso común: Un usuario abre tu sitio en un monitor grande de 4K. Si solo tienes 15 items cargados, podrían caber todos en pantalla sin scroll. Sin esta verificación, nunca se cargarían más items porque el evento scroll jamás se dispararía.

⚠️ Consideración importante: Esta detección activa una petición adicional, pero no garantiza que el nuevo contenido cubra completamente el viewport. La cantidad de elementos cargados por petición es responsabilidad del componente que usa el hook, no del hook mismo.

Recomendación: Implementa siempre un botón “Cargar más” al final de tu lista que se muestre cuando no esté cargando y haya más contenido disponible. Este botón será visible en tres escenarios:

  1. Cuando el usuario hace scroll muy rápido y alcanza el final antes de que se complete la carga automática
  2. Cuando la carga inicial o automática no es suficiente para generar scroll en el viewport actual
  3. Cuando ocurre un fallo en la petición y se necesita un mecanismo manual para reintentar la carga

Ejemplo de botón “Cargar más”:

   export function ItemList() {
	const [items, setItems] = useState([])
	const [loading, setLoading] = useState(false)
	const [hasMore, setHasMore] = useState(true)

	const loadMore = async () => {
		// Tu lógica de carga aquí
	}

	useInfiniteScroll({ onLoadMore: loadMore })

	return (
		<div>
			{items.map((item) => (
				<ItemCard key={item.id} {...item} />
			))}

			{loading && <LoadingSpinner />}

			{/* Botón de respaldo para carga manual */}
			{!loading && hasMore && (
				<button onClick={loadMore} className='load-more-btn'>
					Cargar más
				</button>
			)}

			{!hasMore && <p>No hay más contenido</p>}
		</div>
	)
}

3. Uso de ref para el callback

El hook usa useRef para mantener siempre la versión más reciente del callback onLoadMore, evitando recrear listeners en cada render:

Esta estrategia se conoce como el patrón “Latest Ref”. Para no extendernos, lo revisaremos más en detalle en otro artículo.

   const onLoadMoreRef = useRef(onLoadMore)

useEffect(() => {
	onLoadMoreRef.current = onLoadMore
}, [onLoadMore])

✅ Ventajas de este enfoque

  • Implementación directa: No requiere un elemento de referencia (sentinel) para observar, ni configuración compleja
  • Fácil integración: Se integra rápidamente en cualquier componente React, sin depender de la complejidad de la petición de datos
  • Sin dependencias adicionales: Solo usa APIs nativas del navegador
  • Detección inteligente: El evento resize previene pantallas vacías en viewports grandes
  • Ideal para scroll global: Perfecto cuando trabajas con el scroll del window

⚠️ Consideraciones importantes

Frecuencia de eventos

Los eventos scroll se disparan con mucha frecuencia. Si tu callback onLoadMore realiza operaciones costosas, considera implementar debounce o throttle:

   // Throttle manual sin dependencias externas
// ¿Cómo funciona? Usa una "closure" para recordar la última ejecución
function createThrottle(fn, delay) {
	let lastCall = 0 // Esta variable VIVE en la closure y persiste entre llamadas

	return (...args) => {
		const now = Date.now() // Tiempo actual en milisegundos
		if (now - lastCall >= delay) {
			// ¿Han pasado suficientes ms?
			lastCall = now // Actualiza la referencia para la próxima llamada
			fn(...args)
		}
	}
}

// Ejemplo de uso:
const handleScroll = createThrottle(() => {
	console.log('Scroll detectado')
}, 300)

// Primera llamada: now=1000, lastCall=0 → 1000-0 >= 300? SÍ → Se ejecuta
handleScroll() // ✅ Ejecuta

// Segunda llamada 10ms después: now=1010, lastCall=1000 → 1010-1000 >= 300? NO
handleScroll() // ❌ Se ignora

// Tercera llamada 300ms después: now=1310, lastCall=1000 → 1310-1000 >= 300? SÍ → Se ejecuta
handleScroll() // ✅ Ejecuta

¿Por qué lastCall comienza en 0? Porque es un “tiempo ficticio” que garantiza que la primera llamada siempre se ejecute. Cuando restas Date.now() - 0, obtienes un número enorme (miles de millones), que definitivamente es mayor que tu delay.

¿Cómo persiste lastCall entre llamadas? Gracias a la closure. Cuando createThrottle retorna una función, esa función “recuerda” las variables del scope donde fue creada. Cada invocación de createThrottle crea su propia closure independiente:

   const throttledA = createThrottle(callbackA, 300)
const throttledB = createThrottle(callbackB, 300)

// throttledA y throttledB tienen SU PROPIA variable lastCall
// No comparten referencias, cada una controla su propio timing

Si llamas a throttledA() múltiples veces, todas verán la MISMA variable lastCall que pertenece a ese throttle específico. Es el comportamiento deseado: cada callback throttleado controla independientemente cuándo se ejecuta.

   useInfiniteScroll({
	onLoadMore: createThrottle(fetchPokemons, 300)
})

Dependencia del window

Este hook está diseñado específicamente para el scroll global del navegador (window). No funcionará con contenedores que tengan su propio scroll interno (como elementos con overflow: auto o overflow: scroll).

¿Por qué? Porque usamos window.innerHeight, window.scrollY y eventos globales de window. Si necesitas scroll infinito en un contenedor específico, tendrías que:

  1. Adaptar el hook para recibir una referencia al contenedor
  2. Usar las propiedades del contenedor (como scrollTop, scrollHeight, clientHeight)
  3. Agregar los listeners al contenedor en lugar de window

Para estos casos, Intersection Observer es generalmente una mejor opción ya que está diseñado para observar elementos específicos.

Performance en listas largas

Para listas muy extensas (cientos o miles de elementos), el scroll infinito por sí solo puede no ser suficiente. Aunque cargues datos progresivamente, renderizar miles de elementos DOM afecta la performance.

Solución: Virtualización

La virtualización renderiza solo los elementos visibles en el viewport, creando la ilusión de una lista completa. Cuando haces scroll, los elementos que salen del viewport se reciclan para mostrar los que entran.

La virtualización es una técnica extensa que merece su propio artículo. Recuerda: La virtualización maneja el renderizado eficiente, mientras el infinite scroll carga más datos cuando llegas al final de la lista virtual.

📊 Comparación: Events vs Intersection Observer

Característicascroll + resizeIntersection Observer
Simplicidad⭐⭐⭐⭐⭐ Muy simple⭐⭐⭐ Requiere más setup
Performance⭐⭐⭐ Buena (con throttle si se requiere)⭐⭐⭐⭐⭐ Excelente
Precisión⭐⭐⭐ Aproximada⭐⭐⭐⭐⭐ Muy precisa
Contenedores con scroll❌ No soportado✅ Soportado
Scroll global (window)✅ Ideal✅ Funciona bien
Setup inicialMínimoRequiere sentinel y refs
Casos de usoProyectos rápidos, scroll globalUIs complejas, múltiples listas

Usa scroll + resize cuando:

  • Trabajas con el scroll global del window
  • Necesitas una implementación rápida y directa
  • Tienes un proyecto legacy sin mucha infraestructura
  • No puedes agregar un elemento sentinel fácilmente
  • La precisión exacta no es crítica

Usa Intersection Observer cuando:

  • Trabajas con contenedores con scroll interno (overflow: auto/scroll)
  • Tienes múltiples listas en la misma página
  • La performance y precisión son críticas
  • Necesitas detectar visibilidad de elementos específicos
  • Construyes una UI compleja con muchos componentes

🚀 Ejemplo práctico: Lista de Pokémon

Aquí un ejemplo completo consumiendo la PokeAPI con paginación:

Prueba el ejemplo interactivo →

   import { useState } from "react";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";

interface Pokemon {
  name: string;
  url: string;
}

function throttle<T extends (...args: any[]) => any>(
  func: T,
  limit: number
): (...args: Parameters<T>) => void {
  let inThrottle: boolean;
  return function (...args: Parameters<T>) {
    if (!inThrottle) {
      func(...args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

const LIMIT = 20;

export function PokemonList() {
  const [pokemons, setPokemons] = useState<Pokemon[]>([]);
  const [offset, setOffset] = useState(0);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const fetchPokemons = async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    try {
      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${LIMIT}`
      );
      const data = await response.json();

      setPokemons((prev) => [...prev, ...data.results]);
      setOffset((prev) => prev + LIMIT);
      setHasMore(data.next !== null);
    } catch (error) {
      console.error("Error fetching pokemons:", error);
    } finally {
      setLoading(false);
    }
  };

  useInfiniteScroll({
    onLoadMore: throttle(fetchPokemons, 1000),
    offset: 400,
  });

  return (
    <>

      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
        {pokemons.map((pokemon, index) => {
          const pokemonId = pokemon.url.split("/").filter(Boolean).pop();

          return (
            <div
              key={`${pokemon.name}-${index}`}
              className="bg-gray-300 dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow p-4"
            >
              <div className="aspect-square bg-gray-200 dark:bg-gray-700 rounded-md mb-4 overflow-hidden">
                <img
                  src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${pokemonId}.png`}
                  alt={pokemon.name}
                  className="w-full h-full object-contain"
                  loading="lazy"
                />
              </div>
              <h3 className="text-lg font-semibold capitalize text-gray-900 dark:text-gray-100">
                {pokemon.name}
              </h3>
            </div>
          );
        })}
      </div>

      {loading && (
        <div className="flex justify-center items-center py-8">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
          <span className="ml-4 text-lg text-gray-700 dark:text-gray-300">
            Cargando más Pokémon...
          </span>
        </div>
      )}

      {(!loading && hasMore) && (
        <div className="flex flex-col items-center justify-center py-8 gap-4">
          <button
            onClick={fetchPokemons}
            className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
          >
            Cargar más
          </button>
        </div>
      )}

      {!hasMore && (
        <div className="text-center py-8">
          <p className="text-xl text-gray-600 dark:text-gray-400">
            ¡Has visto todos los Pokémon! 🎉
          </p>
        </div>
      )}
    </>
  );
}

Detalles clave del ejemplo:

  1. Throttle sin dependencias: Función throttle personalizada que limita la ejecución de fetchPokemons a máximo 1 vez por segundo, evitando múltiples peticiones simultáneas
  2. Estado de carga: La bandera loading previene múltiples peticiones simultáneas y moestra un spinner durante la carga
  3. Manejo de errores: Se capturan errores de la petición y se muestra un botón para reintentar la carga
  4. hasMore: Verifica si hay más datos disponibles basándose en la respuesta de la API
  5. Offset incremental: Se incrementa en cada carga para obtener el siguiente lote de 10 pokémon
  6. Botón “Cargar más”: Disponible cuando no está cargando y hay más datos, permitiendo carga manual además de la automática por scroll
  7. Prevención de duplicados: El callback solo se ejecuta si no está cargando y hay más datos disponibles

🎯 Conclusión

El scroll infinito con eventos nativos es una solución pragmática y efectiva para muchos casos de uso. Si trabajas con el scroll global y buscas una implementación rápida, este enfoque es ideal.

Para escenarios más complejos con contenedores de scroll interno o múltiples listas, considera Intersection Observer. Ambos enfoques tienen su lugar, y la elección depende de tus necesidades específicas.

Si te ha gustado este artículo, ¡compártelo con tus amigos y seguidores! Tu apoyo me motiva a llegar a más personas y a seguir creando contenido increíble para ti.