-
Choose the right client stack. Recommendation by use case:
| Use case | Client | Cache layer |
|---|
| Simple REST, few endpoints | fetch | React Query |
| Complex REST, interceptors | Axios | React Query |
| GraphQL | urql or Apollo Client | Built-in cache |
| Real-time GraphQL | urql with subscriptions | Built-in |
React Query (from the state-management skill) is the cache/state layer for REST. GraphQL clients have their own cache.
-
Create a typed API client. In lib/api.ts:
import * as SecureStore from "expo-secure-store";
const API_BASE = process.env.EXPO_PUBLIC_API_URL!;
async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await SecureStore.getItemAsync("auth_token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...authHeaders,
...options.headers,
},
});
if (res.status === 401) {
await SecureStore.deleteItemAsync("auth_token");
throw new Error("Session expired. Please sign in again.");
}
if (!res.ok) {
const error = await res.text();
throw new Error(`API error ${res.status}: ${error}`);
}
return res.json();
}
export const api = {
get: <T>(endpoint: string) => apiRequest<T>(endpoint),
post: <T>(endpoint: string, data: unknown) =>
apiRequest<T>(endpoint, {
method: "POST",
body: JSON.stringify(data),
}),
put: <T>(endpoint: string, data: unknown) =>
apiRequest<T>(endpoint, {
method: "PUT",
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string) =>
apiRequest<T>(endpoint, { method: "DELETE" }),
};
-
Use React Query for caching and state. Create typed query hooks:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
interface Todo {
id: string;
title: string;
completed: boolean;
}
export function useTodos() {
return useQuery({
queryKey: ["todos"],
queryFn: () => api.get<Todo[]>("/todos"),
staleTime: 1000 * 60 * 5,
});
}
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) =>
api.post<Todo>("/todos", { title }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
-
Optimistic updates. Update the UI before the server responds:
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
api.put<Todo>(`/todos/${todo.id}`, {
completed: !todo.completed,
}),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t,
),
);
return { previous };
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(["todos"], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
-
Retry with exponential backoff. Configure in the QueryClient:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000),
staleTime: 1000 * 60 * 5,
},
mutations: {
retry: 1,
},
},
});
-
Offline detection and queuing. Use @react-native-community/netinfo:
npx expo install @react-native-community/netinfo
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
React Query automatically pauses mutations when offline and retries when connectivity returns.
-
GraphQL with urql. Install:
npx expo install urql graphql @urql/exchange-auth
import { Client, cacheExchange, fetchExchange } from "urql";
import { authExchange } from "@urql/exchange-auth";
import * as SecureStore from "expo-secure-store";
const client = new Client({
url: process.env.EXPO_PUBLIC_GRAPHQL_URL!,
exchanges: [
cacheExchange,
authExchange(async (utils) => ({
addAuthToOperation: async (operation) => {
const token = await SecureStore.getItemAsync("auth_token");
if (!token) return operation;
return utils.appendHeaders(operation, {
Authorization: `Bearer ${token}`,
});
},
didAuthError: (error) =>
error.graphQLErrors.some(
(e) => e.extensions?.code === "UNAUTHORIZED",
),
refreshAuth: async () => {
await SecureStore.deleteItemAsync("auth_token");
},
})),
fetchExchange,
],
});