# Expo Networking - Detailed Guide
> This file contains the detailed procedure and reference material extracted from `SKILL.md` for focused loading. The root skill defines activation, examples, safety constraints, and limitations.
## References
Consult these resources as needed:
```
references/
expo-router-loaders.md Route-level data loading with Expo Router loaders (web, SDK 55+)
```
The snippets are separate integration examples: supply application types/components, React/Expo imports, verified API origins, schema parsers and an actual refresh adapter. A generic TypeScript return type does not validate JSON. Scope query keys to the authenticated tenant/user and clear private cached/persisted data on logout or identity changes. SecureStore requires a supported native platform; web needs a separately reviewed session strategy. Persistence does not make mutations a durable offline queue.
## Preferences
- Avoid axios, prefer expo/fetch
## Loading, empty, failure and saved content
Restore the separate screen states from the current [upstream fetching skill](https://github.com/expo/skills/blob/main/plugins/expo/skills/expo-data-fetching/SKILL.md): treat absent data separately from a resolved empty response, retain saved content during a failed refresh, and preserve an unsaved form after failed submission. Hydrate identity before private requests. These are application responsibilities, not states supplied by this guide.
## Common Issues & Solutions
### 1. Basic Fetch Usage
**Simple GET request**:
```tsx
const fetchUser = async (userId: string, signal?: AbortSignal) => {
const response = await fetch(`https://api.example.com/users/${encodeURIComponent(userId)}`, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
};
```
**POST request with body**:
```tsx
const createUser = async (userData: UserData) => {
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(userData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
return response.json();
};
```
---
### 2. React Query (TanStack Query)
**Setup**:
```tsx
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 2,
},
},
});
export default function RootLayout() {
return (
);
}
```
**Fetching data**:
```tsx
import { useQuery } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: string }) {
const { data, fetchStatus, error, refetch } = useQuery({
queryKey: ["user", userId],
queryFn: ({ signal }) => fetchUser(userId, signal),
});
if (data === undefined) {
if (error) return ;
if (fetchStatus === "paused") return ;
return ;
}
return <>
{error && }
{data === null ? : }
>;
}
```
**Mutations**:
```tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
const handleSubmit = (data: UserData) => {
if (!mutation.isPending) mutation.mutate(data);
};
return
;
}
```
---
### 3. Error Handling
**Comprehensive error handling**:
```tsx
class ApiError extends Error {
constructor(message: string, public status: number, public code?: string) {
super(message);
this.name = "ApiError";
}
}
const fetchWithErrorHandling = async (url: string, options?: RequestInit) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(
error.message || "Request failed",
response.status,
error.code
);
}
return response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (options?.signal?.aborted || !(error instanceof TypeError)) throw error;
// Fetch rejects network failures with TypeError; do not classify JSON parse errors as retryable.
throw new ApiError("Network error", 0, "NETWORK_ERROR");
}
};
```
**Retry logic**:
```tsx
const fetchWithRetry = async (
url: string,
options?: RequestInit,
retries = 3
) => {
if (!Number.isInteger(retries) || retries < 1 || retries > 5) throw new Error("Invalid attempt limit");
for (let i = 0; i < retries; i++) {
options?.signal?.throwIfAborted();
try {
return await fetchWithErrorHandling(url, options);
} catch (error) {
const method = (options?.method ?? "GET").toUpperCase();
const status = error instanceof ApiError ? error.status : -1;
const transient = status === 0 || status === 408 || status >= 500;
if (!["GET", "HEAD"].includes(method) || !transient ||
options?.signal?.aborted || i === retries - 1) throw error;
// Exponential backoff
await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
}
}
};
```
---
### 4. Authentication
**Token management**:
```tsx
import * as SecureStore from "expo-secure-store";
const TOKEN_KEY = "auth_token";
export const auth = {
getToken: () => SecureStore.getItemAsync(TOKEN_KEY),
setToken: (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token),
removeToken: () => SecureStore.deleteItemAsync(TOKEN_KEY),
};
// Authenticated fetch wrapper
const authFetch = async (url: string, options: RequestInit = {}) => {
const token = await auth.getToken();
const target = new URL(url);
if (target.origin !== "https://api.example.com" || target.username || target.password) {
throw new Error("Untrusted authenticated API destination");
}
const headers = new Headers(options.headers);
if (!token) throw new Error("Authentication required");
headers.set("Authorization", `Bearer ${token}`);
return fetch(target, { ...options, headers, redirect: "error" });
};
```
**Token refresh**:
```tsx
let isRefreshing = false;
let refreshPromise: Promise | null = null;
const getValidToken = async (): Promise => {
const token = await auth.getToken();
if (!token || isTokenExpired(token)) {
if (!isRefreshing) {
isRefreshing = true;
refreshPromise = refreshToken().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
}
return refreshPromise!;
}
return token;
};
```
---
### 5. Offline Support
**Check network status**:
```tsx
import NetInfo from "@react-native-community/netinfo";
// Hook for network status
function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
return NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected ?? true);
});
}, []);
return isOnline;
}
```
**Offline-first with React Query**:
```tsx
import { onlineManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";
// Sync React Query with network status
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(state.isConnected ?? true);
});
});
// Queries will pause when offline and resume when online
```
---
### 6. Environment Variables
**Using environment variables for API configuration**:
Expo supports environment variables with the `EXPO_PUBLIC_` prefix. These are inlined at build time and available in your JavaScript code.
```tsx
// .env
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_API_VERSION=v1
// Usage in code
const API_URL = process.env.EXPO_PUBLIC_API_URL;
const fetchUsers = async () => {
const response = await fetch(`${API_URL}/users`);
return response.json();
};
```
**Environment-specific configuration**:
```tsx
// .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000
// .env.production
EXPO_PUBLIC_API_URL=https://api.production.com
```
**Creating an API client with environment config**:
```tsx
// api/client.ts
const BASE_URL = process.env.EXPO_PUBLIC_API_URL;
if (!BASE_URL) {
throw new Error("EXPO_PUBLIC_API_URL is not defined");
}
export const apiClient = {
get: async (path: string): Promise => {
const response = await fetch(`${BASE_URL}${path}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
post: async (path: string, body: unknown): Promise => {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
},
};
```
**Important notes**:
- Only variables prefixed with `EXPO_PUBLIC_` are exposed to the client bundle
- Never put secrets (API keys with write access, database passwords) in `EXPO_PUBLIC_` variables—they're visible in the built app
- Environment variables are inlined at **build time**, not runtime
- Restart the dev server after changing `.env` files
- For server-side secrets in API routes, use variables without the `EXPO_PUBLIC_` prefix
**TypeScript support**:
```tsx
// types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_API_VERSION?: string;
}
}
}
export {};
```
---
### 7. Request Cancellation
**Cancel on unmount**:
```tsx
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((response) => response.json())
.then((data) => { if (!controller.signal.aborted) setData(data); })
.catch((error) => {
if (!controller.signal.aborted && error.name !== "AbortError") {
setError(error);
}
});
return () => controller.abort();
}, [url]);
```
**With React Query** (automatic):
```tsx
// Pass the query function signal to fetch; unmount/invalidation alone
// does not guarantee cancellation of the underlying network operation.
```
---
## Decision Tree
```
User asks about networking
|-- Route-level data loading (web, SDK 55+)?
| \-- Expo Router loaders — see references/expo-router-loaders.md
|
|-- Basic fetch?
| \-- Use fetch API with error handling
|
|-- Need caching/state management?
| |-- Complex app -> React Query (TanStack Query)
| \-- Simpler needs -> SWR or custom hooks
|
|-- Authentication?
| |-- Token storage -> expo-secure-store
| \-- Token refresh -> Implement refresh flow
|
|-- Error handling?
| |-- Network errors -> Check connectivity first
| |-- HTTP errors -> Parse response, throw typed errors
| \-- Retries -> Exponential backoff
|
|-- Offline support?
| |-- Check status -> NetInfo
| \-- Queue requests -> React Query persistence
|
|-- Environment/API config?
| |-- Client-side URLs -> EXPO_PUBLIC_ prefix in .env
| |-- Server secrets -> Non-prefixed env vars (API routes only)
| \-- Multiple environments -> .env.development, .env.production
|
\-- Performance?
|-- Caching -> React Query with staleTime
|-- Deduplication -> React Query handles this
\-- Cancellation -> AbortController or React Query
```
## Common Mistakes
**Wrong: No error handling**
```tsx
const data = await fetch(url).then((r) => r.json());
```
**Right: Check response status**
```tsx
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
```
**Wrong: Storing tokens in AsyncStorage**
```tsx
await AsyncStorage.setItem("token", token); // Not secure!
```
**Right: Use SecureStore for sensitive data**
```tsx
await SecureStore.setItemAsync("token", token);
```