Persistencia local, caché y sincronización offline en React Native

Capítulo 6

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

Qué persistir y por qué: preferencias, sesión y caché

En una app móvil real, el usuario espera que ciertas cosas “se queden” aunque cierre la app o pierda conexión: su sesión (token), sus preferencias (tema, idioma, filtros) y datos recientes (listas, detalles) para navegar offline. Persistencia local es guardar información en el dispositivo; caché es guardar respuestas/datos para reutilizarlos; sincronización offline es permitir cambios sin conexión y reconciliarlos cuando vuelve Internet.

Una estrategia práctica separa los datos por tipo y criticidad:

  • Preferencias: pequeñas, no sensibles, lectura frecuente. Ej.: tema, idioma, flags de onboarding.
  • Sesión: sensible. Ej.: access token/refresh token, expiración, usuario actual.
  • Caché de API: potencialmente grande. Ej.: lista de productos, mensajes, catálogo. Debe tener expiración e invalidación.
  • Cola offline: operaciones pendientes (crear/editar/eliminar) para sincronizar luego.

Herramientas recomendadas en React Native

AsyncStorage (simple) vs almacenamiento cifrado (sesión)

@react-native-async-storage/async-storage es clave-valor, fácil y suficiente para preferencias y metadatos de caché. Para tokens, usa almacenamiento seguro/cifrado (por ejemplo, react-native-keychain o expo-secure-store si estás en Expo). Regla práctica: tokens en secure storage, preferencias en AsyncStorage.

Base de datos local para caché y offline

Para listas y datos estructurados, una base de datos local (SQLite, WatermelonDB, Realm) suele ser mejor que guardar JSON enorme en AsyncStorage. Aun así, puedes empezar con AsyncStorage si el volumen es pequeño y migrar después. En este capítulo verás un flujo con AsyncStorage para entender el patrón y una nota de cómo escalar.

Detección de conectividad

Para sincronización offline necesitas saber cuándo hay conexión. La librería típica es @react-native-community/netinfo, que permite reaccionar a cambios de red y disparar sincronización.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Diseño de una capa de persistencia: claves, versiones y serialización

Evita “guardar cosas sueltas” sin orden. Define un esquema de claves y versiona tu almacenamiento para poder migrar.

  • Prefijos: prefs:, cache:, meta:, queue:
  • Versión: storage:schemaVersion
  • Serialización: guarda JSON con JSON.stringify y valida al leer.
export const StorageKeys = {  schemaVersion: 'storage:schemaVersion',  prefs: 'prefs:user',  sessionMeta: 'session:meta',  cacheList: (name) => `cache:list:${name}`,  cacheMeta: (name) => `meta:cache:${name}`,  queue: 'queue:mutations',};

Estrategias de caché: expiración, invalidación y normalización

Expiración (TTL) y stale-while-revalidate

TTL (time-to-live) define cuánto tiempo consideras válida una respuesta. Un patrón muy usado para UX es stale-while-revalidate: muestras inmediatamente lo cacheado (aunque esté “algo viejo”) y en paralelo intentas actualizar; si llega nuevo, actualizas UI.

Metadatos típicos por caché:

  • savedAt: timestamp de guardado
  • ttlMs: tiempo de vida
  • etag o lastModified (si tu API lo soporta)
  • version: para invalidar por cambios de esquema
type CacheMeta = {  savedAt: number;  ttlMs: number;  version: number;  etag?: string;};

Invalidación por eventos

Además del TTL, invalida cuando ocurre algo que cambia los datos:

  • El usuario hace logout: borrar sesión y cachés privadas.
  • El usuario cambia un filtro global: invalidar listas dependientes.
  • Se completa una mutación (crear/editar/eliminar): actualizar caché local (optimista) e invalidar/refresh del servidor.

Normalización para consistencia

Si guardas listas completas como JSON, puedes duplicar entidades (el mismo ítem aparece en varias listas) y crear inconsistencias. Normalizar significa guardar:

  • Un diccionario entitiesById
  • Listas como arrays de IDs

Esto facilita actualizaciones incrementales y reduce duplicación.

type Entity = { id: string; title: string; updatedAt: string; };type NormalizedStore = {  entities: Record<string, Entity>;  lists: Record<string, { ids: string[]; updatedAt?: string }>;};

Flujo práctico completo

Objetivo: (1) guardar tokens de sesión de forma segura, (2) recordar ajustes del usuario, (3) mantener una lista disponible offline con actualización incremental y sincronización de cambios cuando vuelve la conexión.

Paso 1: Guardar tokens (sesión) en almacenamiento seguro

Guarda accessToken y refreshToken en secure storage. Mantén en AsyncStorage solo metadatos no sensibles (por ejemplo, fecha de expiración) si lo necesitas.

// Ejemplo conceptual (API varía según librería elegida)import * as Keychain from 'react-native-keychain';export async function saveSessionTokens(accessToken: string, refreshToken: string) {  await Keychain.setGenericPassword('session', JSON.stringify({ accessToken, refreshToken }));}export async function loadSessionTokens(): Promise<{accessToken: string; refreshToken: string} | null> {  const creds = await Keychain.getGenericPassword();  if (!creds) return null;  try {    return JSON.parse(creds.password);  } catch {    return null;  }}export async function clearSessionTokens() {  await Keychain.resetGenericPassword();}

Buenas prácticas:

  • No guardes tokens en logs.
  • Si el refresh falla, limpia sesión y cachés privadas.
  • Considera rotación de tokens y expiración.

Paso 2: Recordar ajustes del usuario (preferencias) con AsyncStorage

Preferencias típicas: tema, idioma, si ya vio un tutorial, filtros por defecto. Usa un objeto único para reducir lecturas/escrituras.

import AsyncStorage from '@react-native-async-storage/async-storage';import { StorageKeys } from './storageKeys';type UserPrefs = {  theme: 'light' | 'dark' | 'system';  language: 'es' | 'en';  rememberFilters: boolean;};const defaultPrefs: UserPrefs = { theme: 'system', language: 'es', rememberFilters: true };export async function loadPrefs(): Promise<UserPrefs> {  const raw = await AsyncStorage.getItem(StorageKeys.prefs);  if (!raw) return defaultPrefs;  try {    return { ...defaultPrefs, ...JSON.parse(raw) };  } catch {    return defaultPrefs;  }}export async function savePrefs(prefs: UserPrefs) {  await AsyncStorage.setItem(StorageKeys.prefs, JSON.stringify(prefs));}

Consejo de UX: carga preferencias lo antes posible (splash o bootstrap) para evitar “parpadeos” de tema/idioma.

Paso 3: Caché de una lista offline con TTL y stale-while-revalidate

Implementaremos una caché para una lista (por ejemplo, “artículos” o “productos”). Guardaremos: datos normalizados y metadatos de expiración. Flujo:

  • Al abrir la pantalla: leer caché y mostrarla inmediatamente si existe.
  • Si hay conexión: revalidar en segundo plano; si hay cambios, actualizar caché y UI.
  • Si no hay conexión: mantener la lista disponible offline.
import AsyncStorage from '@react-native-async-storage/async-storage';import { StorageKeys } from './storageKeys';type CacheMeta = { savedAt: number; ttlMs: number; version: number; };const CACHE_VERSION = 1;const DEFAULT_TTL = 5 * 60 * 1000; // 5 minexport async function saveListCache(listName: string, data: unknown, ttlMs = DEFAULT_TTL) {  const meta: CacheMeta = { savedAt: Date.now(), ttlMs, version: CACHE_VERSION };  await AsyncStorage.multiSet([    [StorageKeys.cacheList(listName), JSON.stringify(data)],    [StorageKeys.cacheMeta(listName), JSON.stringify(meta)],  ]);}export async function loadListCache<T>(listName: string): Promise<{data: T | null; isExpired: boolean}> {  const [[, rawData], [, rawMeta]] = await AsyncStorage.multiGet([    StorageKeys.cacheList(listName),    StorageKeys.cacheMeta(listName),  ]);  if (!rawData || !rawMeta) return { data: null, isExpired: true };  try {    const meta = JSON.parse(rawMeta) as CacheMeta;    if (meta.version !== CACHE_VERSION) return { data: null, isExpired: true };    const isExpired = Date.now() - meta.savedAt > meta.ttlMs;    return { data: JSON.parse(rawData) as T, isExpired };  } catch {    return { data: null, isExpired: true };  }}

Paso 4: Actualización incremental (delta sync) usando marcas de tiempo

Para evitar descargar toda la lista cada vez, usa actualización incremental. Dos enfoques comunes:

  • Cursor/offset: útil para paginación, menos ideal para “cambios desde la última vez”.
  • Since (marca temporal o versión): la API devuelve elementos cambiados desde lastSyncAt y también IDs eliminados.

Modelo recomendado de respuesta incremental:

type DeltaResponse<T> = {  updated: T[];  deletedIds: string[];  serverTime: string;};

Persistimos lastSyncAt por lista y aplicamos el delta sobre la caché normalizada.

type Entity = { id: string; title: string; updatedAt: string; };type Normalized = { entities: Record<string, Entity>; ids: string[]; lastSyncAt?: string; };function applyDelta(state: Normalized, delta: DeltaResponse<Entity>): Normalized {  const entities = { ...state.entities };  for (const item of delta.updated) entities[item.id] = item;  for (const id of delta.deletedIds) delete entities[id];  const idsSet = new Set(state.ids);  for (const item of delta.updated) idsSet.add(item.id);  for (const id of delta.deletedIds) idsSet.delete(id);  const ids = Array.from(idsSet);  return { entities, ids, lastSyncAt: delta.serverTime };}

Nota: el orden de la lista (por fecha, ranking, etc.) debe definirse. Si el orden depende del servidor, conviene que el delta incluya el orden o que periódicamente hagas un refresh completo. Si el orden es por updatedAt, puedes reordenar localmente.

Paso 5: Cola offline de mutaciones (crear/editar/eliminar)

Para permitir cambios offline, guarda una cola de operaciones. Cada operación debe ser idempotente o tener un identificador para evitar duplicados al reintentar.

type Mutation = {  id: string; // uuid  type: 'create' | 'update' | 'delete';  entity: 'item';  payload: any;  createdAt: number;  retries: number;};async function loadQueue(): Promise<Mutation[]> {  const raw = await AsyncStorage.getItem(StorageKeys.queue);  if (!raw) return [];  try { return JSON.parse(raw); } catch { return []; }}async function saveQueue(queue: Mutation[]) {  await AsyncStorage.setItem(StorageKeys.queue, JSON.stringify(queue));}export async function enqueueMutation(m: Mutation) {  const queue = await loadQueue();  queue.push(m);  await saveQueue(queue);}

UX y consistencia: aplica actualización optimista sobre la caché local para que el usuario vea el cambio inmediatamente. Marca visualmente elementos “pendientes” si es relevante (por ejemplo, un estado syncStatus: 'pending').

Paso 6: Sincronizar cuando vuelve la conexión

Cuando el dispositivo recupera conectividad, procesa la cola en orden. Reglas:

  • Procesa secuencialmente para evitar conflictos simples.
  • Implementa reintentos con backoff y límite.
  • Si una mutación falla por validación (400), no reintentes: marca como error y pide acción del usuario.
  • Si falla por autenticación (401), intenta refresh token; si no, logout.
import NetInfo from '@react-native-community/netinfo';function sleep(ms: number) { return new Promise(res => setTimeout(res, ms));}async function sendMutationToServer(m: Mutation) {  // Aquí llamarías a tu API según m.type  // Debe ser idempotente si es posible (por ejemplo, usando m.id como idempotency key)}export async function syncQueue() {  let queue = await loadQueue();  const remaining: Mutation[] = [];  for (const m of queue) {    try {      await sendMutationToServer(m);    } catch (e: any) {      const status = e?.status;      if (status === 400) {        // Error no reintentable: podrías persistirlo en otra lista de errores        continue;      }      const retries = (m.retries ?? 0) + 1;      if (retries <= 5) {        remaining.push({ ...m, retries });        await sleep(300 * retries);      }      // si excede reintentos, lo dejas en remaining o lo mueves a errores    }  }  await saveQueue(remaining);}export function startNetworkListener() {  return NetInfo.addEventListener(state => {    if (state.isConnected) {      syncQueue();    }  });}

Después de sincronizar mutaciones, ejecuta una revalidación incremental (Paso 4) para asegurarte de que la caché local refleja el estado final del servidor.

Conflictos y consistencia: qué hacer cuando el servidor y el cliente difieren

Estrategias de resolución

  • Last write wins: simple, pero puede pisar cambios. Útil para preferencias.
  • Versión/ETag: el servidor rechaza si la versión cambió (409 Conflict). El cliente debe refrescar y pedir al usuario resolver.
  • Merge por campos: si el dominio lo permite (por ejemplo, notas), puedes fusionar.

Para listas offline típicas, una estrategia razonable es: mutaciones con updatedAt y control de versión; si hay 409, refrescar entidad, mostrar aviso y permitir reintentar.

Consistencia percibida (UX)

  • Muestra el contenido cacheado rápido y un indicador sutil de “actualizando”.
  • Si no hay conexión, muestra estado “sin conexión” pero no bloquees la navegación por datos cacheados.
  • En acciones offline, confirma la acción localmente y muestra estado “pendiente de sincronización”.
  • Evita spinners infinitos: usa timeouts y fallback a caché.

Checklist de implementación

NecesidadSoluciónNotas
TokensSecure storageNo en AsyncStorage
PreferenciasAsyncStorageObjeto único, defaults
Lista offlineCaché + metadatos TTLstale-while-revalidate
Incrementalsince/lastSyncAtaplicar delta
Mutaciones offlinecola persistidaidempotencia, reintentos
ReconexiónNetInfo listenersyncQueue + revalidación

Cuándo migrar a una base de datos local

Si tu caché crece (miles de registros), necesitas consultas (filtros, orden, joins) o quieres sincronización avanzada, considera SQLite/Realm/WatermelonDB. El patrón general se mantiene: metadatos de expiración, normalización, cola de mutaciones y reconciliación al reconectar; solo cambia el motor de almacenamiento y las operaciones pasan de “leer/escribir JSON” a “consultas y transacciones”.

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la estrategia más adecuada para manejar persistencia local en una app React Native que necesita recordar preferencias y mantener la sesión segura?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Las preferencias suelen ser datos pequeños y no sensibles, por lo que AsyncStorage es suficiente. En cambio, los tokens de sesión son sensibles y deben guardarse en almacenamiento seguro/cifrado para reducir riesgos.

Siguiente capítulo

Formularios, validación y experiencia de usuario en React Native

Arrow Right Icon
Portada de libro electrónico gratuitaReact Native desde Cero a App Profesional
50%

React Native desde Cero a App Profesional

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.