Scroll Infinito e Paginação
Páginas de cursor, pull-to-refresh e skeletons de carregamento - o padrão de carregamento de feed padrão em dispositivos móveis.
Busque em todas as páginas da documentação
Páginas de cursor, pull-to-refresh e skeletons de carregamento - o padrão de carregamento de feed padrão em dispositivos móveis.
Cartão de receita de referência rápida - pronto para copiar e colar.
import { useCallback, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
interface PageResult<T> {
items: T[];
nextCursor: string | null;
}
async function fetchPage(cursor?: string): Promise<PageResult<{ id: string; title: string }>> {
const res = await fetch(`https://api.example.com/posts?cursor=${cursor ?? ""}`);
return res.json();
}
export function InfinitePostList() {
const [items, setItems] = useState<{ id: string; title: string }[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const loadingRef = useRef(false);
const load = useCallback(async (mode: "refresh" | "more") => {
if (loadingRef.current) return;
loadingRef.current = true;
mode === "refresh" ? setRefreshing(true) : setLoadingMore(true);
try {
const page = await fetchPage(mode === "refresh" ? undefined : cursor ?? undefined);
setItems((prev) => (mode === "refresh" ? page.items : [...prev, ...page.items]));
setCursor(page.nextCursor);
} finally {
loadingRef.current = false;
setRefreshing(false);
setLoadingMore(false);
}
}, [cursor]);
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
)}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => load("refresh")} />}
onEndReached={() => cursor && load("more")}
onEndReachedThreshold={0.4}
ListFooterComponent={
loadingMore ? <ActivityIndicator style={styles.footer} /> : null
}
/>
);
}
const styles = StyleSheet.create({
row: { padding: 16, backgroundColor: "#fff" },
footer: { paddingVertical: 24 },
});Quando usar isso: Feeds sociais, resultados de pesquisa, histórico de notificações - qualquer lista com dados paginados do servidor.
import { useCallback, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
interface Notification {
id: string;
body: string;
read: boolean;
}
interface Page {
items: Notification[];
nextCursor: string | null;
}
// API de cursor simulada
const MOCK_DB: Notification[] = Array.from({ length: 48 }, (_, i) => ({
id: `n-${i}`,
body: `Notification ${i + 1}`,
read: i % 3 === 0,
}));
async function fetchNotifications(cursor: string | null, pageSize = 12): Promise<Page> {
await new Promise((r) => setTimeout(r, 600));
const start = cursor ? Number(cursor) : 0;
const slice = MOCK_DB.slice(start, start + pageSize);
const next = start + pageSize < MOCK_DB.length ? String(start + pageSize) : null;
return { items: slice, nextCursor: next };
}
function SkeletonRow() {
return (
<View style={styles.skeletonRow}>
<View style={styles.skeletonDot} />
<View style={styles.skeletonLines}>
<View style={styles.skeletonLine} />
<View style={[styles.skeletonLine, styles.skeletonLineShort]} />
</View>
</View>
);
}
function NotificationRow({ item }: { item: Notification }) {
return (
<View style={[styles.row, !item.read && styles.rowUnread]}>
<Text style={styles.body}>{item.body}</Text>
</View>
);
}
export default function NotificationFeedScreen() {
const [items, setItems] = useState<Notification[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadingRef = useRef(false);
const loadPage = useCallback(
async (mode: "initial" | "refresh" | "more") => {
if (loadingRef.current) return;
loadingRef.current = true;
setError(null);
if (mode === "initial") setInitialLoading(true);
if (mode === "refresh") setRefreshing(true);
if (mode === "more") setLoadingMore(true);
try {
const page = await fetchNotifications(mode === "more" ? cursor : null);
setItems((prev) => (mode === "more" ? [...prev, ...page.items] : page.items));
setCursor(page.nextCursor);
} catch {
setError("Could not load notifications.");
} finally {
loadingRef.current = false;
setInitialLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
},
[cursor],
);
// Carregamento inicial
if (initialLoading) {
return (
<View style={styles.screen}>
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonRow key={i} />
))}
</View>
);
}
return (
<View style={styles.screen}>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <NotificationRow item={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => loadPage("refresh")} />
}
onEndReached={() => {
if (cursor) loadPage("more");
}}
onEndReachedThreshold={0.3}
ListEmptyComponent={
error ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>{error}</Text>
<Pressable onPress={() => loadPage("refresh")} style={styles.retry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>All caught up</Text>
<Text style={styles.emptyBody}>No notifications right now.</Text>
</View>
)
}
ListFooterComponent={
loadingMore ? (
<ActivityIndicator style={styles.footerSpinner} color="#2563eb" />
) : cursor ? (
<Text style={styles.footerHint}>Scroll for more</Text>
) : (
<Text style={styles.footerHint}>End of list</Text>
)
}
contentContainerStyle={items.length === 0 ? styles.emptyContainer : undefined}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: "#f8fafc" },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: "#fff",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e2e8f0",
},
rowUnread: { backgroundColor: "#eff6ff" },
body: { fontSize: 15, color: "#0f172a" },
skeletonRow: {
flexDirection: "row",
gap: 12,
padding: 16,
alignItems: "center",
},
skeletonDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: "#e2e8f0",
},
skeletonLines: { flex: 1, gap: 8 },
skeletonLine: { height: 10, borderRadius: 4, backgroundColor: "#e2e8f0" },
skeletonLineShort: { width: "60%" },
emptyContainer: { flexGrow: 1, justifyContent: "center" },
empty: { alignItems: "center", padding: 32, gap: 8 },
emptyTitle: { fontSize: 17, fontWeight: "700", color: "#0f172a" },
emptyBody: { fontSize: 14, color: "#64748b" },
retry: {
marginTop: 8,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: "#2563eb",
},
retryText: { color: "#fff", fontWeight: "600" },
footerSpinner: { paddingVertical: 20 },
footerHint: {
textAlign: "center",
fontSize: 12,
color: "#94a3b8",
paddingVertical: 16,
},
});O que isso demonstra:
RefreshControl (pull-to-refresh), spinner ListFooterComponent (carregar mais).loadingRef impede buscas duplicadas de onEndReached quando o usuário "quica" na parte inferior.nextCursor é null quando esgotado; onEndReached só é disparado quando cursor é verdadeiro.onEndReachedThreshold={0.3} dispara a próxima página quando o usuário está a 30% do fim.ListEmptyComponent lida com resultados zero resolvidos e retentativa; o esqueleto lida com a primeira renderização.onEndReached é disparado quando a posição de rolagem cruza um limite a partir do final da lista - controlado por onEndReachedThreshold (0 = fundo, 1 = topo da área visível; comumente 0.2–0.5).onEndReached pode ser disparado múltiplas vezes por aproximação (mudanças de layout, quique, novos itens) - sempre proteja com um ref ou a flag isFetching da query.refreshing + onRefresh no FlatList, ou uma prop explícita refreshControl para cores personalizadas.nextCursor com cada página - anexe itens, passe o cursor na próxima requisição. Evita desvio de offset quando itens são inseridos/excluídos no lado do servidor.?page=3) é mais simples, mas falha quando o feed é mutado entre requisições.ListEmptyComponent - renderize um esqueleto ou um carregador de tela inteira até que a primeira página se estabilize.| Estratégia | Requisição | Padrão de Anexo | Risco |
|---|---|---|---|
| Cursor | ?cursor=abc | setItems(prev => [...prev, ...page]) | O servidor deve emitir cursores estáveis |
| Offset/limite | ?offset=40&limit=20 | Mesmo anexo | Duplicatas/pulos se o feed for mutado |
| Número da página | ?page=3 | Mesmo anexo | O mesmo que offset |
| Keyset | ?after_id=xyz | Mesmo anexo | Requer coluna de id estável para ordenação |
// Anexo de cursor - preferido para feeds ao vivo
setItems((prev) => (isRefresh ? page.items : [...prev, ...page.items]));
setCursor(page.nextCursor);
// Deduplicar se a API puder ter sobreposição
setItems((prev) => {
const map = new Map(prev.map((i) => [i.id, i]));
for (const item of page.items) map.set(item.id, item);
return Array.from(map.values());
});onEndReached<FlatList
onEndReached={fetchNextPage}
onEndReachedThreshold={0.3} // 30% do final do comprimento do conteúdo
// Opcional: apenas quando a lista é rolável
onMomentumScrollEnd={(e) => {
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
const distanceFromEnd =
contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromEnd < 200 && hasNextPage) fetchNextPage();
}}
/>onEndReached em listas curtas que não preenchem a tela pode ser disparado imediatamente - verifique hasNextPage e a altura do conteúdo.import { useInfiniteQuery } from "@tanstack/react-query";
import { FlatList, RefreshControl } from "react-native";
const query = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
<FlatList
data={items}
onEndReached={() => query.hasNextPage && query.fetchNextPage()}
refreshControl={
<RefreshControl refreshing={query.isRefetching} onRefresh={query.refetch} />
}
ListFooterComponent={query.isFetchingNextPage ? <Spinner /> : null}
/>isFetchingNextPage para o spinner do rodapé; isRefetching para pull-to-refresh (não isLoading na refetch).flatMap páginas em um único array para data do FlatList.| Fase | UI | Componente |
|---|---|---|
| Primeiro carregamento | Linhas de esqueleto ou shimmer | Tela inteira acima da lista |
| Pull-to-refresh | Spinner nativo no cabeçalho | RefreshControl |
| Carregar mais | Pequeno spinner na parte inferior | ListFooterComponent |
| Esgotado | Texto "Fim da lista" | ListFooterComponent |
| Erro (sem dados) | Mensagem + retentar | ListEmptyComponent |
| Erro (com dados) | Toast ou banner inline | Fora da lista |
interface PaginatedResponse<T> {
items: T[];
nextCursor: string | null;
}
type LoadMode = "initial" | "refresh" | "more";
interface PaginationState<T> {
items: T[];
cursor: string | null;
load: (mode: LoadMode) => Promise<void>;
refreshing: boolean;
loadingMore: boolean;
}string | null - null significa que não há mais páginas.| Parâmetro | Tipo | Descrição |
|---|---|---|
onEndReached | () => void | Chamado perto do final da lista - anexa a próxima página |
onEndReachedThreshold | number | Fração do comprimento visível a partir do final (0–1) |
refreshing | boolean | Controla a visibilidade do spinner de pull-to-refresh |
onRefresh | () => void | Manipulador de pull-to-refresh (substitui a primeira página) |
ListFooterComponent | ComponentType | ReactElement | Spinner de carregamento de mais itens ou rótulo de fim de lista |
refreshControl | ReactElement | RefreshControl personalizado (matiz, cores) |
Busca dupla em onEndReached - Dispara duas vezes quando o layout muda após anexar linhas. Correção: loadingRef ou if (isFetching) return antes de cada busca.
onEndReached na montagem com dados curtos - A lista não é rolável, mas o limite é disparado imediatamente. Correção: Busque mais apenas quando hasNextPage && items.length > 0; opcionalmente compare contentSize com layoutMeasurement.
Usar ListEmptyComponent para carregamento - Exibe brevemente "Sem resultados" antes da primeira resposta. Correção: Ramifique em initialLoading antes de renderizar a lista.
Substituir itens ao carregar mais - setItems(page.items) apaga páginas anteriores. Correção: Anexe: setItems(prev => [...prev, ...page.items]) exceto na atualização.
Paginação por offset em feeds ao vivo - Novos itens no topo mudam os índices; a página 3 retorna duplicatas. Correção: Paginação por cursor ou keyset da API.
Sem guarda de fim de lista - onEndReached continua disparando após a última página. Correção: Pare quando nextCursor === null ou !hasNextPage.
Spinner em ListEmptyComponent para carregar mais - O componente vazio só é exibido quando data está vazio. Correção: ListFooterComponent apenas para o spinner de paginação.
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
Scroll infinito (onEndReached) | Feeds, timelines, exploração | Formulários finitos ou assistentes com um fim conhecido |
| Botão "Carregar mais" | Busca controlada pelo usuário, acessibilidade | Feeds sociais estilo Instagram - UX ruim |
| Abas paginadas (página 1, 2, 3) | Tabelas de administração, pesquisa com salto | Feeds sociais móveis - UX ruim |
FlashList + mesma paginação | Feeds infinitos críticos para desempenho | Você ainda não validou o desempenho do FlatList |
| Pré-busca da próxima página a 50% da rolagem | Redes lentas, imagens grandes | Limites rígidos de taxa da API |
Um valor de 0 a 1 representando a que distância do final do conteúdo (como uma fração do comprimento visível da lista) dispara onEndReached. 0.5 dispara quando você está na metade do conteúdo rolável restante a partir do final. Comece com 0.3 e ajuste.
Mudanças de layout após anexar linhas movem o limite de rolagem. A rolagem de quique no iOS o dispara novamente. Proteja com uma flag de carregamento useRef ou o isFetchingNextPage da sua biblioteca de dados.
Use estados separados: refreshing + loadingMore.
Cursor - o servidor envia um nextCursor opaco; estável quando itens são adicionados/removidos. Offset - ?offset=40; simples, mas pode duplicar ou pular linhas em feeds mutáveis. Prefira cursores para feeds sociais e de notificações.
ListFooterComponent - ele permanece visível na parte inferior enquanto houver itens. ListEmptyComponent só é exibido quando data está vazio.
Renderize 5–8 linhas de placeholder em um View (ou um FlatList estático com data={SKELETON}) até que a primeira busca seja concluída. Não use ListEmptyComponent para esqueletos.
Sim - passe refreshing e onRefresh diretamente para o FlatList. Para cores de matiz personalizadas no iOS, passe um refreshControl={<RefreshControl ... />} explícito.
Use useInfiniteQuery, getNextPageParam para o cursor, fetchNextPage em onEndReached, e flatMap páginas em data. Veja TanStack React Query.
onEndReached ainda pode ser disparado. Verifique hasNextPage e considere buscar a próxima página até que a lista preencha o viewport (faça um loop com cuidado e um limite máximo de tentativas) ou mostre um botão "Carregar mais".
Em um novo filtro: redefina items para [], cursor para null, e chame load("refresh") com a nova chave de consulta. Com TanStack Query, altere queryKey para que o cache seja invalidado.
Se a API puder retornar sobreposições (tentativa, corrida), mescle por id com um Map. Caso contrário, o anexo simples é suficiente.
Sim - onEndReached, onEndReachedThreshold, e refreshControl funcionam da mesma forma. Veja FlashList vs FlatList.
Mantenha data intacto e mostre um toast ou um banner acima da lista. Use ListEmptyComponent com retentativa apenas quando items.length === 0.
Use inverted na lista (ou padrões de rolagem para o final), carregue mensagens mais antigas em onEndReached no topo visual, e preserve a posição de rolagem com maintainVisibleContentPosition ao anexar no início.
Veja Receitas FlatList para padrões de ListEmptyComponent, keyExtractor, e contentContainerStyle.
ListEmptyComponent, ListFooterComponent, e keyExtractormemouseInfiniteQueryusePaginatedList de componentes de telaVersões da Stack: Esta página foi escrita para React 19.2.3, React Native 0.86.0, e Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026