Estructura del módulo api/
Árbol de archivos generados
NutriTrackPro/
api/
client.ts ← apiClient tipado
auth.ts ← tokens JWT + refresh
hooks/
useMeals.ts ← query + mutation
useDashboard.ts← polling 2 min
useNetworkStatus.ts
app/
_layout.tsx ← QueryClient + NetInfo
.env.example
types/env.d.ts
Variables de entorno
EXPO_PUBLIC_API_URL
https://api.nutritrackpro.app/v1
PUBLIC
EXPO_PUBLIC_API_VERSION
v1
PUBLIC
EXPO_PUBLIC_APP_ENV
production
PUBLIC
SUPABASE_SERVICE_ROLE_KEY
sk-***************
SECRETO
JWT_REFRESH_SECRET
***********************
SECRETO
⚠️ Solo variables EXPO_PUBLIC_* se incluyen en el bundle del cliente.
api/client.ts — Cliente HTTP tipado
api/client.ts
// api/client.ts — NutriTrack Pro
import { getValidToken } from "./auth";
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
if (!BASE_URL) throw new Error("EXPO_PUBLIC_API_URL no definida");
class ApiError extends Error {
constructor(public status: number, message: string, public code?: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await getValidToken();
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new ApiError(res.status, err.message ?? `HTTP ${res.status}`, err.code);
}
return res.json();
}
export const apiClient = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
delete: <T>(path: string) =>
request<T>(path, { method: "DELETE" }),
};
api/auth.ts — JWT + SecureStore + Auto-refresh
api/auth.ts
import * as SecureStore from "expo-secure-store";
const KEYS = { token: "nt_access", refresh: "nt_refresh" };
export const auth = {
getToken: () => SecureStore.getItemAsync(KEYS.token),
setToken: (t: string) => SecureStore.setItemAsync(KEYS.token, t),
getRefresh: () => SecureStore.getItemAsync(KEYS.refresh),
setRefresh: (t: string) => SecureStore.setItemAsync(KEYS.refresh, t),
clear: async () => {
await SecureStore.deleteItemAsync(KEYS.token);
await SecureStore.deleteItemAsync(KEYS.refresh);
},
};
const isExpired = (jwt: string): boolean => {
try {
const { exp } = JSON.parse(atob(jwt.split(".")[1]));
return Date.now() / 1000 > exp - 30; // 30 s de margen
} catch { return true; }
};
let _refreshPromise: Promise<string> | null = null;
export async function getValidToken(): Promise<string> {
const token = await auth.getToken();
if (token && !isExpired(token)) return token;
// Singleton: una sola llamada de refresh aunque haya N requests concurrentes
if (!_refreshPromise) {
_refreshPromise = doRefresh().finally(() => { _refreshPromise = null; });
}
return _refreshPromise;
}
async function doRefresh(): Promise<string> {
const refresh = await auth.getRefresh();
if (!refresh) throw new Error("SESSION_EXPIRED");
const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: refresh }),
});
if (!res.ok) { await auth.clear(); throw new Error("SESSION_EXPIRED"); }
const { accessToken, refreshToken } = await res.json();
await Promise.all([auth.setToken(accessToken), auth.setRefresh(refreshToken)]);
return accessToken;
}
Hooks React Query — Meals · Dashboard
hooks/useMeals.ts
useMeals.ts
import { useQuery, useMutation, useQueryClient }
from "@tanstack/react-query";
import { apiClient } from "../client";
export function useMeals(userId: string) {
return useQuery({
queryKey: ["meals", userId],
queryFn: () =>
apiClient.get<Meal[]>(`/users/${userId}/meals`),
staleTime: 1000 * 60 * 5, // 5 min
});
}
export function useLogMeal(userId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (meal: NewMeal) =>
apiClient.post<Meal>("/meals", meal),
onSuccess: () => {
// Invalidar query de meals + dashboard del día
qc.invalidateQueries({
queryKey: ["meals", userId],
});
qc.invalidateQueries({
queryKey: ["dashboard", userId],
});
},
});
}
hooks/useDashboard.ts (polling)
useDashboard.ts
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../client";
import { useNetworkStatus } from "./useNetworkStatus";
export function useDashboard(userId: string) {
const isOnline = useNetworkStatus();
return useQuery({
queryKey: ["dashboard", userId],
queryFn: () =>
apiClient.get<DashboardData>
(`/dashboard/${userId}/today`),
// Polling cada 2 min solo si hay conexión
refetchInterval: isOnline
? 1000 * 60 * 2
: false,
// Mantener datos cacheados offline
staleTime: 1000 * 60 * 2,
gcTime: 1000 * 60 * 60, // 1 h en caché
});
}
app/_layout.tsx · useNetworkStatus + banner offline
app/_layout.tsx
_layout.tsx
import { QueryClient, QueryClientProvider, onlineManager }
from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";
import { Stack } from "expo-router";
import { useEffect } from "react";
// Sincronizar React Query con el estado de red del dispositivo
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((s) =>
setOnline(s.isConnected ?? true))
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
retryDelay: (n) => Math.pow(2, n) * 1000,
},
},
});
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
hooks/useNetworkStatus.ts
useNetworkStatus.ts
import NetInfo from "@react-native-community/netinfo";
import { useEffect, useState } from "react";
export function useNetworkStatus() {
const [isOnline, setIsOnline] =
useState<boolean>(true);
useEffect(() => {
const unsub = NetInfo.addEventListener((s) =>
setIsOnline(s.isConnected ?? true)
);
return unsub;
}, []);
return isOnline;
}
Vista previa del banner:
Conectado — datos en tiempo real
Sin conexión — mostrando caché
Errores comunes evitados
✗ Incorrecto — sin manejo de errores
// Sin verificar status HTTP
const data = await fetch(url)
.then((r) => r.json());
// Token en AsyncStorage (inseguro)
await AsyncStorage.setItem("token", t);
// URL hardcodeada
fetch("https://api.nutritrackpro.app/v1/users");
✓ Correcto — patrón NutriTrack Pro
// Verificar status + error tipado
const res = await fetch(url);
if (!res.ok) throw new ApiError(...);
// Token en SecureStore (encriptado)
await SecureStore.setItemAsync("nt_access", t);
// URL desde EXPO_PUBLIC_API_URL
apiClient.get("/users");
✗ Incorrecto — refresh race condition
// Sin singleton → múltiples refreshes
const newToken = await refreshToken();
// Sin soporte offline
refetchInterval: 120000, // siempre activo
✓ Correcto — singleton + offline-aware
// Promise singleton, N callers 1 refresh
if (!_refreshPromise) {
_refreshPromise = doRefresh()...
}
// Polling solo con conexión activa
refetchInterval: isOnline ? 120000 : false,
Árbol de decisión · Checklist de implementación
Árbol de decisión de networking
¿Qué necesitas?
├─ Carga de ruta (web SDK 55+)?
│ └─ Expo Router loaders → useLoaderData
├─ Fetch simple, sin estado?
│ └─ fetch + error handling → apiClient.get()
├─ ¿Cache + estado de carga?
│ ├─ App compleja → React Query
│ └─ App simple → SWR / custom hook
├─ ¿Auth + tokens?
│ ├─ Almacenamiento → expo-secure-store
│ └─ Renovación → getValidToken() singleton
├─ ¿Soporte offline?
│ ├─ Estado → NetInfo + useNetworkStatus
│ └─ Caché → React Query gcTime largo
├─ ¿Config por entorno?
│ ├─ URLs → EXPO_PUBLIC_API_URL en .env
│ └─ Secretos → vars sin prefijo (solo server)
└─ ¿Rendimiento?
├─ Deduplicación → React Query automático
└─ Cancelación → AbortController / RQ
Checklist de implementación NutriTrack Pro
-
✅
apiClient tipado
get/post/delete con genéricos TypeScript y error clase ApiError
-
✅
JWT en SecureStore
access + refresh tokens encriptados en el keychain del dispositivo
-
✅
Auto-refresh singleton
Una sola llamada de renovación aunque haya N requests concurrentes
-
✅
React Query configurado
QueryClientProvider en root, staleTime 5 min, retry exponencial
-
✅
useMeals + useLogMeal
Query con caché + mutation con invalidación automática del dashboard
-
✅
useDashboard con polling
Refresco cada 2 min suspendido automáticamente si offline
-
✅
NetInfo bridge
onlineManager sincronizado con estado real del dispositivo
-
✅
Variables de entorno
EXPO_PUBLIC_* para cliente; secretos solo en server routes