ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Sesión y almacenamiento seguro

template-expo
último cambio 2026-09-25

La sesión es el único estado global de la app. Vive en context/auth-context.tsx y se consume con useAuth().

const { user, accessToken, isLoading, login, logout } = useAuth();

Qué se persiste, y dónde

En expo-secure-store, con claves centralizadas en services/secure-storage.ts y prefijadas (petid.accessToken, petid.refreshToken, petid.userId…). Se guardan los dos tokens y los datos mínimos de identidad, para poder pintar la pantalla sin esperar a la red al abrir la app.

Nunca se guarda ahí nada que la api pueda dar: el perfil completo, las mascotas o los ajustes se piden cada vez.

Dos detalles del módulo de almacenamiento:

El arranque

Un efecto único al montar AuthProvider lee todo de golpe con Promise.all, y mientras isLoading está en true. El layout raíz no pinta nada hasta que la sesión, las fuentes y el i18n están listos, y solo entonces esconde el splash:

const ready = fontsLoaded && !isLoading && i18nReady;
if (!ready) return null;

Así se evita el parpadeo de ver la pantalla de login un instante antes de restaurar la sesión.

El puente hacia apiFetch

Cada vez que los tokens cambian —login, logout, restauración— el contexto los empuja a las variables de módulo del cliente HTTP:

setApiAccessToken(success.accessToken);
setApiRefreshToken(success.refreshToken ?? null);

Y en el otro sentido, el contexto registra un handler para que el cliente pueda devolver la app al estado no autenticado cuando un refresh falla:

setSessionExpiredHandler(() => { clearSession(); });

Ese es el único camino por el que una sesión muere sola. No hay temporizadores ni comprobaciones periódicas: se descubre al fallar una petición.

Login en dos pasos

login() no siempre deja la sesión lista, porque puede haber 2FA. Lo devuelve en el tipo, en vez de lanzar o de devolver un booleano ambiguo:

export type LoginOutcome =
  | { mfaRequired: false }
  | { mfaRequired: true; challengeToken: string };

La pantalla mira mfaRequired y navega al reto si hace falta. Los dos caminos —login directo y completeMfaLogin()— acaban en la misma función privada completeSession(), así que la sesión se persiste en un solo sitio.

Si añades un tercer camino de entrada, hazlo terminar ahí también.

El logout hace más de lo que parece

await authApi.logout().catch(() => undefined);
if (deviceToken) {
  await unregisterDevice(deviceToken).catch(() => undefined);
  await deleteSecureItem(SecureStorageKeys.deviceToken);
}
await clearSession();

Tres cosas, y en este orden: avisar al servidor, dar de baja el token de push —si no, el dispositivo seguiría recibiendo avisos de una cuenta de la que ya salió— y limpiar lo local.

Los dos .catch(() => undefined) son deliberados: si la red falla, el logout local tiene que ocurrir igual. Lo contrario dejaría al usuario atrapado dentro de la app.