UX de fallos de red
Paneles offline, colas de reintentos y pantallas stale-while-revalidate.
Busca en todas las páginas de la documentación
Paneles offline, colas de reintentos y pantallas stale-while-revalidate.
Las redes móviles caen constantemente - ascensores, túneles, Wi-Fi inestable. Una buena UX de fallos de red detecta conectividad, comunica el estado, muestra datos en caché cuando es posible y reintenta de forma segura cuando el dispositivo vuelve a estar en línea.
Tarjeta de receta de referencia rápida - lista para copiar y pegar.
import NetInfo, { useNetInfo } from "@react-native-community/netinfo";
import { onlineManager, useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
// Sincroniza el estado en línea de TanStack Query con el dispositivo
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected && state.isInternetReachable !== false);
})
);
function OfflineBanner() {
const net = useNetInfo();
const offline = net.isConnected === false || net.isInternetReachable === false;
if (!offline) return null;
return (
<View style={styles.banner}>
<Text style={styles.bannerText}>Estás sin conexión. Mostrando datos guardados donde están disponibles.</Text>
</View>
);
}
async function fetchPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ id: number; title: string }[]>;
}
export function FeedScreen() {
const { data, error, isError, isFetching, refetch, dataUpdatedAt } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 60_000,
retry: 2,
});
return (
<View style={styles.screen}>
<OfflineBanner />
{data && (
<Text style={styles.stale}>
{isFetching ? "Actualizando…" : `Actualizado ${new Date(dataUpdatedAt).toLocaleTimeString()}`}
</Text>
)}
{isError && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error instanceof Error ? error.message : "La solicitud falló"}</Text>
<Pressable onPress={() => refetch()} style={styles.retry}>
<Text style={styles.retryLabel}>Reintentar</Text>
</Pressable>
</View>
)}
{data?.map((post) => (
<Text key={post.id} style={styles.row}>{post.title}</Text>
))}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, backgroundColor: "#fff" },
banner: { backgroundColor: "#fef3c7", padding: 10, borderRadius: 8, marginBottom: 12 },
bannerText: { color: "#92400e", fontWeight: "600" },
stale: { fontSize: 12, color: "#64748b", marginBottom: 8 },
errorBox: { padding: 12, backgroundColor: "#fee2e2", borderRadius: 8, marginBottom: 12 },
errorText: { color: "#991b1b", marginBottom: 8 },
retry: { alignSelf: "flex-start", backgroundColor: "#dc2626", paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6 },
retryLabel: { color: "#fff", fontWeight: "600" },
row: { fontSize: 14, marginBottom: 6 },
});Cuándo usarlo:
import AsyncStorage from "@react-native-async-storage/async-storage";
import NetInfo, { useNetInfo } from "@react-native-community/netinfo";
import { onlineManager, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
const QUEUE_KEY = "offline-mutation-queue-v1";
type Todo = { id: string; title: string; pending?: boolean };
type QueuedCreate = { id: string; title: string };
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected && state.isInternetReachable !== false);
})
);
async function loadTodos(): Promise<Todo[]> {
const raw = await AsyncStorage.getItem("todos-cache");
return raw ? (JSON.parse(raw) as Todo[]) : [];
}
async function saveTodos(todos: Todo[]) {
await AsyncStorage.setItem("todos-cache", JSON.stringify(todos));
}
async function readQueue(): Promise<QueuedCreate[]> {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
return raw ? (JSON.parse(raw) as QueuedCreate[]) : [];
}
async function writeQueue(items: QueuedCreate[]) {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(items));
}
async function createTodoOnServer(title: string): Promise<Todo> {
const net = await NetInfo.fetch();
if (!net.isConnected) throw new TypeError("OFFLINE");
// API simulada - reemplaza con tu backend
await new Promise((r) => setTimeout(r, 400));
return { id: `srv-${Date.now()}`, title };
}
function ConnectivityChrome() {
const net = useNetInfo();
const offline = net.isConnected === false;
const captive = net.isConnected && net.isInternetReachable === false;
if (offline) {
return (
<View style={[styles.banner, styles.offline]}>
<Text style={styles.bannerText}>Sin conexión - los cambios se sincronizarán cuando te reconectes.</Text>
</View>
);
}
if (captive) {
return (
<View style={[styles.banner, styles.captive]}>
<Text style={styles.bannerText}>Conectado, pero sin acceso a internet.</Text>
</View>
);
}
return null;
}
export default function App() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [queueLen, setQueueLen] = useState(0);
const todosQuery = useQuery({
queryKey: ["todos"],
queryFn: loadTodos,
staleTime: 30_000,
// Stale-while-revalidate: renderiza la lista en caché inmediatamente; refresca cuando está en línea
placeholderData: (prev) => prev,
});
const flushQueue = useCallback(async () => {
const queue = await readQueue();
if (queue.length === 0) return;
const remaining: QueuedCreate[] = [];
let todos = (await loadTodos()) ?? [];
for (const item of queue) {
try {
const created = await createTodoOnServer(item.title);
todos = todos.map((t) => (t.id === item.id ? created : t));
} catch {
remaining.push(item);
}
}
await saveTodos(todos);
await writeQueue(remaining);
setQueueLen(remaining.length);
queryClient.setQueryData(["todos"], todos);
}, [queryClient]);
useEffect(() => {
const unsub = NetInfo.addEventListener((state) => {
if (state.isConnected && state.isInternetReachable !== false) {
void flushQueue();
}
});
void readQueue().then((q) => setQueueLen(q.length));
return () => unsub();
}, [flushQueue]);
const createMutation = useMutation({
mutationFn: async (newTitle: string) => {
const optimistic: Todo = { id: `local-${Date.now()}`, title: newTitle, pending: true };
const current = (await loadTodos()) ?? [];
const next = [optimistic, ...current];
await saveTodos(next);
queryClient.setQueryData(["todos"], next);
try {
const created = await createTodoOnServer(newTitle);
const synced = next.map((t) => (t.id === optimistic.id ? created : t));
await saveTodos(synced);
queryClient.setQueryData(["todos"], synced);
return created;
} catch (e) {
if (e instanceof TypeError && e.message === "OFFLINE") {
const queue = await readQueue();
await writeQueue([...queue, { id: optimistic.id, title: newTitle }]);
setQueueLen((n) => n + 1);
return optimistic;
}
throw e;
}
},
});
const todos = todosQuery.data ?? [];
return (
<View style={styles.screen}>
<ConnectivityChrome />
{todosQuery.isFetching && <Text style={styles.meta}>Refrescando…</Text>}
{queueLen > 0 && (
<Text style={styles.meta}>{queueLen} cambio(s) esperando sincronización</Text>
)}
<Pressable
style={styles.add}
onPress={() => {
const next = title.trim() || `Todo ${Date.now()}`;
setTitle("");
createMutation.mutate(next);
}}
>
<Text style={styles.addLabel}>Añadir todo</Text>
</Pressable>
<FlatList
data={todos}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Text style={styles.row}>
{item.pending ? "⏳ " : ""}
{item.title}
</Text>
)}
ListEmptyComponent={<Text style={styles.meta}>Sin todos aún.</Text>}
/>
{todosQuery.isError && (
<Pressable onPress={() => todosQuery.refetch()} style={styles.retry}>
<Text style={styles.retryLabel}>Reintentar carga</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, backgroundColor: "#f8fafc" },
banner: { padding: 10, borderRadius: 8, marginBottom: 10 },
offline: { backgroundColor: "#fef3c7" },
captive: { backgroundColor: "#ffedd5" },
bannerText: { fontWeight: "600", color: "#78350f" },
meta: { fontSize: 12, color: "#64748b", marginBottom: 8 },
add: { backgroundColor: "#2563eb", padding: 12, borderRadius: 8, marginBottom: 12 },
addLabel: { color: "#fff", fontWeight: "600", textAlign: "center" },
row: { fontSize: 16, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#e2e8f0" },
retry: { marginTop: 12, padding: 12, backgroundColor: "#dc2626", borderRadius: 8, alignItems: "center" },
retryLabel: { color: "#fff", fontWeight: "600" },
});Esto demuestra:
useNetInfo impulsa un panel de conectividad con mensajes distintos para offline vs captive-portal.onlineManager sincroniza el comportamiento de reintentos de TanStack Query con la conectividad real del dispositivo.placeholderData mientras se ejecuta una actualización de fondo.NetInfo informa que el dispositivo está de nuevo en línea.refetch() - no un reinicio de la aplicación.@react-native-community/netinfo escucha eventos de conectividad del SO (Wi-Fi, celular, modo avión). isConnected significa que existe una interfaz de red; isInternetReachable añade un sondeo de accesibilidad (puede ser null mientras se verifica).onlineManager informa offline y reanuda cuando está en línea. Empareja con staleTime y gcTime para que los datos en caché sobrevivan a interrupciones breves.isFetching es true. Etiquétalo con dataUpdatedAt para que los usuarios sepan que los datos pueden ser antiguos.| Señal | Significado | Respuesta de UX |
|---|---|---|
isConnected === false | Modo avión, sin interfaz | Panel offline; encolar mutaciones |
isInternetReachable === false | Wi-Fi sin DNS/captive portal | Panel "Conectado pero sin internet" |
isInternetReachable === null | Sondeo en vuelo | No mostrar UI offline - mantener estado anterior |
Fetch lanza TypeError / timeout | Fallo de servidor o DNS mientras "en línea" | Error en línea + Reintentar; no el panel offline |
| HTTP 5xx | Error de servidor | "Servicio no disponible" + Reintentar con backoff |
npx expo install @react-native-community/netinfo @react-native-async-storage/async-storage @tanstack/react-queryimport { AppState, Platform } from "react-native";
import { focusManager, QueryClient } from "@tanstack/react-query";
// Vuelve a traer cuando la aplicación está en primer plano (se empareja con recuperación de red)
focusManager.setEventListener((handleFocus) => {
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") handleFocus();
});
return () => sub.remove();
});
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 30_000,
gcTime: 24 * 60 * 60_000,
},
},
});Consulta TanStack Query en dispositivos móviles para la configuración completa de focusManager / onlineManager.
| Preocupación | Recomendación |
|---|---|
| Almacenamiento | AsyncStorage para colas pequeñas; MMKV/SQLite para alto volumen |
| Idempotencia | Envía claves de idempotencia generadas por el cliente a tu API |
| Orden | Procesa FIFO por entidad; paraleliza entidades independientes |
| Conflicto | En 409, descartar o fusionar usando la versión del servidor |
| UI | Muestra estado pending en filas optimistas hasta que la sincronización se complete |
| Límite de fallos | Después de N fallos, expón el elemento al usuario para reintentar/descartar manualmente |
type QueueItem = {
idempotencyKey: string;
operation: "create" | "update" | "delete";
payload: unknown;
attempts: number;
};| Patrón | Usuario ve | Cuándo |
|---|---|---|
| Lista en caché + panel | Feed anterior + "Sin conexión" | isConnected === false |
| En caché + spinner | Datos antiguos + "Actualizando…" sutil | isFetching && data |
| Vacío + error | Ilustración + Reintentar | isError && !data |
| Carga esqueleto primero | Filas de marcador de posición | isLoading && !data |
Siempre muestra cuándo fueron frescos los datos por última vez: dataUpdatedAt, "Actualizado hace 2 min", o "Mostrando copia guardada".
import type { NetInfoState } from "@react-native-community/netinfo";
function isEffectivelyOffline(state: NetInfoState): boolean {
if (state.isConnected === false) return true;
if (state.isInternetReachable === false) return true;
return false;
}
type NetworkFailure =
| { kind: "offline" }
| { kind: "timeout" }
| { kind: "http"; status: number };
function classifyFetchError(error: unknown): NetworkFailure {
if (error instanceof TypeError) return { kind: "offline" };
return { kind: "timeout" };
}isInternetReachable === null como desconocido, no offline - evita parpadeo de panel.Usar un único fetch fallido para significar "offline" - Los servidores devuelven 500 mientras Wi-Fi está bien. Solución: Combina NetInfo con tipo de error; muestra copias diferentes para offline vs errores de servidor.
Parpadear el panel offline cuando isInternetReachable es null - NetInfo sondea tiempo en el lanzamiento. Solución: Muestra UI offline solo cuando isConnected === false o isInternetReachable === false, no durante null.
Spinners de reintentos infinitos - retry: true sin onlineManager agota la batería en modo avión. Solución: Conecta onlineManager a NetInfo y limita reintentos.
Recargar la aplicación en Reintentar - Updates.reloadAsync() descarta el estado en memoria y se ve roto. Solución: Llama a refetch() o reintenta el elemento de la cola.
Pérdida silenciosa de mutación sin conexión - Los usuarios piensan que los cambios se guardaron. Solución: UI optimista con marcador pending + cola persistida hasta ack del servidor.
Mostrar datos obsoletos sin etiquetarlos - Los usuarios toman decisiones en precios o saldos desactualizados. Solución: Muestra dataUpdatedAt o "Mostrando datos guardados desde …".
Reintento de cola sin idempotencia - La reconexión crea cargos o posts duplicados. Solución: Claves de idempotencia en cliente y servidor; consulta Reintentos, Backoff e Idempotencia.
| Alternativa | Usar cuándo | No usar cuándo |
|---|---|---|
| NetInfo + paneles manuales | Control total, cualquier capa de datos | Ya estandarizas en isFetching / isError de TanStack Query solamente |
| Solo TanStack Query (sin NetInfo) | Aplicaciones de solo lectura con caché tolerante | Debes limitar mutaciones o explicar el estado offline claramente |
| Offline-first completo (SQLite sync) | Aplicaciones de campo con ventanas offline largas | Aplicaciones CRUD simples que solo necesitan fallos graciosos |
Módulo Network de Expo | Docs heredados de Expo | Aplicaciones nuevas - prefiere @react-native-community/netinfo |
| Background sync (TaskManager) | Cargas grandes que deben completarse después | La retroalimentación del usuario inmediato en la misma pantalla es suficiente |
npx expo install @react-native-community/netinfoExpo soporta este módulo en compilaciones de desarrollo y producción; no se requiere complemento de configuración para listeners de conectividad básicos.
isConnected - El dispositivo tiene una interfaz de red (Wi-Fi o celular).isInternetReachable - NetInfo sondeó el acceso real a internet (puede ser false en portales cautivos).Usa ambos: muestra offline cuando está desconectado; muestra un mensaje diferente cuando está conectado pero no es accesible.
TanStack Query pausa reintentos automáticos cuando está offline y reanuda cuando onlineManager cambia a true. Sin esto, las consultas reintentarían inútilmente en modo avión.
Muestra la última respuesta exitosa inmediatamente (placeholderData o caché persistido), obtén datos frescos en segundo plano y etiqueta la marca de tiempo. Los usuarios permanecen productivos durante redes lentas o inestables.
Usualmente no - usa un panel delgado sin bloqueo. Deshabilita solo acciones que realmente no pueden funcionar sin conexión (por ejemplo, captura de pago), no pantallas completas de solo lectura con datos en caché.
Usa persistQueryClient de TanStack Query con AsyncStorage o MMKV para pantallas de lectura principalmente. Empareja con staleTime e invalidación manual en logout.
<Pressable onPress={() => refetch()}>
<Text>Reintentar</Text>
</Pressable>Vuelve a traer la consulta fallida o reintenta el elemento específico de la cola - no reinicia la aplicación a menos que un error nativo fatal lo requiera.
En fallo de mutación cuando está sin conexión: escribe { idempotencyKey, payload } en almacenamiento, muestra UI optimista pending, suscríbete a NetInfo y vacía la cola cuando está conectado. Incrementa attempts y detente después de un límite.
Sin conexión: panel amarillo, acciones de cola, mostrar caché. Servidor 500: error en línea en la sección afectada ("Servicio no disponible"), Reintentar con backoff exponencial, sin panel offline.
Sí - RefreshControl conectado a refetch() es idiomático en dispositivos móviles y coincide con las expectativas del usuario junto con un botón Reintentar explícito.
Sí - suscríbete en un proveedor raíz o hook y actualiza el estado global. Vacía colas en reconexión incluso si el usuario regresa después; empareja con recarga de primer plano AppState.
Comienza con 30-60 segundos para feeds que cambian frecuentemente, más tiempo para catálogos y conchas de perfil. Equilibra frescura con batería - consulta Conceptos básicos de redes.
Sí - mantén lista obsoleta visible, muestra una barra de error compacta ("No se pudo refrescar") y deja que Reintentar llame a refetch(). Ocultar caché bueno en fallo de refresco transitorio se siente peor que degradación parcial.
Esta página cubre UX de fallo en pantallas en red. Offline-first completo añade bases de datos locales, motores de sincronización y resolución de conflictos - consulta Conceptos básicos offline-first.
focusManager, onlineManager, refresco en primer planoVersiones 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