TanStack Query (React Query v5) caches server data, retries failed requests and decides when to refetch. On the web it refetches when the window regains focus and when the browser comes back online. A phone has no window focus or browser online event. It has AppState and a network listener instead, and the app goes to the background, the radio drops and returns, and a user pulls a list down. This guide wires each of those signals in an Expo Router app, with the code you would copy.
Install the packages
In a ReadyNative app, bun run setup --data react-query adds the client, the provider and the fetch helper for you. In a plain Expo app, install the libraries with expo install, which picks the versions that match your SDK:
npx expo install @tanstack/react-query
npx expo install expo-network @react-native-async-storage/async-storage
npx expo install @tanstack/react-query-persist-client @tanstack/query-async-storage-persisterOnly the first line is needed for the basic setup. The other two are for the network listener and the offline cache further down. TanStack Query is pure JavaScript, so Expo Go runs all of it.
Provide the client in the root layout
Create the client once, at module scope. A client created inside a component is replaced on every render, and the cache goes with it. Expo Router's root layout is the place to provide it, so every screen in src/app can read the cache:
// src/app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}ReadyNative's root layout renders a Providers component instead, and the data module adds QueryProvider to it. The defaults live in src/lib/query.tsx: one retry, a 30 second staleTime and a 5 minute gcTime. Change those there, not in each hook.
Refetch when the app comes back
On the web, TanStack listens for window events. React Native has none, so you feed AppState into focusManager. When the app is active, stale queries refetch. ReadyNative already does this in src/lib/query.tsx; here is what it does:
import { focusManager } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState, type AppStateStatus } from "react-native";
useEffect(() => {
const subscription = AppState.addEventListener("change", (status: AppStateStatus) => {
focusManager.setFocused(status === "active");
});
return () => subscription.remove();
}, []);Refetch when a screen gains focus
App focus covers the whole app. A tab that should be fresh each time the user returns to it needs a screen-level trigger. Expo Router exports useFocusEffect, so you do not import @react-navigation/native. The callback also runs on the first mount, so a ref skips that first call, as TanStack's React Native example does:
import { useQueryClient } from "@tanstack/react-query";
import { useFocusEffect } from "expo-router";
import { useCallback, useRef } from "react";
export function useRefreshOnFocus(key: string) {
const queryClient = useQueryClient();
const firstRun = useRef(true);
useFocusEffect(
useCallback(() => {
if (firstRun.current) {
firstRun.current = false;
return;
}
// Only stale queries on screen refetch; fresh ones stay as they are.
queryClient.refetchQueries({ queryKey: [key], stale: true, type: "active" });
}, [queryClient, key])
);
}The hook takes a string key. React compares strings by value in the dependency array, so the callback is not recreated on every render.
Refetch on reconnect with onlineManager
Until you set it up, TanStack assumes the app is always online. It never pauses a query, and it never refetches when the network comes back. Connect onlineManager to a network listener. expo-network works in Expo Go; put this in src/lib/query.tsx, next to the client:
import { onlineManager } from "@tanstack/react-query";
import * as Network from "expo-network";
onlineManager.setEventListener((setOnline) => {
let initialized = false;
const subscription = Network.addNetworkStateListener((state) => {
initialized = true;
setOnline(!!state.isConnected);
});
Network.getNetworkStateAsync()
.then((state) => {
if (!initialized) setOnline(!!state.isConnected);
})
.catch(() => {
// getNetworkStateAsync can reject on some platforms and SDK versions.
});
return () => subscription.remove();
});isConnected means a network link exists, not that the internet answers. expo-network also reports isInternetReachable, which on Android needs a validated connection and on iOS matches isConnected. The TanStack example uses isConnected. @react-native-community/netinfo works the same way: call setOnline from its listener.
Pull to refresh with RefreshControl
A FlatList with onRefresh gets a standard RefreshControl. The refreshing prop has to follow the request, and isRefetching does that: it is true while a background refetch is in flight, and it stays false during the first load, which isPending covers.
const { data, refetch, isRefetching } = useQuery({
queryKey: ["posts"],
queryFn: () => fetchJson<Post[]>("/posts"),
});
<FlatList
data={data}
keyExtractor={(post) => post.id}
renderItem={({ item }) => <PostRow post={item} />}
refreshing={isRefetching}
onRefresh={() => refetch()}
/>On a plain ScrollView, pass refreshControl with a RefreshControl element instead. It accepts the same two props.
Infinite lists with useInfiniteQuery
useInfiniteQuery stores every page in one cache entry. getNextPageParam returns the cursor for the next page, or null or undefined when there are no more. onEndReached fires when the user nears the end of the list, and the guard stops a second fetch while one is running:
type Page = { items: Post[]; nextCursor: number | null };
const posts = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchJson<Page>(`/posts?cursor=${pageParam}`),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const items = posts.data?.pages.flatMap((page) => page.items) ?? [];
<FlatList
data={items}
keyExtractor={(post) => post.id}
renderItem={({ item }) => <PostRow post={item} />}
onEndReached={() => {
if (posts.hasNextPage && !posts.isFetchingNextPage) posts.fetchNextPage();
}}
onEndReachedThreshold={0.5}
refreshing={posts.isRefetching}
onRefresh={() => posts.refetch()}
/>Flatten the pages before the list renders them. The list gets one array, and the cache keeps the page structure. TanStack's guide warns that one infinite query has a single ongoing fetch, and two overlapping fetches can overwrite each other, which is why the guard checks isFetchingNextPage.
Keep the cache across launches
The client keeps data in memory, so after a cold start every query begins empty. PersistQueryClientProvider restores the saved cache first, then saves it as it changes. The AsyncStorage persister writes to @react-native-async-storage/async-storage, and the key/value storage guide compares it with the other stores:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
export const queryClient = new QueryClient({
defaultOptions: {
queries: { gcTime: 24 * 60 * 60 * 1000 }, // at least maxAge, or the saved cache is dropped
},
});
export const persister = createAsyncStoragePersister({ storage: AsyncStorage });
// In src/app/_layout.tsx, in place of QueryClientProvider:
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, buster: "v1" }}
>
<Stack />
</PersistQueryClientProvider>Three details decide whether this works. The ReadyNative default gcTime is 5 minutes. A persisted cache is discarded after gcTime of inactivity, so set gcTime to at least maxAge, which defaults to 24 hours. Change buster when the shape of cached data changes, and every old copy is discarded. Queries wait until the restore finishes, so they do not fetch over a cache that is still loading.
Sign-out needs one more step. queryClient.clear() empties memory, but the saved copy is a separate store. Add persister.removeClient() to the same onWipeLocalData hook that ReadyNative already uses in src/lib/query.tsx.
Optimistic updates with useMutation
An optimistic update writes the expected result into the cache before the server answers. It keeps the old value so onError can roll back, and onSettled refetches to end with the server's version:
export function useSaveNote() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (note: Note) =>
fetchJson<Note>(`/notes/${note.id}`, { method: "PUT", json: note }),
onMutate: async (note) => {
await queryClient.cancelQueries({ queryKey: ["notes"] });
const previous = queryClient.getQueryData<Note[]>(["notes"]);
queryClient.setQueryData<Note[]>(["notes"], (old) =>
old?.map((n) => (n.id === note.id ? note : n))
);
return { previous };
},
onError: (_error, _note, context) => {
if (context) queryClient.setQueryData(["notes"], context.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["notes"] }),
});
}cancelQueries stops an in-flight refetch from overwriting the optimistic value. Return the invalidate promise from onSettled, so the mutation stays pending until the refetch ends. If only one component shows the change, you can skip the cache and render from variables while isPending is true.
Dev tools for React Native
TanStack's React Native page lists these options. All of them are third-party:
- Rozenite: a plugin for React Native DevTools.
- rn-better-dev-tools: a native macOS app that monitors queries across devices, for any JavaScript app.
- Flipper and Reactotron: plugins for those two tools.
TanStack Query vs SWR vs Apollo in Expo
Pick TanStack Query when your API is REST-shaped and you write mutations: it has mutations, query invalidation and the React Native devtools above. It is the recommended option in ReadyNative. SWR has the smallest API: a string key is all a read needs, and its module revalidates on foreground too. Apollo fits a GraphQL backend, with a normalised InMemoryCache and useQuery and useMutation. Its module refetches on foreground as well. Switching between them is one command: bun run setup --data react-query, --data swr, --data apollo, or --data none.
Read next: AsyncStorage vs MMKV vs expo-sqlite/kv-store for the store behind the persister, then the TanStack Query module docs.
FAQ
Does TanStack Query work in Expo Go?
Yes. TanStack Query is pure JavaScript, and expo-network and AsyncStorage also run in Expo Go. Only a native store you add later, such as MMKV, needs a development build.
Why does TanStack Query not refetch when my phone reconnects?
By default it assumes the app is always online. Connect onlineManager to a listener, such as expo-network's addNetworkStateListener, and it will refetch stale queries on reconnect.
Why does my persisted cache disappear after a few minutes?
The default gcTime is 5 minutes, and a persisted cache is discarded after gcTime of inactivity. Set gcTime to at least the persister's maxAge, which is 24 hours by default.
Should I refetch on app focus or on screen focus?
Use AppState for the whole app coming back from the background. Use useFocusEffect from expo-router for one screen that should be fresh each time the user returns to it.
More guides
- Unistyles 3 with Expo: a working setup
- Expo environment variables: which are public?
- Expo zustand: persist a store across restarts
- jest-expo setup, Expo Router tests and fixes
- Expo auth: Clerk vs Supabase vs Better Auth
- AsyncStorage vs MMKV vs expo-sqlite/kv-store
- Expo i18n: i18next or Lingui, compared
- Expo android widgets: expo-widgets on SDK 58
- Tamagui in Expo: setup, styling and themes
- Best paywall tools for Expo in 2026
- Expo widgets on iOS: a working setup
- RevenueCat subscriptions in an Expo Router app
- Supabase auth in Expo: email, Apple and Google
- Expo push notifications: setup, token, testing
- Expo Go vs development build: limitations
- The best Expo boilerplates, compared