Experiência do Usuário em Falhas de Rede
Banners de offline, filas de retentativa e exibição de stale-while-revalidate.
Busque em todas as páginas da documentação
Banners de offline, filas de retentativa e exibição de stale-while-revalidate.
Redes móveis caem constantemente - elevadores, túneis, Wi‑Fi instável. Uma boa UX de falha de rede detecta conectividade, comunica o estado, exibe dados em cache quando possível e tenta novamente com segurança quando o dispositivo está online novamente.
Cartão de receita de referência rápida - pronto para copiar e colar.
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 o estado online do TanStack Query com o 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}>Você está offline. Exibindo dados salvos onde disponíveis.</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 ? "Atualizando…" : `Atualizado ${new Date(dataUpdatedAt).toLocaleTimeString()}`}
</Text>
)}
{isError && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error instanceof Error ? error.message : "Falha na requisição"}</Text>
<Pressable onPress={() => refetch()} style={styles.retry}>
<Text style={styles.retryLabel}>Tentar Novamente</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 },
});Quando usar isso:
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 - substitua pelo seu 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}>Offline - as alterações serão sincronizadas quando você reconectar.</Text>
</View>
);
}
if (captive) {
return (
<View style={[styles.banner, styles.captive]}>
<Text style={styles.bannerText}>Conectado, mas sem alcance de 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: exibe a lista em cache imediatamente; atualiza quando online
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}>Atualizando…</Text>}
{queueLen > 0 && (
<Text style={styles.meta}>{queueLen} alteração(ões) aguardando sincronização</Text>
)}
<Pressable
style={styles.add}
onPress={() => {
const next = title.trim() || `Todo ${Date.now()}`;
setTitle("");
createMutation.mutate(next);
}}
>
<Text style={styles.addLabel}>Adicionar 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}>Nenhum todo ainda.</Text>}
/>
{todosQuery.isError && (
<Pressable onPress={() => todosQuery.refetch()} style={styles.retry}>
<Text style={styles.retryLabel}>Tentar carregar novamente</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" },
});O que isso demonstra:
useNetInfo controla um banner de conectividade com mensagens distintas para offline e portal cativo.onlineManager sincroniza o comportamento de retentativa do TanStack Query com a conectividade real do dispositivo.placeholderData enquanto uma retentativa em segundo plano é executada.NetInfo informa que o dispositivo está online novamente.refetch() - não reinicia o aplicativo.@react-native-community/netinfo escuta eventos de conectividade do sistema operacional (Wi‑Fi, celular, modo avião). isConnected significa que uma interface de rede existe; isInternetReachable adiciona uma sonda de alcance (pode ser null enquanto verifica).onlineManager informa offline e retoma quando online. Combine com staleTime e gcTime para que os dados em cache sobrevivam a curtas interrupções.isFetching é verdadeiro. Rotule-o com dataUpdatedAt para que os usuários saibam que os dados podem estar desatualizados.| Sinal | Significado | Resposta da UX |
|---|---|---|
isConnected === false | Modo avião, sem interface | Banner offline; enfileirar mutações |
isInternetReachable === false | Wi‑Fi sem DNS/portal cativo | Banner "Conectado, mas sem internet" |
isInternetReachable === null | Sonda em andamento | Não piscar UI offline - manter estado anterior |
Fetch lança TypeError / timeout | Falha no servidor ou DNS enquanto "online" | Erro inline + Retentativa; não o banner offline |
| HTTP 5xx | Erro do servidor | "Serviço indisponível" + Retentativa com 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";
// Refetch ao trazer o aplicativo para o primeiro plano (combina com recuperação de rede)
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,
},
},
});Veja TanStack Query em Mobile para configuração completa de focusManager / onlineManager.
| Preocupação | Recomendação |
|---|---|
| Armazenamento | AsyncStorage para filas pequenas; MMKV/SQLite para alto volume |
| Idempotência | Envie chaves de idempotência geradas pelo cliente para sua API |
| Ordenação | Processe FIFO por entidade; paralelize entidades independentes |
| Conflito | Em caso de 409, descarte ou mescle usando a versão do servidor |
| UI | Exiba o estado pending em linhas otimistas até que o flush seja bem-sucedido |
| Limite de falha | Após N falhas, apresente o item ao usuário para retentativa manual/descarte |
type QueueItem = {
idempotencyKey: string;
operation: "create" | "update" | "delete";
payload: unknown;
attempts: number;
};| Padrão | Usuário vê | Quando |
|---|---|---|
| Lista em cache + banner | Último feed + "Offline" | isConnected === false |
| Cache + spinner | Dados antigos + sutil "Atualizando…" | isFetching && data |
| Vazio + erro | Ilustração + Retentativa | isError && !data |
| Esqueleto na primeira carga | Linhas de placeholder | isLoading && !data |
Sempre mostre quando os dados foram atualizados pela última vez: dataUpdatedAt, "Atualizado há 2 min", ou "Exibindo cópia salva".
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 desconhecido, não offline - evita piscar o banner.Usar uma única busca falha para significar "offline" - Servidores retornam 500 enquanto o Wi‑Fi está funcionando. Correção: Combine NetInfo com o tipo de erro; exiba cópias diferentes para erros offline vs. de servidor.
Exibir o banner offline quando isInternetReachable é null - As sondas do NetInfo levam um momento no lançamento. Correção: Exiba a UI offline apenas quando isConnected === false ou isInternetReachable === false, não durante null.
Spinners de retentativa infinitos - retry: true sem onlineManager drena a bateria em modo avião. Correção: Conecte onlineManager ao NetInfo e limite as retentativas.
Recarregar o aplicativo em Retentativa - Updates.reloadAsync() descarta o estado em memória e parece quebrado. Correção: Chame refetch() ou reproduza o item da fila.
Perda silenciosa de mutação offline - Os usuários pensam que os salvamentos foram bem-sucedidos. Correção: UI otimista com marcador pending + fila persistida até o ack do servidor.
Exibir dados desatualizados sem rotulá-los - Os usuários tomam decisões com base em preços ou saldos desatualizados. Correção: Exiba dataUpdatedAt ou "Exibindo dados salvos de...".
Reprodução de fila sem idempotência - Reconectar cria cobranças ou postagens duplicadas. Correção: Chaves de idempotência no cliente e servidor; veja Retentativas, Backoff e Idempotência.
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| NetInfo + banners manuais | Controle total, qualquer camada de dados | Você já padroniza apenas com isFetching / isError do TanStack Query |
| Apenas TanStack Query (sem NetInfo) | Aplicativos somente leitura com cache tolerante | Você precisa restringir mutações ou explicar claramente o estado offline |
| Offline-first completo (sincronização SQLite) | Aplicativos de campo com longas janelas offline | Aplicativos CRUD simples que precisam apenas de falha graciosa |
Módulo Network do Expo | Referência de documentação legada do Expo | Aplicativos Greenfield - prefira @react-native-community/netinfo |
| Sincronização em segundo plano (TaskManager) | Uploads grandes que devem terminar mais tarde | Feedback imediato do usuário na mesma tela é suficiente |
npx expo install @react-native-community/netinfoO Expo suporta este módulo em builds de desenvolvimento e produção; nenhum plugin de configuração é necessário para ouvintes básicos de conectividade.
isConnected - o dispositivo tem uma interface de rede (Wi‑Fi ou celular).isInternetReachable - o NetInfo sondou o acesso real à internet (pode ser false em portais cativos).Use ambos: exiba offline quando desconectado; exiba uma mensagem diferente quando conectado, mas inalcançável.
O TanStack Query pausa as retentativas automáticas quando offline e retoma quando onlineManager muda para true. Sem isso, as consultas tentam novamente inutilmente em modo avião.
Exiba a última resposta bem-sucedida imediatamente (placeholderData ou cache persistido), busque dados frescos em segundo plano e rotule o timestamp. Os usuários permanecem produtivos durante redes lentas ou instáveis.
Geralmente não - use um banner fino e não bloqueador. Desabilite apenas ações que verdadeiramente não podem funcionar offline (por exemplo, captura de pagamento), não telas inteiras somente leitura com dados em cache.
Use persistQueryClient do TanStack Query com AsyncStorage ou MMKV para telas de leitura principal. Combine com staleTime e invalidação manual ao sair.
<Pressable onPress={() => refetch()}>
<Text>Retry</Text>
</Pressable>Refetch a consulta falha ou reproduza o item específico da fila - não reinicie o aplicativo, a menos que um erro nativo fatal o exija.
Na falha da mutação quando offline: escreva { idempotencyKey, payload } para armazenamento, exiba UI otimista pending, assine o NetInfo e envie a fila quando conectado. Incremente attempts e pare após um limite.
Offline: banner amarelo, enfileirar ações, exibir cache. Server 500: erro inline na seção afetada ("Serviço indisponível"), Retentativa com backoff exponencial, sem banner offline.
Sim - RefreshControl conectado a refetch() é idiomático em mobile e corresponde às expectativas do usuário junto com um botão Retry explícito.
Sim - assine em um provedor raiz ou hook e atualize o estado global. Envie filas ao reconectar, mesmo que o usuário retorne mais tarde; combine com refetch de primeiro plano do AppState.
Comece com 30–60 segundos para feeds que mudam com frequência, mais tempo para catálogos e shells de perfil. Equilibre a atualidade com a bateria - veja Noções Básicas de Rede.
Sim - mantenha a lista desatualizada visível, exiba uma barra de erro compacta ("Não foi possível atualizar") e deixe Retry chamar refetch(). Ocultar cache bom em falha de atualização transitória parece pior do que degradação parcial.
Esta página cobre a UX de falha em telas conectadas. Offline-first completo adiciona bancos de dados locais, motores de sincronização e resolução de conflitos - veja Noções Básicas de Offline-First.
focusManager, onlineManager, refetch de primeiro planoVersõ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