Tips & Tricks (Atualizado: 02/06/2026)

Scroll infinito em produção com Claude Code e React

Implemente scroll infinito com Claude Code, React, cursor API, acessibilidade, SEO e tratamento de falhas.

Scroll infinito em produção com Claude Code e React

Scroll infinito é o padrão em que a página carrega mais itens quando a pessoa chega perto do fim da lista. Em redes sociais, arquivos de artigos, catálogos e painéis de atividade, ele parece simples. Em produção, porém, não basta chamar fetch quando o último card aparece.

Os problemas reais são conhecidos: requisições duplicadas, respostas antigas sobrescrevendo estado novo, posição perdida ao voltar de uma página de detalhe, leitores de tela sem contexto, SEO fraco e APIs que repetem ou pulam registros quando novos dados entram. Se você pedir ao Claude Code apenas “adicione scroll infinito”, ele pode entregar uma boa demonstração, mas não uma implementação segura.

Este guia transforma a tarefa em um fluxo claro: prompt para Claude Code, hook React, componente de lista, rota Next.js com cursor, casos de uso reais, armadilhas concretas, links oficiais e nota de verificação. Para listas enormes em que o problema é o número de nós DOM, veja também scroll virtual. Quando o usuário precisa escolher páginas, compare com paginação.

Decisões Antes Do Código

Intersection Observer é uma API do navegador que informa quando um elemento cruza o viewport ou outro contêiner raiz. Em termos simples, ela avisa quando o marcador no fim da lista está perto da tela. A referência de base é MDN Intersection Observer API.

Esse marcador costuma ser chamado de sentinel. Quando ele fica visível, a aplicação carrega a próxima página. É mais leve do que observar todos os eventos de scroll. Com rootMargin, dá para iniciar a requisição um pouco antes de a pessoa chegar ao final.

A segunda decisão é paginação. Offset pagination pede “pule 40 itens e traga 20”. Isso funciona em listas paradas, mas feeds vivos mudam enquanto a pessoa lê. Cursor pagination pede “continue depois deste id”, por isso é mais estável para artigos, notificações, logs e buscas.

Um bom pedido ao Claude Code seria:

Implemente uma lista de artigos com scroll infinito em React e Next.js.
Use Intersection Observer e uma API baseada em cursor.
Inclua prevenção de requisições duplicadas, cleanup com AbortController,
erro visível, botão manual "Carregar mais", aria-live, role="feed"
e links normais seguros para SEO.
Não remova frontmatter, heroImage, links internos nem rotas localizadas.

Os fluxos comuns do Claude Code recomendam tarefas claras, exemplos e restrições. Scroll infinito precisa disso porque atravessa UI, API, acessibilidade e fluxo de produto.

Casos De Uso

O primeiro caso é um arquivo de artigos. Um site de tutoriais pode manter a primeira página leve e ainda permitir que leitores engajados descubram mais conteúdo. O ponto frágil é voltar da página de detalhe: se a posição some, a experiência quebra.

O segundo caso é busca em ecommerce ou SaaS. Explorar produtos, templates ou integrações com rolagem contínua é natural. Mas filtros, ordenação e texto de busca precisam ficar na URL para compartilhamento e retorno.

O terceiro caso é central de notificações ou logs de auditoria. Operadores revisam primeiro o que é mais recente. Aqui, cursor, timestamp e estado de leitura devem ser conceitos separados. Não use “último visto” como cursor técnico e status de negócio ao mesmo tempo.

O quarto caso é chat, comentários ou feed de atividade. Muitas vezes é preciso scroll infinito reverso para carregar mensagens antigas acima. Diga ao Claude Code a direção de carregamento, porque preservar a posição muda completamente.

O quinto caso é painel de aprendizado. Lições, exemplos e checklists podem aparecer em sequência, mas cada seção ainda precisa de URL estável, progresso e CTA como treinamento Claude Code.

Hook React

O hook abaixo parte de uma API com cursor. Ele cancela trabalho antigo com AbortController, bloqueia fetch duplicado com loadingRef e pré-carrega com rootMargin.

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 sincronização com sistemas externos e cleanup, use a referência oficial do React useEffect. Ao pedir revisão ao Claude Code, cite explicitamente o cleanup do observer e da requisição.

Componente De Lista

O componente mantém uma saída manual. Se Intersection Observer falhar, se um navegador corporativo bloquear algo ou se a pessoa usar teclado, o botão chama o mesmo 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">Artigos recentes</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("pt-BR").format(new Date(article.publishedAt))}
            </time>
          </article>
        ))}
      </div>

      {error && <p role="alert">Falha ao carregar. Verifique a conexão e tente novamente.</p>}

      <div ref={sentinelRef} aria-hidden="true" />

      <p aria-live="polite">
        {status === "loading" && "Carregando mais artigos."}
        {status === "done" && "Todos os artigos estão visíveis."}
      </p>

      {hasMore && (
        <button type="button" onClick={() => void loadMore()} disabled={status === "loading"}>
          Carregar mais
        </button>
      )}
    </section>
  );
}

Se usar role="feed", confira o padrão WAI-ARIA feed. Ele ajuda a validar se posição, carregamento e erro são compreensíveis sem depender da animação.

API Cursor No Next.js

O frontend não corrige uma API instável. Esta rota busca limit + 1 linhas, retorna apenas limit e usa a linha extra para decidir se existe próximo cursor.

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 });
}

Em produção, confirme se o índice do banco acompanha publishedAt e id. Se a consulta ficar lenta, a UI parece quebrada mesmo com o observer correto. Trate isso como parte de otimização de performance.

Armadilhas Reais

A primeira armadilha é o observer disparar várias vezes. Se o sentinel continua visível durante um render lento, outra requisição pode começar antes do state mudar. Um bloqueio com ref é mais seguro.

A segunda é usar offset em feed que muda. Um novo artigo no topo pode fazer a segunda página repetir itens da primeira. Use cursor e deduplicação por id estável.

A terceira é tornar o rodapé inalcançável. Se contato, termos ou treinamento Claude Code ficam sempre empurrados para baixo, o padrão prejudica conversão. Após algumas páginas, pare o carregamento automático ou use botão manual.

A quarta é SEO. Buscadores e cards sociais não dependem do estado de rolagem do usuário. Mantenha links normais, categorias, sitemap e metadados.

A quinta é o botão voltar. Se a pessoa abre um detalhe e volta para o topo da lista, a experiência fica ruim. Teste scroll restoration, cache e estado de filtros na URL.

Prompt De Revisão

Depois de implementar, peça uma revisão por modos de falha.

Revise esta implementação de scroll infinito para riscos de produção.
Verifique fetch duplicado, respostas antigas, cleanup de IntersectionObserver,
tratamento de AbortError, paginação cursor, acessibilidade, SEO, botão voltar,
índices de banco e recuperação manual após falha.
Retorne achados por arquivo e correções concretas.

Para o contexto da ferramenta, use Anthropic Claude Code overview. Quanto mais você delega ao agente, mais importantes ficam as restrições e a checklist.

Resumo E CTA

Scroll infinito parece uma pequena melhoria visual, mas envolve navegador, API, banco, acessibilidade, SEO e funil. Com Claude Code, peça o fluxo completo: Intersection Observer, API cursor, fallback manual, cleanup, restauração de posição e verificação.

Se sua equipe quer repetir esse padrão com qualidade, comece pelo treinamento Claude Code. O objetivo não é gerar um hook uma vez, e sim aprender a especificar, revisar, testar e publicar esse tipo de recurso.

Resultado Verificado

Nesta atualização, verifiquei MDN, React, WAI-ARIA e documentação da Anthropic, depois substituí o conteúdo corrompido por um guia prático de produção. O código está estruturado como TypeScript/TSX válido e cobre proteção contra duplicidade, AbortController, API cursor, recuperação manual e aria-live. Em um projeto real, eu finalizaria com npm run build, teste de carga da API, teste em celular e verificação do botão voltar.

#Claude Code #scroll infinito #React #performance #UX
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.