📡

NutriTrack Pro — Capa de Networking Expo/React Native

Módulo completo: fetch seguro · React Query · auth JWT · offline support · entornos

Expo SDK 55 React Query v5 TypeScript JWT + SecureStore Offline-First

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