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.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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.stringifyy 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 guardadottlMs: tiempo de vidaetagolastModified(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
lastSyncAty 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
| Necesidad | Solución | Notas |
|---|---|---|
| Tokens | Secure storage | No en AsyncStorage |
| Preferencias | AsyncStorage | Objeto único, defaults |
| Lista offline | Caché + metadatos TTL | stale-while-revalidate |
| Incremental | since/lastSyncAt | aplicar delta |
| Mutaciones offline | cola persistida | idempotencia, reintentos |
| Reconexión | NetInfo listener | syncQueue + 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”.