Networking, data fetching and offline support
Wrap fetch with timeouts and retries, cache and deduplicate with TanStack Query, detect connectivity, and queue mutations so a lost signal does not lose work.
A fetch wrapper worth having
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
this.name = 'HttpError';
}
}
export async function request<T>(
path: string,
init: RequestInit & { timeoutMs?: number; retries?: number } = {},
): Promise<T> {
const { timeoutMs = 15000, retries = 2, ...rest } = init;
for (let attempt = 0; ; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch('https://api.example.com' + path, {
...rest,
signal: controller.signal,
headers: { Accept: 'application/json', ...(rest.headers ?? {}) },
});
if (!response.ok) throw new HttpError(response.status, 'Request failed');
return (await response.json()) as T;
} catch (error) {
const retryable = error instanceof HttpError ? error.status >= 500 : true;
if (attempt >= retries || !retryable) throw error;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
} finally {
clearTimeout(timer);
}
}
}fetchhas no timeout in React Native: without anAbortControllera request on a dead network hangs until the platform gives up.- Retry only transport failures and 5xx responses. Retrying a 4xx multiplies load and never succeeds.
- Back off exponentially with jitter; a fixed delay makes every client retry in lockstep.
- Never retry a non-idempotent POST unless you send an idempotency key the server can deduplicate.
Caching and deduplication
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, error) =>
error instanceof HttpError && error.status < 500 ? false : failureCount < 2,
},
},
});
export function useArticles(page: number) {
return useQuery({
queryKey: ['articles', page],
queryFn: () => request<Article[]>('/articles?page=' + page),
placeholderData: (previous) => previous, // keep the old page visible while loading
});
}
export function useAddArticle() {
const client = useQueryClient();
return useMutation({
mutationFn: (draft: ArticleDraft) =>
request<Article>('/articles', { method: 'POST', body: JSON.stringify(draft) }),
onSuccess: () => client.invalidateQueries({ queryKey: ['articles'] }),
});
}| Option | Meaning | Set it to |
|---|---|---|
staleTime | How long data is considered fresh | As long as the data realistically stays correct |
gcTime | How long unused cache is kept | Longer than staleTime |
retry | Attempts after failure | Never retry 4xx |
refetchOnReconnect | Refetch when the network returns | True for a mobile app |
Two components requesting the same key share one in-flight request. That deduplication alone removes most duplicate network traffic in a screen with several cards.
Connectivity and an offline queue
import NetInfo from '@react-native-community/netinfo';
export function useIsOnline() {
const [online, setOnline] = useState(true);
useEffect(
() =>
NetInfo.addEventListener((state) => {
setOnline(Boolean(state.isConnected) && state.isInternetReachable !== false);
}),
[],
);
return online;
}
// persist mutations so a queued write survives an app restart
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';
const persister = createSyncStoragePersister({
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
removeItem: AsyncStorage.removeItem,
},
});
// pause mutations while offline, resume when connectivity returns
const onlineManager = {
setOnline: (value: boolean) => {
if (value) queryClient.resumePausedMutations();
},
};⚠️
isConnected only means a network interface is up, not that the internet is reachable. Captive portals report a connection. Check isInternetReachable as well, and still handle a failed request as a normal case.FAQ
How do I show a good offline experience?
Render cached data first with a small stale indicator, queue writes, and tell the user what is pending. An error screen with a retry button should be the exception, not the default when the cache holds usable data.
Why do my requests fail on Android emulator?
Usually a wrong host. Use
10.0.2.2 instead of localhost to reach the host machine, and remember that cleartext HTTP is blocked by default on modern Android.Related
Persistence and device APIs State management: Context, Redux Toolkit and Zustand
Last refreshed 2026-09-18.