TanStack Query in React Native: Caching and Offline Persistence
Use TanStack Query in React Native properly: app focus and online state, persisted caches for instant startup, offline mutations and infinite feeds.
Full-Stack, Mobile & AI Engineer
TanStack Query (formerly React Query) is the most effective way to manage server data in React Native: caching, background refetching, retries, pagination and mutations with one consistent API. But it was designed in browsers first, and a few defaults assume a browser: "window focus" and "online" events don't exist on phones the same way.
This guide shows how to set up TanStack Query in React Native properly in 2026: connecting it to app focus and network state, persisting the cache for instant startup, handling mutations offline, and building infinite feeds.
Key takeaways
- Wire
focusManagertoAppStateso data refreshes when users return to the app. - Wire
onlineManagerto NetInfo so queries pause offline and resume on reconnect. - Persist the query cache (to AsyncStorage or MMKV) so the app renders the last known data instantly at launch.
- Set
staleTimedeliberately per kind of data, and keepgcTimeat least as long as your persistence window. - Use optimistic updates and paused mutations for snappy, offline-tolerant writes.
Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // treat data as fresh for a minute
gcTime: 24 * 60 * 60_000, // keep unused data for a day (needed for persistence)
retry: 2,
},
},
});App focus: refetch when users come back
On the web, TanStack Query refetches stale data when the window regains focus. In React Native, connect it to AppState:
import { useEffect } from "react";
import { AppState, Platform, type AppStateStatus } from "react-native";
import { focusManager } from "@tanstack/react-query";
function onAppStateChange(status: AppStateStatus) {
if (Platform.OS !== "web") focusManager.setFocused(status === "active");
}
export function useAppFocusRefetch() {
useEffect(() => {
const sub = AppState.addEventListener("change", onAppStateChange);
return () => sub.remove();
}, []);
}Online state: pause offline, resume on reconnect
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected))
);Queries now pause instead of failing when the device is offline, and resume automatically when it reconnects.
Persist the cache for instant startup
Without persistence, every cold start begins with empty data and a spinner. With it, the app renders yesterday's data immediately and refreshes in the background:
import 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 });
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 24 * 60 * 60_000 }}
>
{children}
</PersistQueryClientProvider>
);
}MMKV is a faster storage backend for the same idea; use a synchronous persister with an MMKV adapter if startup speed is critical. Don't persist sensitive data (anything you wouldn't store unencrypted), and exclude it from persistence with dehydrateOptions if needed.
Choosing staleTime by data type
| Data | Suggested staleTime | Why |
|---|---|---|
| Static content (categories, plans) | Hours | Rarely changes |
| User profile | Minutes | Changes occasionally |
| Feeds, dashboards | Seconds to a minute | Users expect freshness on return |
| Live data (prices, availability) | 0, plus polling or sockets | Must be current |
Mutations: optimistic and offline-tolerant
For actions like "book a class" or "like a post", update the UI immediately and roll back if the server rejects it:
const queryClient = useQueryClient();
const bookClass = useMutation({
mutationFn: (classId: string) => api.post(`/classes/${classId}/book`),
onMutate: async (classId) => {
await queryClient.cancelQueries({ queryKey: ["classes"] });
const previous = queryClient.getQueryData<ClassItem[]>(["classes"]);
queryClient.setQueryData<ClassItem[]>(["classes"], (old = []) =>
old.map((c) => (c.id === classId ? { ...c, booked: true } : c))
);
return { previous };
},
onError: (_err, _classId, ctx) => queryClient.setQueryData(["classes"], ctx?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ["classes"] }),
});When offline, mutations pause rather than fail, and persisted paused mutations can resume after a restart with queryClient.resumePausedMutations() (set default mutation functions so they can be restored). For writes that must never be lost, such as workout sessions or field reports, a local database with an outbox is the safer choice; TanStack Query works well on top of it.
From the field: Cache-first fetching was one of three levers, with Hermes and image optimisation, that cut load time by about 60% (3.8 seconds to 1.5) in apps I migrated to the New Architecture. Rendering from cache first and refreshing quietly is the single biggest difference users feel on a slow network.
Query keys that scale
Query keys decide caching and invalidation, so treat them as an API. A small key factory per feature keeps them consistent:
export const classKeys = {
all: ["classes"] as const,
list: (filters: ClassFilters) => [...classKeys.all, "list", filters] as const,
detail: (id: string) => [...classKeys.all, "detail", id] as const,
};Invalidating classKeys.all then refreshes every list and detail for classes, while invalidating one detail key leaves the rest alone. Include every variable a query depends on (filters, user, language) in its key, or different screens will share data they shouldn't.
Common mistakes
- Leaving
staleTimeat zero everywhere, so every mount and focus refetches, wasting battery and data. - Copying server data into global state (Zustand, Redux) and then keeping two copies in sync. Let TanStack Query own server data; keep only client state elsewhere.
- Fetching in
useEffectalongside queries, which brings back the loading and race-condition bugs the library exists to remove. - Persisting everything, including sensitive or huge responses. Choose what to persist.
- Forgetting the React Native setup for app focus and online state, then wondering why data never refreshes after returning to the app.
When TanStack Query isn't enough on its own
- Real-time data: keep using queries, but update the cache from WebSocket events with
setQueryData, so screens stay live without polling. - User-created data that must survive offline: store it in a local database with an outbox, and let queries read from your sync layer.
- Large local datasets you need to search and sort: that's a database's job (SQLite), not a request cache's.
Testing screens that use queries
Data-fetching screens are easy to test once you control the network and the cache:
- Create a fresh
QueryClientper test, with retries turned off, so tests don't leak cached data into each other or wait for retry delays. - Mock at the network boundary, not inside components. Mocking your API module, or using a request-mocking library such as MSW, keeps components and hooks running their real code.
- Test every state the user can see: loading, success, empty, error and offline. The empty and error states are the ones most often broken in production.
- Test optimistic updates by making the mocked request fail and checking that the UI rolls back.
A handful of these tests on your core screens catches most data bugs before release.
Infinite feeds
useInfiniteQuery handles paginated feeds and pairs naturally with FlashList:
const feed = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => api.get(`/feed?cursor=${pageParam ?? ""}`),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
// <FlashList data={feed.data?.pages.flatMap((p) => p.items)} onEndReached={() => feed.fetchNextPage()} />Use cursor-based pagination on the server rather than page numbers, so new items don't shift pages while the user scrolls.
FAQ
Does TanStack Query work with React Native?
Yes. It works the same as on the web, with two additions: connect focusManager to React Native's AppState so data refreshes when the app returns to the foreground, and connect onlineManager to NetInfo so queries pause offline and resume on reconnect.
How do I persist React Query data in React Native?
Use PersistQueryClientProvider with a persister backed by AsyncStorage (or MMKV for speed), and set gcTime at least as long as your persistence maxAge. The app then restores cached data at launch and refreshes it in the background.
Can TanStack Query handle offline mutations?
Yes, to a point. Mutations pause while offline and resume when the connection returns, and persisted paused mutations can resume after a restart. For data that must never be lost, such as user-created records, combine it with a local database and an outbox.
What staleTime should I use?
It depends on the data: hours for static content, minutes for profiles, seconds to a minute for feeds and dashboards, and zero with polling or sockets for live data. The default of zero refetches very often, which is rarely what mobile apps need.
Next steps
If your app shows spinners on every launch or fetches the same data repeatedly, I can set up caching and persistence so it feels instant on any network. Get in touch, or see how I work as a React Native developer.
Sources
- React Native (TanStack Query guide), TanStack
- persistQueryClient, TanStack
- Optimistic updates, TanStack
- NetInfo, GitHub
Related
Offline-First React Native: Lessons from an App with 100K Users
How to build offline-first React Native apps that never lose data: local-first storage, an outbox for writes, sync, conflicts and testing bad networks.
WatermelonDB vs SQLite vs Realm in 2026
WatermelonDB vs SQLite vs Realm for React Native in 2026: performance, sync, the end of Atlas Device Sync, and which local database to choose.
State Management in React Native 2026: Zustand vs Redux Toolkit vs Jotai
React Native state management in 2026: separating server and client state, then Zustand vs Redux Toolkit vs Jotai vs Legend State, with clear picks.
