Infinite Scroll y Paginación
Páginas con cursor, pull-to-refresh y esqueletos de carga - el patrón estándar de carga de feed en dispositivos móviles.
Busca en todas las páginas de la documentación
Páginas con cursor, pull-to-refresh y esqueletos de carga - el patrón estándar de carga de feed en dispositivos móviles.
Tarjeta de referencia rápida - lista para copiar y pegar.
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 },
});Cuándo usarlo: Feeds sociales, resultados de búsqueda, historial de notificaciones - cualquier lista respaldada por datos de servidor paginados.
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: `Notificación ${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("No se pudo cargar las notificaciones.");
} finally {
loadingRef.current = false;
setInitialLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
},
[cursor],
);
// Carga 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}>Reintentar</Text>
</Pressable>
</View>
) : (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Todo al día</Text>
<Text style={styles.emptyBody}>Sin notificaciones en este momento.</Text>
</View>
)
}
ListFooterComponent={
loadingMore ? (
<ActivityIndicator style={styles.footerSpinner} color="#2563eb" />
) : cursor ? (
<Text style={styles.footerHint}>Desplázate para ver más</Text>
) : (
<Text style={styles.footerHint}>Fin de la lista</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,
},
});Lo que esto demuestra:
RefreshControl (pull-to-refresh), spinner de ListFooterComponent (cargar más).loadingRef previene recuperaciones duplicadas de onEndReached cuando el usuario rebota en la parte inferior.nextCursor es null cuando se agota; onEndReached solo se dispara cuando cursor es truthy.onEndReachedThreshold={0.3} dispara la siguiente página cuando el usuario está al 30% del final.ListEmptyComponent maneja cero resultados resueltos y reintentos; el esqueleto maneja la primera pintura.onEndReached se dispara cuando la posición de desplazamiento cruza un umbral desde la parte inferior de la lista - controlado por onEndReachedThreshold (0 = fondo, 1 = parte superior del área visible; comúnmente 0.2-0.5).onEndReached puede dispararse múltiples veces por aproximación (cambios de diseño, rebote, elementos nuevos) - siempre protege con una ref o bandera isFetching de consulta.refreshing + onRefresh en FlatList, o una prop refreshControl explícita para colores personalizados.nextCursor con cada página - agrega elementos, pasa cursor en la siguiente solicitud. Evita desvío de offset cuando se insertan/eliminan elementos del lado del servidor.?page=3) es más simple pero se rompe cuando el feed muta entre solicitudes.ListEmptyComponent - renderiza un esqueleto o cargador de pantalla completa hasta que se resuelva la primera página.| Estrategia | Solicitud | Patrón de adición | Riesgo |
|---|---|---|---|
| Cursor | ?cursor=abc | setItems(prev => [...prev, ...page]) | El servidor debe emitir cursores estables |
| Offset/limit | ?offset=40&limit=20 | Misma adición | Duplicados/saltos si el feed muta |
| Número de página | ?page=3 | Misma adición | Lo mismo que offset |
| Keyset | ?after_id=xyz | Misma adición | Necesita columna de id ordenable estable |
// Adición con cursor - preferida para feeds en vivo
setItems((prev) => (isRefresh ? page.items : [...prev, ...page.items]));
setCursor(page.nextCursor);
// Deduplica si la API puede superponer
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());
});<FlatList
onEndReached={fetchNextPage}
onEndReachedThreshold={0.3} // 30% desde el fondo de la longitud del contenido
// Opcional: solo cuando la lista es desplazable
onMomentumScrollEnd={(e) => {
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
const distanceFromEnd =
contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromEnd < 200 && hasNextPage) fetchNextPage();
}}
/>onEndReached en listas cortas que no llenan la pantalla puede dispararse inmediatamente - comprueba hasNextPage y altura del contenido.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 spinner de pie de página; isRefetching para pull-to-refresh (no isLoading en recarga).flatMap páginas en una matriz para FlatList data.| Fase | IU | Componente |
|---|---|---|
| Primera carga | Filas de esqueleto o shimmer | Pantalla completa encima de la lista |
| Pull-to-refresh | Spinner nativo en encabezado | RefreshControl |
| Cargar más | Spinner pequeño en la parte inferior | ListFooterComponent |
| Agotado | Copia "Fin de la lista" | ListFooterComponent |
| Error (sin datos) | Mensaje + reintentar | ListEmptyComponent |
| Error (con datos) | Toast o banner en línea | Fuera de la 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 sin más páginas.| Parámetro | Tipo | Descripción |
|---|---|---|
onEndReached | () => void | Llamado cerca del final de la lista - agrega la siguiente página |
onEndReachedThreshold | number | Fracción de longitud visible desde el final (0-1) |
refreshing | boolean | Controla la visibilidad del spinner de pull-to-refresh |
onRefresh | () => void | Manejador de pull-to-refresh (reemplaza la primera página) |
ListFooterComponent | ComponentType | ReactElement | Spinner de cargar más o etiqueta de fin de lista |
refreshControl | ReactElement | RefreshControl personalizado (tinte, colores) |
Doble recuperación en onEndReached - Se dispara dos veces cuando el diseño cambia después de agregar. Solución: loadingRef o if (isFetching) return antes de cada recuperación.
onEndReached en montaje con datos cortos - La lista no es desplazable pero el umbral se dispara inmediatamente. Solución: Solo recupera más cuando hasNextPage && items.length > 0; opcionalmente compara contentSize con layoutMeasurement.
Usar ListEmptyComponent para carga - Destella "Sin resultados" antes de la primera respuesta. Solución: Rama en initialLoading antes de renderizar la lista.
Reemplazar elementos en cargar más - setItems(page.items) borra páginas anteriores. Solución: Agrega: setItems(prev => [...prev, ...page.items]) excepto en actualización.
Paginación por offset en feeds en vivo - Los nuevos elementos en la parte superior cambian índices; página 3 devuelve duplicados. Solución: Paginación por cursor o keyset desde la API.
Sin protección de fin de lista - onEndReached sigue disparándose después de la última página. Solución: Detente cuando nextCursor === null o !hasNextPage.
Spinner en ListEmptyComponent para cargar más - El componente vacío se oculta cuando existen elementos. Solución: ListFooterComponent solo para spinner de paginación.
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
Scroll infinito (onEndReached) | Feeds, cronogramas, exploración | Formularios finitos o asistentes con final conocido |
| Botón "Cargar más" | Recuperación controlada por usuario, accesibilidad | Feeds de estilo Instagram - UX pobre |
| Pestañas paginadas (página 1, 2, 3) | Tablas de administrador, búsqueda con salto | Feeds sociales móviles - UX pobre |
FlashList + misma paginación | Feeds infinitos críticos de rendimiento | No has validado rendimiento de FlatList aún |
| Precarga siguiente página al 50% de desplazamiento | Redes lentas, imágenes grandes | Límites de velocidad de API estrictos |
Un valor de 0 a 1 que representa qué tan lejos del final del contenido (como fracción de la longitud de la lista visible) dispara onEndReached. 0.5 se dispara cuando estás a mitad de camino a través del contenido desplazable restante desde la parte inferior. Comienza con 0.3 y ajusta.
Los cambios de diseño después de agregar filas mueven el umbral de desplazamiento. El desplazamiento de rebote en iOS lo vuelve a disparar. Protege con una bandera de carga useRef o la bandera isFetchingNextPage de tu biblioteca de datos.
Usa estado separado: refreshing + loadingMore.
Cursor - el servidor envía un nextCursor opaco; estable cuando se agregan/eliminan elementos. Offset - ?offset=40; simple pero puede duplicar o saltar filas en feeds mutantes. Prefiere cursores para feeds sociales y de notificaciones.
ListFooterComponent - permanece visible en la parte inferior mientras existen elementos. ListEmptyComponent solo se muestra cuando data está vacía.
Renderiza 5-8 filas de marcador de posición en una View (o una FlatList estática con data={SKELETON}) hasta que se complete la primera recuperación. No uses ListEmptyComponent para esqueletos.
Sí - pasa refreshing y onRefresh directamente a FlatList. Para colores de tinte personalizados en iOS, pasa un refreshControl={<RefreshControl ... />} explícito.
Usa useInfiniteQuery, getNextPageParam para el cursor, fetchNextPage en onEndReached, y flatMap páginas en data. Ver TanStack React Query.
onEndReached puede aún dispararse. Comprueba hasNextPage y considera recuperar la siguiente página hasta que la lista llene la ventana gráfica (bucle con cuidado y un límite máximo de intentos) o muestra un botón "Cargar más".
En nuevo filtro: reinicia items a [], cursor a null, y llama a load("refresh") con la nueva clave de consulta. Con TanStack Query, cambia queryKey para que la caché se invalide.
Si la API puede devolver superposiciones (reintento, carrera), fusiona por id con una Map. De lo contrario, la adición simple es suficiente.
Sí - onEndReached, onEndReachedThreshold y refreshControl funcionan igual. Ver FlashList vs FlatList.
Mantén data intacta y muestra un toast o un banner encima de la lista. Usa ListEmptyComponent con reintentar solo cuando items.length === 0.
Usa inverted en la lista (o patrones de desplazamiento a fondo), carga mensajes antiguos en onEndReached en la parte superior visual, y preserva la posición de desplazamiento con maintainVisibleContentPosition cuando prepends.
Ver FlatList Recipes para patrones de ListEmptyComponent, keyExtractor y contentContainerStyle.
ListEmptyComponent, ListFooterComponent y keyExtractormemouseInfiniteQueryusePaginatedList de componentes de pantallaVersiones de Stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0 y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026