Files
minimax-skills/skills/react-native-dev/references/networking.md
2026-03-26 20:32:52 +08:00

9.6 KiB

Networking Reference

Building a robust data layer for Expo apps: API clients, server state, authentication, and server-side routes.

API Client

Setup

Create a thin wrapper around fetch (or expo/fetch on SDK 53+) rather than installing axios. Build a generic request<T>(path, init?) function that:

  • Prepends process.env.EXPO_PUBLIC_API_URL to the path
  • Defaults Content-Type: application/json, merges caller headers
  • On !res.ok, throws an error with status and body attached (use Object.assign) so callers can branch on HTTP status
  • Returns res.json() as Promise<T>

Then export convenience methods: api.get<T>(path), api.post<T>(path, body), etc., each delegating to request() with the appropriate method and JSON.stringify(body).

Typed Errors

Distinguish network-level failures (no connectivity, DNS) from HTTP-level errors (4xx/5xx). The wrapper above attaches status and body to thrown errors so callers can branch:

try {
  await api.post("/tasks", newTask);
} catch (err: any) {
  if (err.status === 409) {
    Alert.alert("Duplicate", "A task with that title already exists.");
  } else if (err.status === undefined) {
    Alert.alert("Offline", "Check your connection and try again.");
  }
}

Server State (React Query)

Provider

// app/_layout.tsx
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";

const qc = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },
});

export default function RootLayout() {
  return (
    <QueryClientProvider client={qc}>
      <Stack />
    </QueryClientProvider>
  );
}

Reading Data

function TaskList({ projectId }: { projectId: string }) {
  const { data: tasks, isPending, error } = useQuery({
    queryKey: ["projects", projectId, "tasks"],
    queryFn: () => api.get<Task[]>(`/projects/${projectId}/tasks`),
  });

  if (isPending) return <ActivityIndicator />;
  if (error) return <ErrorBanner message={error.message} />;

  return (
    <FlashList
      data={tasks}
      renderItem={({ item }) => <TaskRow task={item} />}
      estimatedItemSize={56}
    />
  );
}

Writing Data

function useCompleteTask(projectId: string) {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: (taskId: string) => api.put(`/tasks/${taskId}`, { done: true }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
  });
}

Optimistic Updates

For snappy UIs, update the cache before the server confirms:

const toggle = useMutation({
  mutationFn: (task: Task) => api.put(`/tasks/${task.id}`, { done: !task.done }),
  onMutate: async (task) => {
    await qc.cancelQueries({ queryKey });
    const prev = qc.getQueryData<Task[]>(queryKey);
    qc.setQueryData<Task[]>(queryKey, (old) =>
      old?.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)),
    );
    return { prev };
  },
  onError: (_err, _task, ctx) => qc.setQueryData(queryKey, ctx?.prev),
  onSettled: () => qc.invalidateQueries({ queryKey }),
});

Authentication

Storing Credentials

Use expo-secure-store for any token or secret. AsyncStorage is unencrypted and readable on rooted/jailbroken devices.

import * as SecureStore from "expo-secure-store";

const TOKEN_KEY = "session_token";

export async function saveToken(token: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function getToken() {
  return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function clearToken() {
  await SecureStore.deleteItemAsync(TOKEN_KEY);
}

Injecting Auth Headers

Extend the API client to attach the token automatically:

export async function authRequest<T>(path: string, init?: RequestInit): Promise<T> {
  const token = await getToken();
  return request<T>(path, {
    ...init,
    headers: { ...init?.headers, ...(token && { Authorization: `Bearer ${token}` }) },
  });
}

Refreshing Expired Tokens

Avoid stampeding refresh calls when multiple requests discover the token is expired simultaneously. Hold a single in-flight refresh promise and let all waiters share it:

let pending: Promise<string> | null = null;

async function getFreshToken(): Promise<string> {
  if (pending) return pending;

  pending = (async () => {
    const refresh = await SecureStore.getItemAsync("refresh_token");
    const { accessToken } = await api.post<{ accessToken: string }>("/auth/refresh", { refresh });
    await saveToken(accessToken);
    return accessToken;
  })();

  try {
    return await pending;
  } finally {
    pending = null;
  }
}

Environment Variables

# .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000

# .env.production
EXPO_PUBLIC_API_URL=https://api.production.example.com
  • The EXPO_PUBLIC_ prefix makes a variable available in client JS (inlined at build time)
  • Variables without the prefix are only accessible in server-side API routes
  • Never expose database credentials or write-capable API keys via EXPO_PUBLIC_
  • Restart the dev server after editing .env files

Type the variables for autocomplete:

// env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      EXPO_PUBLIC_API_URL: string;
    }
  }
}
export {};

Offline & Connectivity

Track device connectivity with @react-native-community/netinfo and wire it into React Query so queries automatically pause offline and resume on reconnect:

// app/_layout.tsx (once, at startup)
import { onlineManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
);

To show an in-app banner, subscribe separately:

function useOnline() {
  const [online, setOnline] = useState(true);
  useEffect(() => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)), []);
  return online;
}

Request Lifecycle

Cancellation

When a component unmounts mid-request, abort the in-flight fetch to avoid setting state on an unmounted component:

useEffect(() => {
  const ac = new AbortController();
  api.get(`/projects/${id}`, { signal: ac.signal }).then(setProject);
  return () => ac.abort();
}, [id]);

React Query handles cancellation automatically for queries — no extra work needed.

Retries

React Query retries failed queries by default (3 attempts with exponential backoff). For mutations or non-React-Query code, implement manually:

async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
    }
  }
  throw new Error("unreachable");
}

Server-Side API Routes

Expo Router supports +api.ts files that run on the server (deployed to EAS Hosting / Cloudflare Workers). Use them when you need to keep secrets server-side, proxy third-party APIs, or run database queries.

Conventions

app/
  api/
    health+api.ts              → GET /api/health
    projects+api.ts            → GET|POST /api/projects
    projects/[id]+api.ts       → GET|PUT|DELETE /api/projects/:id
    webhooks/payments+api.ts   → POST /api/webhooks/payments

Export a named function per HTTP method:

// app/api/projects+api.ts
export async function GET(req: Request) {
  const url = new URL(req.url);
  const cursor = url.searchParams.get("cursor");
  const rows = await db.query("SELECT * FROM projects WHERE id > ? LIMIT 20", [cursor ?? 0]);
  return Response.json(rows);
}

export async function POST(req: Request) {
  const { name, description } = await req.json();
  const [row] = await db.insert(projectsTable).values({ name, description }).returning();
  return Response.json(row, { status: 201 });
}

Secrets

Variables without the EXPO_PUBLIC_ prefix are server-only:

// app/api/ai/summarize+api.ts
const LLM_KEY = process.env.LLM_API_KEY; // never reaches the client bundle

export async function POST(req: Request) {
  const { text } = await req.json();
  const res = await fetch("https://api.llm.example.com/v1/chat", {
    method: "POST",
    headers: { Authorization: `Bearer ${LLM_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ messages: [{ role: "user", content: `Summarize: ${text}` }] }),
  });
  return Response.json(await res.json());
}

Webhooks

// app/api/webhooks/payments+api.ts — verify signature, then handle event
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WH_SECRET!);
if (event.type === "checkout.session.completed") {
  await activateSubscription(event.data.object.customer as string);
}

Protecting Routes

// lib/require-auth.ts — extract and verify JWT from Authorization header, throw Response on failure
export async function requireAuth(req: Request): Promise<string> {
  const header = req.headers.get("Authorization");
  if (!header?.startsWith("Bearer "))
    throw Response.json({ error: "unauthorized" }, { status: 401 });
  const uid = await verifyJwt(header.slice(7));
  if (!uid) throw Response.json({ error: "invalid token" }, { status: 401 });
  return uid;
}
// Usage in route: const uid = await requireAuth(req);

Deploying

npx expo export
eas deploy            # preview
eas deploy --prod     # production

# Set server-only secrets
eas env:create --name LLM_API_KEY --value "sk-..." --environment production

API routes run on Cloudflare Workers — no fs module, 30 s CPU limit, use Web APIs (fetch, crypto.subtle) instead of Node built-ins.