10 ejemplos para comenzar con datos offline-first en móvil - 7 básicos y 3 intermedios. Los usuarios móviles pierden señal constantemente; trata la conectividad como un estado de característica, no como una excepción.
Scaffolding de una aplicación Expo de nivel producción con un pin SDK 57 explícito. Los ejemplos offline asumen Expo Router, TypeScript y TanStack Query como la capa de estado del servidor.
Offline-first comienza en producto y arquitectura - no en un hook NetInfo agregado después del lanzamiento. Documenta la suposición en tu README de capa de datos o ADR.
Suposiciones offline-first (contrato de equipo):1. Las lecturas tienen éxito desde caché local incluso cuando isConnected === false2. Las escrituras se aceptan localmente de inmediato; la confirmación del servidor es asincrónica3. Los conflictos se resuelven por una estrategia documentada (gana el servidor, LWW o CRDT)4. Los usuarios siempre saben si los datos son activos, anticuados o pendientes de sincronización5. El arranque en frío debe renderizar la última versión conocida buena dentro del presupuesto de un fotograma cuando sea posible
Los usuarios necesitan copia predecible para cuatro estados. El diseño y la ingeniería deben estar de acuerdo en las etiquetas antes de la implementación.
Estado
El usuario ve
Señal de ingeniería
Activo
Interfaz de usuario normal, etiqueta opcional sutil "Actualizado hace poco"
isFetching === false, en línea, caché fresco
Anticuado
Datos visibles + "Datos guardados desde las 2:14 PM"
dataUpdatedAt más antiguo que staleTime o lectura sin conexión
Pausa los reintentos de consulta cuando el dispositivo está sin conexión - reintentos sin límite en modo avión drenan la batería y envían spam a los registros.
// src/lib/setupMobileQuery.tsimport NetInfo from "@react-native-community/netinfo";import { AppState, type AppStateStatus } from "react-native";import { focusManager, onlineManager } from "@tanstack/react-query";export function setupMobileQuery() { onlineManager.setEventListener((setOnline) => NetInfo.addEventListener((state) => { const online = !!state.isConnected && state.isInternetReachable !== false; setOnline(online); }) ); const onChange = (status: AppStateStatus) => { focusManager.setFocused(status === "active"); }; const sub = AppState.addEventListener("change", onChange); return () => sub.remove();}
// app/_layout.tsx (fragmento)import { QueryClientProvider } from "@tanstack/react-query";import { useEffect, useState } from "react";import { createQueryClient } from "@/lib/queryClient";import { setupMobileQuery } from "@/lib/setupMobileQuery";export default function RootLayout() { const [queryClient] = useState(() => createQueryClient()); useEffect(() => setupMobileQuery(), []); return ( <QueryClientProvider client={queryClient}> {/* rutas */} </QueryClientProvider> );}
onlineManager controla si Query considera el cliente en línea para reintentos y refetchOnReconnect
focusManager vincula refetchOnWindowFocus a AppState - vuelve a buscar cuando la aplicación regresa al primer plano
Llama a setupMobileQuery() una vez en bootstrap - listeners duplicados causan cambios de estado en línea
isPending && !data es el único caso para un cargador de pantalla completa - si existe data, se aplica SWR
staleTime: 60_000 mantiene la lista de trabajos fresca durante un minuto sin refetch en cada enfoque
Las anulaciones por consulta superan los valores predeterminados globales - las listas de catálogos pueden ser más anticuadas que los saldos de cuentas
Cuando está sin conexión, la aplicación aún debe aceptar la intención del usuario - almacenar localmente y superficie el estado de sincronización.
// src/data/outbox.tsimport AsyncStorage from "@react-native-async-storage/async-storage";export type OutboxItem = { id: string; type: "create_note"; payload: { text: string }; createdAt: number; idempotencyKey: string;};const OUTBOX_KEY = "sync:outbox:v1";export async function enqueueOutbox(item: OutboxItem) { const raw = await AsyncStorage.getItem(OUTBOX_KEY); const queue: OutboxItem[] = raw ? JSON.parse(raw) : []; queue.push(item); await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(queue));}export async function readOutbox(): Promise<OutboxItem[]> { const raw = await AsyncStorage.getItem(OUTBOX_KEY); return raw ? JSON.parse(raw) : [];}
// Indicador de interfaz de usuario en un compositor de nota{pendingSync && ( <Text style={{ color: "#6b7280" }}>Guardado en el dispositivo - se sincronizará cuando esté en línea</Text>)}
Cada artículo en cola necesita una idempotencyKey - las reproducciones después de reconectar no deben duplicar filas del servidor
El esquema de bandeja de salida tiene versión (sync:outbox:v1) - las migraciones siguen AsyncStorage Patterns
Vacía la bandeja de salida en reconexión de NetInfo y en primer plano de la aplicación - ver Background Sync & TaskManager
Relacionado:Optimistic UI - interfaz de usuario instantánea con reversión en caso de fallo
Vincula conectividad, caché, almacenamiento e interfaz de usuario en una pantalla de lista de verificación que los equipos pueden copiar.
// src/features/checklist/OfflineChecklistScreen.tsximport { useQuery } from "@tanstack/react-query";import { Switch, Text, View } from "react-native";import { DataFreshnessLabel } from "@/components/DataFreshnessLabel";import { OfflineBanner } from "@/components/OfflineBanner";import { useEffectiveConnectivity } from "@/hooks/useEffectiveConnectivity";import { useOfflineModeStore } from "@/stores/offlineModeStore";import { fetchChecklist } from "@/api/checklist";export function OfflineChecklistScreen() { const { isOffline, forcedOffline } = useEffectiveConnectivity(); const setForcedOffline = useOfflineModeStore((s) => s.setForcedOffline); const { data, isFetching, isPending, dataUpdatedAt, refetch } = useQuery({ queryKey: ["checklist"], queryFn: fetchChecklist, staleTime: 5 * 60_000, // Saltar refetch de red en modo sin conexión forzada; los datos en caché aún se renderizan vía SWR enabled: !forcedOffline, }); return ( <View style={{ flex: 1, padding: 16 }}> <OfflineBanner /> <View style={{ flexDirection: "row", alignItems: "center", marginBottom: 12 }}> <Text style={{ flex: 1 }}>Forzar modo sin conexión</Text> <Switch value={forcedOffline} onValueChange={setForcedOffline} /> </View> {data && ( <DataFreshnessLabel dataUpdatedAt={dataUpdatedAt} isFetching={isFetching} isOffline={isOffline} /> )} {isPending && !data && <Text>Cargando lista de verificación…</Text>} {!data && isOffline && ( <Text onPress={() => refetch()}>Sin lista de verificación guardada. Conéctate para descargar.</Text> )} {data?.map((item) => ( <Text key={item.id}>• {item.label}</Text> ))} </View> );}
Lista de verificación de ruta de lectura sin conexión:✓ NetInfo + onlineManager cableado en bootstrap✓ staleTime configurado por sensibilidad de pantalla✓ Datos en caché mostrados con etiqueta de actualización✓ Errores sin conexión vs servidor clasificados✓ Escrituras en cola localmente con claves de idempotencia✓ Caché de Query persistido para arranque en frío (opcional)✓ Modo sin conexión forzada para pruebas de campo (opcional)
Esta pantalla es una plantilla - reemplaza fetchChecklist con tu API de dominio
Añade datos sin conexión relacional en SQLite cuando las listas superan la comodidad de AsyncStorage - ver expo-sqlite