Networking and Data Fetching
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Getting Started scaffolded Places and
Styling gave it a theme, but every screen has so far rendered data made up on
the spot. This page replaces that with a real backend: the browser-standard fetch, XMLHttpRequest, and
WebSocket APIs React Native ships as globals, the platform networking rules (App Transport Security on iOS,
cleartext traffic on Android) that govern which URLs those APIs may even reach, and TanStack Query as the caching
and data-synchronization layer built on top of them. It ends with Places's own typed, Zod-validated API client
and the usePlaces()/useAddPlace()/usePlacesLiveUpdates() Hooks every later data-driven screen in this
section builds on. Storage and Offline (later in this section)
covers what happens to this same data when the device has no connectivity at all; this page assumes a reachable
server.
fetch, XMLHttpRequest, and WebSocket
React Native ships the same three networking globals a browser does — no import required — implemented natively rather than backed by an embedded browser engine, so they carry the same call signatures web developers already know with a few mobile-specific caveats below.
fetch: JSON, Headers, and File Uploads
A JSON request is the common case — a body serialized with JSON.stringify, a Content-Type header set
explicitly (unlike a browser form submission, fetch never infers it), and the response parsed back out with
.json():
const response = await fetch('https://api.example.com/places', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer <access-token>',
},
body: JSON.stringify({ name: 'Golden Gate Bridge', category: 'landmark' }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const place = await response.json();
response.ok is false for any non-2xx status — fetch only rejects its promise on a network-level failure
(no connectivity, a DNS failure, a timed-out connection), never on an HTTP error status, so checking response.ok
explicitly is required on every call rather than relying on a catch block to see a 404 or a 500.
Uploading a file — Places's own Place.photoUri, a local file URI from the device’s camera or photo library — uses FormData instead of a JSON body, with the browser leaving the Content-Type (including its boundary
string) to be set automatically rather than specified by hand:
async function uploadPlacePhoto(placeId: string, photoUri: string): Promise<void> {
const formData = new FormData();
formData.append('photo', {
uri: photoUri,
name: 'photo.jpg',
type: 'image/jpeg',
} as unknown as Blob);
const response = await fetch(`https://api.example.com/places/${placeId}/photo`, {
method: 'POST',
body: formData,
// no Content-Type header here -- fetch sets the multipart boundary itself
});
if (!response.ok) {
throw new Error(`Photo upload failed with status ${response.status}`);
}
}
The { uri, name, type } shape is React Native’s own extension to FormData.append — there is no File object
on a mobile OS the way there is in a browser, so the native fetch implementation reads the file directly off
disk at uri when it builds the multipart body.
Cancellation and Timeouts with AbortController
fetch accepts a signal from an AbortController, the same cancellation primitive the web platform defines — calling .abort() rejects the in-flight fetch promise with an AbortError, which is how a screen cancels a
request when the user navigates away before it resolves:
const controller = new AbortController();
fetch('https://api.example.com/places', { signal: controller.signal })
.then((response) => response.json())
.catch((error) => {
if (error.name === 'AbortError') {
return; // cancelled deliberately, not a real failure
}
throw error;
});
// later, e.g. in a useEffect cleanup function:
controller.abort();
fetch has no built-in timeout option — unlike most native HTTP clients, an unreachable server or a stalled
connection leaves a fetch call pending indefinitely unless something else cancels it. Combining
AbortController with setTimeout is the standard pattern for a request timeout:
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
Places's own apiClient.ts (below) builds this pattern in once, so every call through it gets a timeout without
repeating the setTimeout/clearTimeout pair at every call site.
XMLHttpRequest: Upload and Download Progress
fetch exposes no progress events — its Response.body readable stream reports chunks arriving but never a
percentage, and it has no upload-progress signal at all. XMLHttpRequest, React Native’s older networking
global, still is the one API with native progress events for both directions, which is why it remains relevant
for a large photo upload or a large asset download that needs a visible progress bar:
function uploadWithProgress(url: string, formData: FormData, onProgress: (fraction: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(event.loaded / event.total);
}
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Status ${xhr.status}`)));
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
}
Outside of progress reporting, fetch is the better default — a Promise-based API rather than an
event-callback one, and the one every library in this page (TanStack Query, axios, Apollo Client) is itself
built on.
WebSocket: Live Updates
WebSocket is also a built-in global, requiring no library for a basic connection — Places's
usePlacesLiveUpdates() Hook (below) uses exactly this to learn when a place changes on the server from another
device:
const socket = new WebSocket('wss://api.example.com/places/live-updates');
socket.onopen = () => console.log('connected');
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('received', message);
};
socket.onerror = (event) => console.log('socket error', event);
socket.onclose = () => console.log('disconnected');
socket.send(JSON.stringify({ type: 'subscribe', topic: 'places' }));
A raw WebSocket does not reconnect itself after a dropped connection — a backgrounded app, a network handoff
between Wi-Fi and cellular, or a server restart all close the socket — so any real usage needs its own
reconnection logic, typically an exponential backoff loop that recreates the socket in onclose and gives up (or
falls back to polling) after a maximum number of attempts. Places's own usePlacesLiveUpdates() Hook shows this
pattern in full below.
App Transport Security (iOS) and Cleartext Traffic (Android)
Both platforms block plain-HTTP requests by default, for different reasons and with different escape hatches, and
both errors look identical from JavaScript — a rejected fetch promise with no further detail — which makes
knowing the platform-side cause worth memorizing rather than debugging from scratch each time:
-
iOS: App Transport Security (ATS). Every request must use HTTPS with a modern TLS configuration by default; a plain-HTTP request to any host is refused entirely, not merely warned about. Reaching a specific
http://host anyway — a local dev server, a staging environment without a certificate — requires an explicit exception inInfo.plist(viaapp.json’s `ios.infoPlistunder Expo) scoped to that one host, never a blanket ATS disable in a shipped app:{ "expo": { "ios": { "infoPlist": { "NSAppTransportSecurity": { "NSExceptionDomains": { "dev.example.internal": { "NSExceptionAllowsInsecureHTTPLoads": true } } } } } } } -
Android: cleartext traffic. Plain HTTP is blocked by default since Android 9 (API 28) via the app’s network security config, controlled through
usesCleartextTraffic(viaapp.json’s `android.usesCleartextTrafficunder Expo, or a customnetwork_security_config.xmlscoped to specific domains for anything more targeted than an all-or-nothing switch).
Security (later in this section) covers both mechanisms, and the broader
question of what belongs in an exception list at all, in full; the point here is narrower — a fetch that works
in a browser pointed at the same URL can still fail silently from React Native until one of these two exceptions
is in place for that specific host.
The emulator/simulator addressing gotcha. localhost inside a React Native app always means that app’s own
process, never the developer’s machine — the same as it would in any native app — which trips up a local dev
server address in three different ways depending on where the app is running:
| Target | Dev server address |
|---|---|
iOS Simulator |
|
Android Emulator |
|
Physical device (either platform) |
The developer machine’s real LAN IP (e.g. |
Places's own apiClient.ts never hardcodes any of these — the base URL comes from an environment-specific
config value precisely so this switch happens in one place instead of at every call site.
TanStack Query
Calling fetch directly for every screen quickly turns into hand-rolled loading/error state, no caching between
screens that need the same data, and no coordination between a mutation and the queries it should invalidate.
TanStack Query (formerly React Query) solves all
three, and is Places's data-fetching layer for the rest of this section.
Queries, Mutations, and Cache Invalidation
useQuery fetches and caches data keyed by a queryKey; useMutation performs a write and, on success, tells
TanStack Query which cached queries are now stale via queryClient.invalidateQueries:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
function usePlace(id: string) {
return useQuery({
queryKey: ['places', id],
queryFn: () => placesApi.getPlace(id),
});
}
function useDeletePlace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => placesApi.deletePlace(id),
onSuccess: (_data, id) => {
queryClient.invalidateQueries({ queryKey: ['places'] });
queryClient.removeQueries({ queryKey: ['places', id] });
},
});
}
Invalidating ['places'] marks every query whose key starts with that array stale — the places list, any
individual place detail — and triggers a refetch for whichever of those queries has an active observer (a
mounted component currently reading it) right now.
Infinite Queries: usePlaces()
useInfiniteQuery is useQuery for a paginated list, tracking pages fetched so far and how to fetch the next one — exactly what Places's own usePlaces() (in full below) uses for its cursor-paginated list, driven by
PlacesPage.nextCursor coming back from the server on every page.
networkMode
TanStack Query’s networkMode option controls how a query or mutation behaves when the device has no network
connection, which matters far more on mobile — where connectivity is routinely intermittent — than it typically
does for a desktop browser tab:
| Mode | Behavior |
|---|---|
|
A query/mutation pauses (stays in a |
|
Runs regardless of connectivity, ignoring the network status entirely — appropriate for a |
|
Attempts the request once even while marked offline (useful behind a captive portal or a flaky connection where
the OS-reported status lags reality), then falls back to the same paused behavior as |
Places's own queries and mutations keep the 'online' default; Storage
and Offline revisits networkMode alongside NetInfo for the app’s own connectivity-aware behavior.
Persistence Across App Restarts
By default, TanStack Query’s cache lives only in memory and is gone the moment the app process is killed.
@tanstack/query-async-storage-persister plus persistQueryClient restore it from AsyncStorage on the next
launch, so the places list still renders instantly (then silently revalidates) instead of showing a blank loading
state every cold start:
// src/lib/queryPersistence.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { QueryClient } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
export const queryClient = new QueryClient({
defaultOptions: {
queries: { gcTime: 1000 * 60 * 60 * 24 }, // keep cached data around long enough to persist meaningfully
},
});
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // discard anything restored that is older than 24 hours
});
Storage and Offline covers AsyncStorage itself, and the
broader offline-first data model Places layers on top of this persisted cache, in full.
Alternative HTTP Client: axios
axios is a common alternative to fetch rather than a replacement for TanStack Query — it slots into the same queryFn/mutationFn position — and is worth knowing for three conveniences fetch
does not provide natively: interceptors (a single place to attach an Authorization header or handle a 401
across every request), automatic JSON parsing (no explicit .json() call), and a consistent error shape
(a failed request always throws, carrying error.response.status and error.response.data, rather than fetch’s
resolve-with-`ok: false behavior). Places uses fetch throughout this section precisely because that
resolve-vs-throw distinction is worth understanding directly, but swapping apiClient.ts’s internals for axios
changes nothing about the `PlacesApi interface or the Hooks built on it below.
GraphQL with Apollo Client
Everything above assumes a REST backend. When the backend is GraphQL instead,
Apollo Client is the equivalent data layer — ApolloClient in place of
a raw fetch/axios instance, and useQuery/useMutation from @apollo/client in place of TanStack Query’s
Hooks of the same name, with its own normalized cache handling the invalidation TanStack Query’s queryClient
handles explicitly above:
import { ApolloClient, InMemoryCache, gql, useQuery } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache(),
});
const PLACES_QUERY = gql`
query Places {
places {
id
name
category
}
}
`;
function usePlacesGraphQL() {
return useQuery(PLACES_QUERY, { client });
}
When to Use GraphQL or REST covers the protocol-level trade-off itself — a single typed schema and no over-/under-fetching versus REST’s simpler caching and tooling — and the GraphQL reference section covers Apollo Client and the wider GraphQL ecosystem in depth; Places stays on REST throughout this section, so Apollo Client is not used again after this mention.
Error Handling, Retries, and Exponential Backoff
TanStack Query retries a failed query automatically — three attempts by default, with an exponential delay between them — and both are configurable per query or globally:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // 1s, 2s, 4s, ... capped at 30s
},
mutations: {
retry: 1, // a write is retried far more conservatively than a read
},
},
});
A queryFn/mutationFn that throws a validation error (the Zod-backed placesApi below) rather than a network
error still counts as a failure for retry purposes, so a function distinguishing "the server is unreachable,
retry" from "the server sent garbage, do not retry" needs to inspect the thrown error inside retry itself
(returning false for a validation error) rather than relying on the numeric default.
Inspecting Requests
React Native DevTools — opened from the Dev Menu on a development build, introduced on
Getting Started — includes a Network panel that lists every fetch
and XMLHttpRequest call with its headers, body, and response, the same inspection experience Chrome DevTools
provides for a web app.
Debugging and DevTools (later in this section) covers React Native DevTools, including
this Network panel, in full.
Typed API Clients
fetch(url).thenr) ⇒ r.json( returns any — TypeScript trusts the shape of a JSON response completely,
with nothing checking that the server actually sent a Place and not, say, an error object with a different
shape entirely. Two things fix this together, both shown in the code below: a typed interface
(PlacesApi, matching the exact shape Places needs) so every call site gets compile-time checking of arguments
and return types, and runtime validation with a schema library (Zod, below) so a response that does not
actually match that shape throws a clear error immediately, at the network boundary, instead of an any-typed lie
silently corrupting state three components downstream.
Places: a Typed, Validated API Client
apiClient.ts: the fetch Wrapper
// src/lib/apiClient.ts
const API_BASE_URL = '<api-base-url>'; // resolved per environment, see the emulator-addressing note above
const DEFAULT_TIMEOUT_MS = 10000;
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
export interface ApiClientOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
export async function apiRequest<T>(path: string, options: ApiClientOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal } = options;
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs);
// an externally-supplied signal (e.g. a component unmounting) aborts the same request as the timeout does
signal?.addEventListener('abort', () => timeoutController.abort());
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: timeoutController.signal,
});
if (!response.ok) {
throw new ApiError(`Request to ${path} failed`, response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} finally {
clearTimeout(timeoutId);
}
}
apiRequest returns the parsed JSON as T unchecked — it is deliberately just the transport layer. Every real
caller goes through placesApi.ts below, which validates that T actually holds before handing it back.
placesApi.ts: Zod Validation Over the Wire
// src/features/places/api/placesApi.ts
import { z } from 'zod';
import { apiRequest } from '../../../lib/apiClient';
import type { NewPlaceInput, Place, PlaceId } from '../types';
const placeSchema = z.object({
id: z.string(),
name: z.string(),
notes: z.string(),
category: z.enum(['restaurant', 'landmark', 'nature', 'other']),
favourite: z.boolean(),
visitedAt: z.string(),
latitude: z.number().nullable(),
longitude: z.number().nullable(),
photoUri: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});
const placesPageSchema = z.object({
items: z.array(placeSchema),
nextCursor: z.string().nullable(),
});
export interface PlacesPage {
items: Place[];
nextCursor: string | null;
}
export interface PlacesApi {
listPlaces(cursor?: string): Promise<PlacesPage>;
getPlace(id: PlaceId): Promise<Place>;
addPlace(input: NewPlaceInput): Promise<Place>;
toggleFavourite(id: PlaceId, favourite: boolean): Promise<Place>;
deletePlace(id: PlaceId): Promise<void>;
}
export const placesApi: PlacesApi = {
async listPlaces(cursor) {
const query = cursor !== undefined ? `?cursor=${encodeURIComponent(cursor)}` : '';
const raw = await apiRequest<unknown>(`/places${query}`);
const result = placesPageSchema.safeParse(raw);
if (!result.success) {
throw new Error(`Malformed places page from server: ${result.error.message}`);
}
return result.data;
},
async getPlace(id) {
const raw = await apiRequest<unknown>(`/places/${id}`);
return placeSchema.parse(raw); // throws a descriptive ZodError if the shape does not match
},
async addPlace(input) {
const raw = await apiRequest<unknown>('/places', { method: 'POST', body: input });
return placeSchema.parse(raw);
},
async toggleFavourite(id, favourite) {
const raw = await apiRequest<unknown>(`/places/${id}/favourite`, { method: 'PATCH', body: { favourite } });
return placeSchema.parse(raw);
},
async deletePlace(id) {
await apiRequest<void>(`/places/${id}`, { method: 'DELETE' });
},
};
.parse() throws immediately on a mismatch, which is appropriate wherever a malformed response should abort the
operation outright; .safeParse() returns a { success, data | error } result instead, useful wherever the
caller wants to handle a validation failure without an exception — listPlaces uses it here only to attach a
clearer error message before re-throwing, but the same result shape is exactly where a caller would instead fall
back to cached data or an empty page.
usePlaces() and useAddPlace()
// src/features/places/hooks/usePlaces.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { placesApi } from '../api/placesApi';
export function usePlaces() {
return useInfiniteQuery({
queryKey: ['places'],
queryFn: ({ pageParam }) => placesApi.listPlaces(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}
// src/features/places/hooks/useAddPlace.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { placesApi } from '../api/placesApi';
import type { NewPlaceInput, Place } from '../types';
import type { PlacesPage } from '../api/placesApi';
interface InfinitePlacesData {
pages: PlacesPage[];
pageParams: unknown[];
}
export function useAddPlace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: NewPlaceInput) => placesApi.addPlace(input),
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: ['places'] });
const previousData = queryClient.getQueryData<InfinitePlacesData>(['places']);
const optimisticPlace: Place = {
id: `optimistic-${Date.now()}`,
name: input.name,
notes: input.notes,
category: input.category,
favourite: false,
visitedAt: input.visitedAt,
latitude: input.latitude,
longitude: input.longitude,
photoUri: input.photoUri,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
queryClient.setQueryData<InfinitePlacesData>(['places'], (data) => {
if (data === undefined || data.pages.length === 0) {
return data;
}
const [firstPage, ...restPages] = data.pages;
const updatedFirstPage = { ...firstPage, items: [optimisticPlace, ...firstPage.items] };
return { ...data, pages: [updatedFirstPage, ...restPages] };
});
return { previousData };
},
onError: (_error, _input, context) => {
if (context?.previousData !== undefined) {
queryClient.setQueryData(['places'], context.previousData);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['places'] });
},
});
}
The optimistic place appears in the list the instant the mutation fires, before the fetch call it triggers has
even resolved; onError rolls the cache back to previousData if that request fails, and onSettled — run
after either success or failure — invalidates ['places'] so the next refetch reconciles the cache with
whatever the server actually persisted, replacing the temporary optimistic-… id with the real one.
usePlacesLiveUpdates(): WebSocket Plus the Query Cache
// src/features/places/hooks/usePlacesLiveUpdates.ts
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
interface PlaceChangedMessage {
type: 'place-changed';
placeId: string;
}
const MAX_RECONNECT_DELAY_MS = 30000;
export function usePlacesLiveUpdates(): void {
const queryClient = useQueryClient();
const reconnectAttempt = useRef(0);
useEffect(() => {
let socket: WebSocket | null = null;
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
let closedByCleanup = false;
function connect() {
socket = new WebSocket('wss://api.example.com/places/live-updates');
socket.onopen = () => {
reconnectAttempt.current = 0;
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data) as PlaceChangedMessage;
if (message.type === 'place-changed') {
// invalidate rather than guess the new shape -- the next refetch fetches the authoritative version
queryClient.invalidateQueries({ queryKey: ['places', message.placeId] });
queryClient.invalidateQueries({ queryKey: ['places'] });
}
};
socket.onclose = () => {
if (closedByCleanup) {
return;
}
const delay = Math.min(1000 * 2 ** reconnectAttempt.current, MAX_RECONNECT_DELAY_MS);
reconnectAttempt.current += 1;
reconnectTimeoutId = setTimeout(connect, delay);
};
}
connect();
return () => {
closedByCleanup = true;
if (reconnectTimeoutId !== null) {
clearTimeout(reconnectTimeoutId);
}
socket?.close();
};
}, [queryClient]);
}
Reconnection uses the same exponential-backoff shape as TanStack Query’s own retryDelay above, capped at 30
seconds, and resets to the first, shortest delay as soon as a connection succeeds — so a brief network blip
reconnects quickly while a genuinely down server does not hammer it every second forever.