TanStack Query on Mobile
focusManager, onlineManager, and refetch on app foreground - the mobile-specific wiring TanStack Query needs beyond web tutorials.
Search across all documentation pages
focusManager, onlineManager, and refetch on app foreground - the mobile-specific wiring TanStack Query needs beyond web tutorials.
Quick-reference recipe card - copy-paste ready.
// src/lib/queryClient.ts
import NetInfo from "@react-native-community/netinfo";
import { QueryClient, focusManager, onlineManager } from "@tanstack/react-query";
import { AppState, Platform } from "react-native";
// Refetch when app returns to foreground
focusManager.setEventListener((handleFocus) => {
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") handleFocus();
});
return () => sub.remove();
});
// Pause retries when offline; resume when connected
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
const online =
state.isConnected !== false && state.isInternetReachable !== false;
setOnline(online);
});
});
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 30_000,
gcTime: 24 * 60 * 60_000,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
});// app/_layout.tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "../src/lib/queryClient";
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
{/* routes */}
</QueryClientProvider>
);
}npx expo install @tanstack/react-query @react-native-community/netinfoWhen to reach for this:
import NetInfo from "@react-native-community/netinfo";
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
useQuery,
} from "@tanstack/react-query";
import { useEffect } from "react";
import {
AppState,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
const API = "https://jsonplaceholder.typicode.com";
focusManager.setEventListener((handleFocus) => {
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") handleFocus();
});
return () => sub.remove();
});
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected && state.isInternetReachable !== false);
})
);
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 45_000, retry: 2, refetchOnReconnect: true },
},
});
async function fetchPosts({ signal }: { signal?: AbortSignal }) {
const res = await fetch(`${API}/posts?_limit=12`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ id: number; title: string }[]>;
}
function PostsScreen() {
const { data, error, isError, isFetching, refetch, dataUpdatedAt } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
});
return (
<View style={styles.screen}>
<Text style={styles.meta}>
{isFetching ? "Updating…" : `Updated ${new Date(dataUpdatedAt).toLocaleTimeString()}`}
</Text>
<FlatList
data={data}
keyExtractor={(item) => String(item.id)}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} />}
renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
ListEmptyComponent={isError ? null : <Text>Loading…</Text>}
/>
{isError && (
<View style={styles.error}>
<Text>{error instanceof Error ? error.message : "Failed"}</Text>
<Pressable onPress={() => refetch()}>
<Text style={styles.retry}>Retry</Text>
</Pressable>
</View>
)}
</View>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<PostsScreen />
</QueryClientProvider>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, backgroundColor: "#fff" },
meta: { fontSize: 12, color: "#64748b", marginBottom: 8 },
row: { paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#e2e8f0" },
error: { padding: 12, backgroundColor: "#fee2e2", borderRadius: 8 },
retry: { color: "#2563eb", fontWeight: "600", marginTop: 8 },
});What this demonstrates:
focusManager + AppState refetches stale queries when the app becomes active.onlineManager + NetInfo pauses retries offline and triggers refetchOnReconnect when LTE returns.signal in queryFn cancels fetch when the query unmounts or key changes.refetch() - idiomatic mobile UX alongside foreground refetch.dataUpdatedAt labels staleness for users on flaky networks.| AppState | TanStack behavior |
|---|---|
active | handleFocus() - refetch stale queries if refetchOnWindowFocus |
background | Queries stay cached; in-flight requests may complete |
inactive (iOS) | Treat like brief interruption - same listener |
// Optional: only refetch "important" queries on focus
queryClient.refetchQueries({ queryKey: ["inbox"], type: "active" });staleTime: 0 for those keys onlyqueryKeyonlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
// null reachability = unknown - do not force offline
if (state.isConnected === false) {
setOnline(false);
return;
}
if (state.isInternetReachable === false) {
setOnline(false);
return;
}
setOnline(true);
});
});refetchOnReconnect: true (default) refetches stale queries when onlineManager flips truenetworkMode: "offlineFirst" only when you have a persisted queue - see Offline-First Basics| Option | Suggested mobile value | Why |
|---|---|---|
staleTime | 30–60 s feeds; 5–15 min catalogs | Reduces LTE chatter |
gcTime | 24 h | Survive short offline sessions |
retry | 2 | LTE blips recover; cap avoids battery drain |
retryDelay | exponential 1s → 4s | See Retries doc |
refetchOnWindowFocus | true with focusManager | Mobile "window" = app foreground |
refetchOnReconnect | true with onlineManager | Catch up after subway |
npx expo install @tanstack/react-query-persist-client @react-native-async-storage/async-storageimport AsyncStorage from "@react-native-async-storage/async-storage";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
// Wrap QueryClientProvider with PersistQueryClientProvider in root layoutuseQuery({
queryKey: ["profile", userId],
queryFn: fetchProfile,
enabled: !!userId,
});enabled: false until session exists - avoids 401 storms on launch["profile"] on logout: queryClient.clear() or removeQueriesqueryFn via shared client - not inside every hookAppState listener in app bootstrap.isInternetReachable: null as offline - Banner and retry flicker on launch. Fix: Only set offline on explicit false.signal in queryFn - Requests complete after unmount. Fix: fetch(url, { signal }) with Query's injected signal.staleTime: 0 globally - Refetch on every focus drains battery. Fix: Per-query tuning.| Alternative | Use When | Don't Use When |
|---|---|---|
| TanStack Query + managers | Default server state for Expo | Pure local state with no network |
| SWR | Team already on SWR from web | Greenfield Expo - Query has richer RN docs in this site |
| Redux + thunks | Legacy store already central | New apps - duplicate cache logic |
| Manual useEffect fetch | One screen, no shared cache | Multiple screens reading same API |
Once at module load in queryClient.ts or root _layout.tsx before any queries run - not inside individual screens.
No - React Native has no window focus events. You must wire AppState to focusManager.
Only idempotent mutations with server-side keys. Default retry: 0 for POST payments - see Retries, Backoff & Idempotency.
queryClient.prefetchQuery({ queryKey: ["post", id], queryFn: () => fetchPost(id) });Call on onPressIn of list rows for snappy navigation.
TanStack Query gives stale-while-revalidate and optional persistence. Full offline-first adds SQLite sync - Offline-First Basics.
queryFnretryDelay and mutation safetyuseMutation patterns on deviceenabled queries and cache clear on logoutStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026