Mobile Apps with React Native and Expo · State, Data, Offline and Sign-In
Fetching Data: Loading, Errors, Retries and Caching
Load data from your API the way good apps do: show loading and error states, retry flaky mobile requests with backoff, cache results so screens open instantly, and refresh when the app comes back to the foreground.
What you'll build
An "Orders" screen that opens instantly from cache, refreshes in the background, retries when the network blips, and shows a clear error with a "Try again" button when the server is down.
The idea
Mobile networks are slow and unreliable: elevators, tunnels, switching from Wi-Fi to cellular. A screen that loads data has at least four states: loading, success, empty and error. Handle all of them.
Doing this by hand in every screen (useEffect + useState + retries + caching) is repetitive and easy to get wrong. TanStack Query (React Query) works in React Native and handles caching, retries, background refetching, pagination and request deduplication. You describe what to fetch with a key; it handles when.
Mobile-specific setup:
- Tell it about app focus (refetch when the app returns to the foreground) using React Native's
AppState. - Tell it about connectivity with
@react-native-community/netinfoso it pauses while offline and resumes when back online. - Retries should use exponential backoff, and never retry requests that can't succeed (400, 401, 404).
The code
import { useEffect } from "react";
import { AppState, FlatList, Pressable, Text, View } from "react-native";
import NetInfo from "@react-native-community/netinfo";
import { QueryClient, QueryClientProvider, focusManager, onlineManager, useQuery } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // fresh for 30 s: no refetch when revisiting the screen
retry: (count, error: any) => error?.status >= 500 && count < 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000),
},
},
});
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)));
export function AppProviders({ children }: { children: React.ReactNode }) {
useEffect(() => {
const sub = AppState.addEventListener("change", (s) => focusManager.setFocused(s === "active"));
return () => sub.remove();
}, []);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
async function getOrders(): Promise<{ id: string; total: number }[]> {
const res = await fetch("https://api.example.com/orders", { headers: { Accept: "application/json" } });
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status });
return res.json();
}
export function Orders() {
const { data, isPending, isError, refetch, isRefetching } = useQuery({ queryKey: ["orders"], queryFn: getOrders });
if (isPending) return <Text style={{ padding: 16 }}>Loading orders…</Text>;
if (isError) return (
<View style={{ padding: 16, gap: 8 }}>
<Text>Couldn't load your orders.</Text>
<Pressable onPress={() => refetch()}><Text style={{ color: "#0f766e" }}>Try again</Text></Pressable>
</View>
);
return (
<FlatList data={data} keyExtractor={(o) => o.id} refreshing={isRefetching} onRefresh={refetch}
ListEmptyComponent={<Text style={{ padding: 16 }}>No orders yet.</Text>}
renderItem={({ item }) => <Text style={{ padding: 16 }}>Order {item.id}: ${item.total}</Text>} />
);
}
Try the logic
The retry rule is the part most apps get wrong. This simulation runs a fake flaky server through a fetchWithRetry helper: it retries timeouts and 5xx errors with growing delays, gives up on 4xx errors immediately, and uses a timeout so a hung request doesn't spin forever.
type Reply = { status: number; body?: string } | "hang";
function fakeServer(replies: Reply[]) {
let call = 0;
return async (signal: AbortSignal): Promise<{ status: number; body?: string }> => {
const reply = replies[Math.min(call++, replies.length - 1)];
if (reply === "hang") {
await new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("timeout"))));
}
return reply as { status: number; body?: string };
};
}
async function fetchWithRetry(
request: (signal: AbortSignal) => Promise<{ status: number; body?: string }>,
{ attempts = 4, timeoutMs = 50, baseDelayMs = 10 } = {},
): Promise<string> {
const log: string[] = [];
for (let attempt = 0; attempt < attempts; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await request(controller.signal);
if (res.status < 400) return `${log.join(", ")}${log.length ? ", " : ""}ok: ${res.body}`;
if (res.status < 500) return `${log.join(", ")}${log.length ? ", " : ""}HTTP ${res.status}: not retried`;
log.push(`HTTP ${res.status}`);
} catch (e) {
log.push((e as Error).message);
} finally {
clearTimeout(timer);
}
await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt)); // 10, 20, 40 ms...
}
return `${log.join(", ")}, gave up`;
}
console.log(await fetchWithRetry(fakeServer([{ status: 200, body: "3 orders" }])));
console.log(await fetchWithRetry(fakeServer([{ status: 503 }, "hang", { status: 200, body: "3 orders" }])));
console.log(await fetchWithRetry(fakeServer([{ status: 401 }])));
console.log(await fetchWithRetry(fakeServer([{ status: 500 }])));
Output
ok: 3 orders HTTP 503, timeout, ok: 3 orders HTTP 401: not retried HTTP 500, HTTP 500, HTTP 500, HTTP 500, gave up
Common mistakes
- No timeout. A request on a dead connection can hang for a minute; use
AbortControllerwith a timeout. - Retrying everything. A 401 or 422 won't succeed on retry; it just wastes battery and data.
- Showing a spinner on every visit. Cache data and refresh in the background (stale-while-revalidate).
- Forgetting empty and error states. "No orders yet" and "Try again" are part of the design.
- Setting state after the screen unmounted. Libraries like TanStack Query handle this; hand-written effects must cancel or ignore late results.
Key points
- Every data screen has loading, success, empty and error states.
- Retry only timeouts and 5xx, with exponential backoff and a timeout per request.
- Cache and refetch in the background; refresh on app focus and on reconnect.
- TanStack Query handles most of this; wire it to
AppStateand NetInfo.
Further reading: TanStack Query for React Native, Networking in React Native.
Practice questions
Which failed requests should a mobile app retry automatically?
Answer
Network errors, timeouts and 5xx server errors (often also 429 after the Retry-After delay), with exponential backoff and a limit. Not 4xx errors like 400, 401, 403 or 404, which will fail again until something changes.
Why wire TanStack Query to AppState and NetInfo in React Native?
Answer
On the web it listens to window focus and online events, which don't exist in React Native. AppState tells it when the app returns to the foreground (to refetch stale data) and NetInfo tells it when the device goes offline or online (to pause and resume).
Open in the app to mark lessons complete, take notes and track your progress.