An API Layer Worth Having
One client module: base URL per environment, the auth header in a single place, failures that throw, and typed responses instead of any.
async function request<T>(path: string, init?: RequestInit): Promise<T>Scatter fetch calls across twenty screens and you get twenty copies of the base URL, twenty places that can forget the auth header, and no shared idea of what a failure is. One module fixes all three. Everything else imports functions from it and never writes a URL.
What the module owns
The base URL comes from an environment variable. Expo inlines any variable prefixed EXPO_PUBLIC_ into the bundle at build time, so a development build can point at your machine and a release build at production with no code change. The token is read from secure storage on each request and attached as a header. Non-2xx responses throw a typed error carrying the status, so a screen can tell 401 from 500. A generic request<T> gives each caller the shape it expects.
Typed, not asserted
A cast such as as Note[] is a promise to the compiler, not a check. It is a reasonable default, but it stays an assumption — the server can rename a field and nothing fails until a screen renders undefined. Parsing the response with a schema validator turns that into a clear error at the boundary.
What trips people up
EXPO_PUBLIC_ values are readable in any shipped build; they are configuration, not secrets. Join paths carefully — a base ending in / plus a path starting with / gives a double slash some servers reject. And never set Content-Type yourself when the body is FormData.
Example
// api/client.ts
import * as SecureStore from 'expo-secure-store';
// Set EXPO_PUBLIC_API_URL per build; it is inlined at build time, not read at runtime.
const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.1.10:3000';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly body: string
) {
super(message);
this.name = 'ApiError';
}
}
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await SecureStore.getItemAsync('accessToken');
const isForm = init.body instanceof FormData;
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Accept: 'application/json',
// FormData writes its own Content-Type with a boundary: never override it.
...(init.body && !isForm ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers, // per-call headers win
},
});
if (!response.ok) {
throw new ApiError(
`${init.method ?? 'GET'} ${path} failed`,
response.status,
await response.text()
);
}
if (response.status === 204) return undefined as T; // no content to parse
return (await response.json()) as T;
}When to use it
- Point a development build at a laptop server and a release build at production without touching code.
- Attach the stored access token to every request from one place.
- Show a sign-in prompt on 401 and a retry on 500, from the same error type.
- Give every endpoint a return type so screens stop handling any.
More examples
One typed function per endpoint
Screens import listNotes, not a URL, so when the API moves an endpoint one line changes here and nothing else does. These functions also drop straight into useQuery and useMutation as the queryFn and mutationFn, since they already return promises and already throw on failure.
// api/notes.ts
import { request } from './client';
export type Note = { id: string; body: string; createdAt: string };
export function listNotes(): Promise<Note[]> {
return request<Note[]>('/notes');
}
export function getNote(id: string): Promise<Note> {
return request<Note>(`/notes/${id}`);
}
export function createNote(body: string): Promise<Note> {
// The shorthand { body } is the same object as { body: body }.
return request<Note>('/notes', { method: 'POST', body: JSON.stringify({ body }) });
}
export function deleteNote(id: string): Promise<void> {
return request<void>(`/notes/${id}`, { method: 'DELETE' });
}Checking the response instead of asserting it
Worth doing for the endpoints a screen cannot survive without. Note the request<unknown> call: asking for unknown rather than Note[] means the only typed value in the file is the one the schema actually verified, so a cast cannot quietly take the check's place.
// api/notes.ts
import { z } from 'zod';
import { request } from './client';
const noteSchema = z.object({
id: z.string(),
body: z.string(),
createdAt: z.string(),
});
// The type is derived from the schema, so the two cannot drift apart.
export type Note = z.infer<typeof noteSchema>;
export async function listNotes(): Promise<Note[]> {
const raw = await request<unknown>('/notes');
// Throws here, naming the offending field, rather than three screens later.
return z.array(noteSchema).parse(raw);
}Reacting to a specific status
This is the payoff for throwing a typed error. Because ApiError carries the status, the screen can show a sign-in prompt for 401 and a retry for 500, and the retry rule can stop wasting three attempts on a request that will never succeed.
import { useQuery } from '@tanstack/react-query';
import { ActivityIndicator, StyleSheet, Text } from 'react-native';
import { ApiError } from '../api/client';
import { listNotes, type Note } from '../api/notes';
export default function NotesScreen() {
const { data, isPending, error } = useQuery<Note[]>({
queryKey: ['notes'],
queryFn: listNotes,
retry: (failureCount, err) => {
// Never retry a rejected token or a bad request; retry server faults twice.
if (err instanceof ApiError && err.status < 500) return false;
return failureCount < 2;
},
});
if (isPending) return <ActivityIndicator style={styles.row} />;
if (error instanceof ApiError && error.status === 401) {
return <Text style={styles.row}>Your session expired. Sign in again.</Text>;
}
return <Text style={styles.row}>{data?.length ?? 0} notes</Text>;
}
const styles = StyleSheet.create({ row: { padding: 16 } });
Discussion