Como implementar infinite scroll com IntersectionObserver
1. Fundamentos do Infinite Scroll e do IntersectionObserver
O infinite scroll é uma técnica de UX que carrega conteúdo continuamente conforme o usuário rola a página, eliminando a necessidade de clicar em "próxima página". É amplamente utilizado em feeds de redes sociais, galerias de imagens e listas de resultados de busca. Quando bem implementado, melhora a experiência do usuário ao fornecer fluxo contínuo de conteúdo.
Implementações ingênuas baseadas em eventos de scroll (window.onscroll) sofrem de problemas graves de performance. Cada pixel de rolagem pode disparar dezenas de eventos, exigindo debounce ou throttle para evitar travamentos. Além disso, calcular manualmente a posição do scroll em relação ao final da página é propenso a erros e inconsistências entre navegadores.
A API IntersectionObserver resolve esses problemas de forma elegante. Ela permite observar quando um elemento alvo intersecta a viewport (ou um container específico), disparando um callback apenas quando a interseção ocorre. O navegador otimiza internamente o monitoramento, sem necessidade de debounce manual. O observer é eficiente, assíncrono e não bloqueia a thread principal.
2. Estrutura básica do componente de infinite scroll
O coração da implementação é o elemento sentinela — um pequeno div posicionado no final da lista que será observado pelo IntersectionObserver. Quando esse elemento fica visível, sabemos que o usuário chegou ao fim do conteúdo atual e precisamos carregar mais dados.
Aqui está um hook personalizado useInfiniteScroll para React/Next.js:
import { useEffect, useRef, useState, useCallback } from 'react';
function useInfiniteScroll({ root = null, rootMargin = '200px', threshold = 0 }) {
const sentinelRef = useRef(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
},
{ root, rootMargin, threshold }
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, [root, rootMargin, threshold]);
return { sentinelRef, isIntersecting };
}
Parâmetros essenciais:
- root: elemento usado como viewport para verificação (null = viewport do navegador)
- rootMargin: margem extra ao redor do root (ex: '200px' carrega 200px antes do fim)
- threshold: porcentagem de visibilidade para disparar (0 = qualquer pixel visível)
3. Integração com carregamento de dados (fetch/API)
O hook acima nos dá a flag isIntersecting. Precisamos integrá-la com uma função de carregamento assíncrono que busca a próxima página de dados:
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadingRef = useRef(false);
const { sentinelRef, isIntersecting } = useInfiniteScroll({
rootMargin: '400px'
});
const fetchMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/items?page=${page}&limit=20`);
const data = await response.json();
setItems(prev => [...prev, ...data.items]);
setHasMore(data.hasMore);
setPage(prev => prev + 1);
} catch (err) {
setError('Falha ao carregar mais itens.');
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [page, hasMore]);
useEffect(() => {
if (isIntersecting && hasMore && !loading) {
fetchMore();
}
}, [isIntersecting, hasMore, loading, fetchMore]);
// ... render
}
O controle de concorrência com loadingRef.current evita requisições duplicadas quando o observer dispara múltiplas vezes rapidamente.
4. Gerenciamento de estados visuais e UX
A experiência do usuário deve comunicar claramente o estado atual:
return (
<div>
{items.map(item => <ItemCard key={item.id} item={item} />)}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && (
<div className="loading-indicator">
<Spinner /> Carregando mais itens...
</div>
)}
{!hasMore && !loading && (
<div className="end-message">
Você chegou ao fim da lista.
</div>
)}
{error && (
<div className="error-message">
{error}
<button onClick={fetchMore}>Tentar novamente</button>
</div>
)}
</div>
);
Boas práticas:
- Spinner ou skeleton loader no final da lista durante carregamento
- Mensagem clara de "fim da lista" quando hasMore = false
- Botão de retry em caso de erro, mantendo os itens já carregados
5. Otimizações de performance e memória
Uso de rootMargin: defina rootMargin: '400px' para iniciar o carregamento antes do usuário atingir o fim da lista. Isso elimina a percepção de espera.
Limpeza do observer: sempre chame observer.disconnect() no cleanup do useEffect para evitar memory leaks quando o componente desmonta.
Virtualização: para listas com milhares de itens, considere bibliotecas como react-window ou react-virtuoso. Elas renderizam apenas os itens visíveis, mantendo o DOM enxuto.
6. Adaptação para diferentes cenários e layouts
Infinite scroll horizontal: basta definir rootMargin no eixo X e observar a largura do container.
Container com overflow scroll: configure o root como o container com scroll:
const containerRef = useRef(null);
const { sentinelRef, isIntersecting } = useInfiniteScroll({
root: containerRef.current,
rootMargin: '100px'
});
Feed com paginação baseada em cursor: adapte o fetch para usar cursores em vez de números de página, evitando problemas com inserção/remoção de itens.
7. Testes e depuração da implementação
Testes unitários com Jest e Testing Library:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('carrega mais itens ao scrollar', async () => {
// Mock do IntersectionObserver
const mockObserve = jest.fn();
window.IntersectionObserver = jest.fn(() => ({
observe: mockObserve,
disconnect: jest.fn()
}));
render(<InfiniteList />);
expect(mockObserve).toHaveBeenCalled();
});
Depuração no navegador:
- Chrome DevTools → Elements → selecione o sentinel → veja se está sendo observado
- Console: console.log(observer.takeRecords()) para ver interseções ativas
- Network tab: confira se as requisições são disparadas no momento correto
8. Considerações finais e boas práticas
Acessibilidade: infinite scroll pode ser problemático para navegação por teclado. Adicione role="feed" na lista e aria-live="polite" para anunciar novos itens. Considere oferecer um botão "Carregar mais" como fallback.
Quando evitar infinite scroll:
- Dados finitos e bem definidos (paginação clássica é mais adequada)
- Busca textual onde o usuário precisa navegar por resultados específicos
- Conteúdo que exige contexto completo antes de prosseguir
Alternativas: o botão "Load More" oferece controle explícito ao usuário e é mais amigável para acessibilidade e SEO. Escolha baseado no padrão de uso esperado.
Implementar infinite scroll com IntersectionObserver é eficiente, performático e relativamente simples. A chave está em gerenciar corretamente os estados de carregamento, tratar concorrência e fornecer feedback visual claro ao usuário.
Referências
- MDN Web Docs: IntersectionObserver — Documentação oficial completa da API, com exemplos e especificações técnicas
- React Documentation: Building your own hooks — Guia oficial do React sobre criação de hooks personalizados, base para o useInfiniteScroll
- Web.dev: Infinite scroll without layout shifts — Artigo do Google sobre boas práticas de infinite scroll, incluindo performance e UX
- CSS-Tricks: Intersection Observer API in React — Tutorial prático de implementação do IntersectionObserver em componentes React
- Smashing Magazine: Infinite Scroll Best Practices — Artigo abrangente sobre quando usar infinite scroll, acessibilidade e alternativas
- Testing Library: Mocking IntersectionObserver — Exemplo oficial de como mockar o IntersectionObserver em testes com React Testing Library