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

Hablar con la api: apiFetch

template-expo
último cambio 2026-09-25

Todas las llamadas salen por una función, apiFetch() en services/api-client.ts. Pone las cabeceras, renueva el token cuando toca y normaliza los errores. Ningún componente llama a fetch por su cuenta.

Los tokens no viven en React

Están en variables de módulo:

let currentAccessToken: string | null = null;
let currentRefreshToken: string | null = null;

Y el contexto de sesión los empuja ahí con setApiAccessToken() cada vez que cambian. Puede chocar viniendo de React, pero es lo que permite que apiFetch sea una función suelta, llamable desde un servicio o un hook, sin necesitar un componente ni un useContext.

La consecuencia a tener presente: el token del módulo y el del estado de React se actualizan por caminos distintos. Por eso los hooks de datos esperan al accessToken del contexto antes de lanzar nada — ver useFetch o usePaginatedFetch.

Un único refresh en vuelo, compartido

Cuando una petición recibe un 401, apiFetch intenta renovar el token. Si hay tres peticiones en vuelo, las tres comparten la misma promesa:

if (!refreshPromise) {
  refreshPromise = (async () => { /* ... */ })().finally(() => {
    refreshPromise = null;
  });
}
return refreshPromise;

Esto no es una optimización. El motivo está comentado en el código: el backend rota el refresh token en cada uso y revoca la familia entera de sesiones si se le presenta uno caducado dos veces. Dos refreshes en paralelo usarían el mismo token dos veces y cerrarían la sesión del usuario.

Si alguna vez reescribes esta parte, esa es la invariante que hay que conservar.

Las rutas /auth/* no disparan refresh

if (response.status === 401 && !path.startsWith('/auth/')) {

Un 401 en login, logout o en el propio refresh no significa “token caducado”: significa credenciales incorrectas o sesión ya muerta. Intentar renovar ahí provocaría un bucle.

Si el refresh falla, se avisa al contexto con el handler que registró (sessionExpiredHandler) y la app vuelve al estado no autenticado. No se reintenta nada más: a esas alturas la sesión ya no existe en el servidor.

La cabecera que cambia la respuesta del backend

headers.set('X-Client-Type', 'mobile');

Con ella, el backend devuelve el refresh token en el cuerpo; sin ella lo manda en una cookie httpOnly, que un cliente nativo no puede leer. Se manda en todas las peticiones, y también en la de refresh, que construye sus cabeceras aparte.

Errores y respuestas vacías

Los servicios por encima

services/ tiene un fichero por recurso (pets-api.ts, settings-api.ts…) y cada uno son envoltorios finos sobre apiFetch que solo aportan la ruta y los tipos:

export function listPets(page: number, limit: number) {
  return apiFetch<PaginatedResponse<Pet>>(`/pets?page=${page}&limit=${limit}`);
}

Los tipos compartidos viven en services/api-types.ts. Un servicio nuevo se crea cuando aparece un recurso nuevo de la api, no cuando aparece una pantalla nueva.

Descargar un fichero no pasa por aquí

apiFetch sirve para JSON. Un fichero que hay que guardar —un ZIP, un PDF— se sale de este camino, y conviene saber por qué antes de intentar forzarlo.

En el servidor esa descarga es un GET con un token en la query, no una llamada autenticada por cabecera, y la página del lado API explica el porqué. Desde aquí lo que importa es la consecuencia: hace falta un paso previo que pida el token con apiFetch, y la descarga en sí la hace otro.

const { token } = await requestDataExport(currentPassword);
await WebBrowser.openBrowserAsync(dataExportDownloadUrl(token));

Se abre el navegador del sistema y no se descarga dentro de la app, y es una decisión de coste, no de elegancia: expo-file-system es código nativo y añadirlo obliga a un rebuild de EAS, mientras que expo-web-browser ya está y deja el fichero en la carpeta de descargas del móvil, que es donde alguien espera encontrarlo.

Lo que esto obliga a hacer en la pantalla

Tres cosas, y las tres se olvidan la primera vez:

El spinner del botón, por tanto, cubre sólo la llamada que pide el token — unas décimas de segundo—. Del rato en que el servidor empaqueta no informa nadie desde la app, y si algún día hiciera falta una barra de verdad, expo-file-system sólo daría bytes y no porcentaje: un fichero que se streamea no lleva Content-Length.