Scroll infinito en producción con Claude Code y React
Implementa scroll infinito con Claude Code, React, cursor API, accesibilidad, SEO y manejo de errores reales.
El scroll infinito carga más elementos cuando la persona se acerca al final de una lista. En redes sociales, archivos de artículos, catálogos de productos y paneles de actividad parece una mejora pequeña, pero en producción no basta con hacer fetch cuando se ve la última tarjeta.
Los problemas reales suelen aparecer después: peticiones duplicadas, respuestas antiguas que sobrescriben el estado nuevo, pérdida de posición al volver desde una página de detalle, lectores de pantalla sin contexto, SEO débil y una API que repite o salta registros cuando entran datos nuevos. Si solo le pides a Claude Code “añade scroll infinito”, puede crear una demo bonita y aun así dejar fuera estas partes.
Esta guía convierte la tarea en un flujo que Claude Code puede implementar y revisar: prompt inicial, hook de React, componente de lista, ruta de Next.js con cursor, casos de uso reales, trampas concretas, enlaces oficiales y nota de verificación. Para listas enormes donde el problema principal es el número de nodos DOM, revisa virtual scroll. Si necesitas números de página explícitos, compáralo con paginación.
Decisiones Antes De Programar
Intersection Observer es una API del navegador que avisa cuando un elemento objetivo cruza el viewport o un contenedor raíz. En palabras simples, permite saber cuándo el marcador del final de la lista está cerca de la pantalla. La referencia base es MDN Intersection Observer API.
Ese marcador final se suele llamar sentinel. Cuando el sentinel entra en la zona observada, cargamos otra página. Es más ligero que escuchar cada evento de scroll y calcular posiciones manualmente. Con rootMargin se puede iniciar la carga antes de que la persona llegue al borde inferior.
La segunda decisión es la paginación. Offset pagination pide “salta 40 elementos y trae 20”. Funciona en listas estáticas, pero en feeds vivos puede duplicar o perder elementos si se insertan registros arriba. Cursor pagination pide “continúa después de este id”, por eso suele ser mejor para artículos, notificaciones, logs y resultados que cambian.
Un buen encargo para Claude Code sería:
Implementa una lista de artículos con scroll infinito en React y Next.js.
Usa Intersection Observer y una API basada en cursor.
Incluye prevención de peticiones duplicadas, limpieza con AbortController,
estado de error visible, botón manual "Cargar más", aria-live, role="feed"
y enlaces normales compatibles con SEO.
No elimines frontmatter, heroImage, enlaces internos ni rutas localizadas.
Los flujos de trabajo comunes de Claude Code recomiendan dar tareas claras, ejemplos y restricciones. Aquí es esencial porque la función cruza UI, API, accesibilidad y comportamiento de producto.
Casos De Uso Reales
El primer caso es un archivo de artículos. Un sitio con muchos tutoriales puede mantener ligera la primera carga y seguir mostrando contenido a quien quiere explorar. El riesgo es la navegación de vuelta: si la persona abre un artículo y regresa al listado, perder la posición rompe la experiencia.
El segundo caso es búsqueda en ecommerce o SaaS. Explorar productos, plantillas o integraciones se siente más fluido con scroll continuo. Pero filtros, orden y texto de búsqueda deben vivir en la URL para poder compartir el mismo resultado con otra persona.
El tercer caso es un centro de notificaciones o auditoría. En operaciones se revisa primero lo más reciente. Aquí cursor, timestamp y estado de lectura deben ser conceptos separados; no uses “último visto” como cursor técnico y estado de negocio al mismo tiempo.
El cuarto caso es chat, comentarios o actividad. Muchas veces se necesita scroll infinito inverso para cargar mensajes antiguos hacia arriba. Debes decirle a Claude Code la dirección de carga, porque conservar la posición es diferente.
El quinto caso es un panel de formación. Lecciones, ejemplos y checklists pueden fluir en una sola experiencia, pero cada sección necesita URL estable, progreso y una CTA clara como formación de Claude Code.
Hook De React
Este hook asume una API con cursor. Usa AbortController para cancelar trabajo antiguo, loadingRef para bloquear peticiones duplicadas y rootMargin para precargar antes de llegar al final visible.
import { useCallback, useEffect, useRef, useState } from "react";
export type CursorPage<T> = {
items: T[];
nextCursor: string | null;
};
type FetchPage<T> = (args: {
cursor: string | null;
signal: AbortSignal;
}) => Promise<CursorPage<T>>;
type InfiniteStatus = "idle" | "loading" | "error" | "done";
type UseInfiniteCursorOptions<T> = {
fetchPage: FetchPage<T>;
mergeItems?: (previous: T[], next: T[]) => T[];
initialCursor?: string | null;
};
export function useInfiniteCursor<T>({
fetchPage,
mergeItems,
initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [status, setStatus] = useState<InfiniteStatus>("idle");
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef(false);
const hasMore = cursor !== null || items.length === 0;
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setError(null);
try {
const page = await fetchPage({ cursor, signal: controller.signal });
setItems((previous) =>
mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
);
setCursor(page.nextCursor);
setStatus(page.nextCursor ? "idle" : "done");
} catch (unknownError) {
if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
return;
}
setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
setStatus("error");
} finally {
loadingRef.current = false;
}
}, [cursor, fetchPage, hasMore, mergeItems]);
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
observerRef.current?.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadMore();
},
{ rootMargin: "600px 0px", threshold: 0 },
);
observerRef.current.observe(node);
},
[hasMore, loadMore],
);
useEffect(() => {
void loadMore();
return () => {
abortRef.current?.abort();
observerRef.current?.disconnect();
};
}, [loadMore]);
return { items, status, error, hasMore, loadMore, sentinelRef };
}
Para sincronización con sistemas externos y limpieza, toma como base la referencia oficial de React useEffect. Al pedir revisión a Claude Code, menciona explícitamente la limpieza del observer y de la petición.
Componente De Lista
El componente mantiene una salida manual. Si Intersection Observer falla, si el navegador corporativo bloquea algo o si el usuario navega con teclado, el botón sigue usando el mismo loadMore.
import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
function mergeUniqueById(previous: Article[], next: Article[]) {
const seen = new Set(previous.map((item) => item.id));
return [...previous, ...next.filter((item) => !seen.has(item.id))];
}
async function fetchArticlePage({
cursor,
signal,
}: {
cursor: string | null;
signal: AbortSignal;
}): Promise<CursorPage<Article>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/articles?${params}`, { signal });
if (!response.ok) throw new Error(`Failed to load articles: ${response.status}`);
return response.json();
}
export function ArticleFeed() {
const fetchPage = useCallback(fetchArticlePage, []);
const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
fetchPage,
mergeItems: mergeUniqueById,
});
return (
<section aria-labelledby="article-feed-title">
<h2 id="article-feed-title">Artículos recientes</h2>
<div role="feed" aria-busy={status === "loading"}>
{items.map((article, index) => (
<article
key={article.id}
role="article"
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
>
<a href={article.href}>
<h3>{article.title}</h3>
</a>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{new Intl.DateTimeFormat("es-ES").format(new Date(article.publishedAt))}
</time>
</article>
))}
</div>
{error && <p role="alert">No se pudo cargar. Revisa la conexión e inténtalo de nuevo.</p>}
<div ref={sentinelRef} aria-hidden="true" />
<p aria-live="polite">
{status === "loading" && "Cargando más artículos."}
{status === "done" && "Ya se muestran todos los artículos."}
</p>
{hasMore && (
<button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
Cargar más
</button>
)}
</section>
);
}
Si usas role="feed", revisa el patrón feed de WAI-ARIA. Aunque no siempre sea obligatorio, ayuda a comprobar si la posición, el estado de carga y los errores son comprensibles sin mirar la animación.
API Con Cursor En Next.js
El frontend no puede corregir una API inestable. Esta ruta obtiene limit + 1 filas, devuelve solo limit y usa la fila adicional para saber si existe una página siguiente.
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number(searchParams.get("limit") ?? "20"), 1), 50);
const cursor = searchParams.get("cursor");
const rows = await prisma.article.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: [{ publishedAt: "desc" }, { id: "desc" }],
select: {
id: true,
title: true,
summary: true,
href: true,
publishedAt: true,
},
});
const items = rows.slice(0, limit);
const nextCursor = rows.length > limit ? items.at(-1)?.id ?? null : null;
return NextResponse.json({ items, nextCursor });
}
En producción confirma que el índice de base de datos acompaña el orden por publishedAt e id. La suavidad del scroll depende también de la estabilidad de la consulta. Mide esto igual que lo harías en una revisión de optimización de rendimiento.
Fallos Frecuentes
El primer fallo es que el observer se dispare varias veces. Si el sentinel queda visible mientras React renderiza, puede entrar otra petición antes de que el estado cambie. Un bloqueo inmediato con ref es más fiable.
El segundo fallo es usar offset en un feed vivo. Un artículo nuevo insertado arriba puede duplicar resultados en la segunda página. Usa cursor y deduplicación por id estable.
El tercer problema es empujar el footer y la CTA sin fin. Si nadie llega a contacto, información legal o formación de Claude Code, el patrón afecta al negocio. Después de cierto número de páginas, cambia a botón manual o deja visible el footer.
El cuarto problema es SEO. Los crawlers y las tarjetas sociales no dependen del estado de scroll. Mantén enlaces normales, categorías, sitemap y metadatos.
El quinto problema es volver atrás. Si la persona abre un detalle y vuelve al inicio de la lista, la experiencia se rompe. Prueba scroll restoration, caché y estado de filtros en URL.
Prompt De Revisión
Después de implementar, pide a Claude Code una revisión de modos de fallo.
Revisa esta implementación de scroll infinito para riesgos de producción.
Comprueba fetch duplicado, respuestas obsoletas, limpieza de IntersectionObserver,
manejo de AbortError, paginación cursor, accesibilidad, SEO, botón Atrás,
índices de base de datos y recuperación manual tras fallo.
Devuelve hallazgos por archivo y correcciones concretas.
Para el contexto de la herramienta, la referencia oficial es Anthropic Claude Code overview. Cuanto más delegas al agente, más valor tienen las restricciones y la lista de revisión.
Resumen Y CTA
El scroll infinito parece una mejora de interfaz, pero toca navegador, API, base de datos, accesibilidad, SEO y conversión. Con Claude Code, pide el flujo completo: Intersection Observer, API con cursor, recuperación manual, cleanup, restauración de posición y verificación.
Si tu equipo quiere convertirlo en una práctica repetible, empieza por formación de Claude Code. El objetivo no es generar un hook una vez, sino aprender a especificar, revisar, probar y publicar este tipo de funcionalidad sin sorpresas.
Resultado Verificado
En esta actualización revisé documentación oficial de MDN, React, WAI-ARIA y Anthropic, y reemplacé el contenido dañado por una guía práctica de producción. El código está escrito como TypeScript/TSX válido e incluye protección contra duplicados, AbortController, API con cursor, recuperación manual y aria-live. En un proyecto real terminaría con npm run build, prueba de carga de API, móvil real y verificación del botón Atrás.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.