from QueryClient to offline persistence with AsyncStorage
TanStack Query solves the exact problems that plain fetch with useEffect and useState leave open in React Native: race conditions on fast navigation, missing caching, duplicate requests, and no automatic refetch when the app resumes from background. With QueryClient, useQuery, useMutation, NetInfo reconnect handling, and an AsyncStorage persister, the REST integration becomes robust, cached, and offline capable.
Table of Contents
- 1. Why fetch, useEffect, and useState hit a wall in React Native
- 2. QueryClient and QueryClientProvider: the foundation in Expo/React Native
- 3. useQuery in practice: loading data without boilerplate
- 4. useMutation, query keys, and invalidation
- 5. Background refetching: keeping data current
- 6. Refetch on reconnect: NetInfo and the onlineManager
- 7. Offline persistence with a custom AsyncStorage persister
- 8. useInfiniteQuery: paginated lists without offset tracking
- 9. fetch, RTK Query, SWR, and TanStack Query compared
- 10. Summary
- 11. FAQ
1. Why fetch, useEffect, and useState hit a wall in React Native
The most obvious way to load data in a React Native component is a useEffect that fires a fetch call on mount, while three useState variables hold loading, error, and data. That works fine for a demo screen. Once an app has multiple screens, tabs, and fast navigation, this pattern reveals concrete gaps, and closing exactly those gaps is why TanStack Query has become the standard for data access in React Native. The first problem is race conditions: if a user switches quickly between two list filters or tabs, each switch fires a new request, but the responses are not guaranteed to arrive in the same order they were sent. Without cancellation or versioning logic, an older response can resolve after a newer one and overwrite state with stale data, with no visible error at all.
The second problem is missing caching. In React Native navigation stacks, screens are often fully remounted when navigating back, which fires the useEffect again and reloads the same data over a mobile network, even though it was fetched just seconds earlier. That costs data volume, battery, and time, especially on 4G or 3G connections. A related problem is duplicate in-flight requests: when two components on the same screen need the same resource, each fires its own fetch instead of sharing a request that is already running. Without a central cache layer, plain React has no built-in mechanism to detect such duplicates.
The third problem, particularly noticeable in mobile apps: when an app returns from background, for example after a user briefly opened another app, the old state simply sits there until the user manually pulls to refresh. Exactly these three gaps, race conditions, missing caching, and no automatic refetch on app resume, are what TanStack Query closes by treating server state as its own concern, separate from local UI state, with caching, deduplication, and lifecycle-aware refetching built in.
2. QueryClient and QueryClientProvider: the foundation in Expo/React Native
Every application using TanStack Query needs exactly one instance of QueryClient, which acts as the central cache and is made available through QueryClientProvider at the root of the app, typically in App.tsx or the root layout of Expo Router. Unlike on the web, the default options should be deliberately adjusted for React Native, because mobile network connections are less reliable and data volume is more expensive than on a desktop connection. A longer staleTime, for example one minute, prevents every return to a screen from immediately triggering a refetch, while a reduced retry count with exponential backoff prevents the app from hammering an unstable connection with endless retry attempts.
Installing the required packages includes, besides the core package, the libraries for NetInfo, AsyncStorage, and the persistence helpers needed in the following sections. All four packages are best installed right at the start, because the setup of QueryClient, focusManager, and onlineManager typically lives together in the same file.
# Core TanStack Query package for React Native / Expo
npm install @tanstack/react-query
# NetInfo: required to wire the onlineManager for refetch-on-reconnect
npm install @react-native-community/netinfo
# AsyncStorage: backing store for the offline persistence layer
npm install @react-native-async-storage/async-storage
# Persist client helpers for rehydrating the cache across app restarts
npm install @tanstack/query-async-storage-persister @tanstack/react-query-persist-client
The setup below shows how QueryClient is configured with mobile-friendly defaults, how focusManager is wired to the app lifecycle through AppState, and how onlineManager is later wired to NetInfo, before useQuery is explained in detail in the next section.
// App.tsx - QueryClient setup tuned for mobile networks
import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
useQuery,
} from '@tanstack/react-query';
// Mobile networks are less reliable than desktop connections,
// so retries and staleTime are tuned differently than the library defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute: avoid refetching on every screen focus
gcTime: 5 * 60 * 1000, // keep unused cache entries for 5 minutes
retry: 2, // fail fast on mobile instead of hammering a flaky connection
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
refetchOnReconnect: true,
},
},
});
// React Native has no window focus events, so TanStack Query needs
// AppState to know when the app returns from background to foreground
function onAppStateChange(status) {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
}
AppState.addEventListener('change', onAppStateChange);
// React Native has no navigator.onLine, so the onlineManager must be
// wired manually to NetInfo to enable refetchOnReconnect correctly
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected && state.isInternetReachable !== false);
});
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ProductListScreen />
</QueryClientProvider>
);
}
// A typical screen using useQuery instead of fetch + useEffect + useState
function ProductListScreen() {
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: ['products', { category: 'sale' }],
queryFn: async () => {
const response = await fetch('https://api.example.com/products?category=sale');
if (!response.ok) {
throw new Error('Failed to load products');
}
return response.json();
},
});
if (isLoading) {
return <LoadingSpinner />;
}
if (isError) {
return <ErrorView message={error.message} onRetry={refetch} />;
}
return <ProductList products={data} isRefreshing={isFetching} />;
}
3. useQuery in practice: loading data without boilerplate
The useQuery hook from TanStack Query replaces the three manual useState variables from the introduction with a single object of clearly defined fields. The first parameter, the query key, is an array that uniquely identifies the query, in the example above ['products', { category: 'sale' }]. The second parameter, the query function, is a simple async function that loads the data and throws on error, instead of manually setting an error state. Important for good UX in React Native is the difference between isLoading, which is only true on the very first load without cached data, and isFetching, which also becomes true on every background refetch. This distinction lets you show a full skeleton on first load and only a subtle spinner at the edge of the screen for later refetches.
A key advantage of TanStack Query over manual fetch is automatic deduplication: when multiple components on the same screen use the same query key, they share a single in-flight request and one cache entry, instead of independently querying the same resource. This solves the duplicate in-flight request problem right at the root, without developers having to write their own locking or debouncing logic.
Also built in is a retry mechanism with exponential backoff, which automatically retries transient network errors before passing an error through to the UI. On mobile devices that frequently switch between WiFi and cellular, this prevents a one-second connection blip from immediately triggering an error screen, even though the connection stabilizes again fractions of a second later.
4. useMutation, query keys, and invalidation
While useQuery handles reads, useMutation takes care of writes, meaning POST, PUT, and DELETE calls against the REST API. After a successful mutation, you typically call queryClient.invalidateQueries with a matching query key so that affected lists refetch automatically, without having to manually adjust the local state of the list. This combination of useMutation and invalidation is one of the reasons why TanStack Query in React Native saves so much boilerplate compared to manual state management.
For invalidation to work precisely, query keys need to be thoughtfully structured. An array like ['products', { category: 'sale' }] lets you invalidate exactly that combination, while a call with just ['products'] invalidates every variant of this query family, regardless of filter. This hierarchical structure of query keys is a core concept to understand, so invalidation is neither too broad, causing unnecessary refetches, nor too narrow, missing stale caches.
For an instantly responsive UI, optimistic updates are a good fit: instead of waiting for the server response, you change the cache in onMutate before the request is even sent, roll the change back in onError if the mutation fails, and trigger an invalidation in onSettled to finally reconcile the cache with the server. This pattern of snapshot, optimistic change, rollback, and final reconciliation is standardized in TanStack Query and makes optimistic updates in React Native considerably more robust than hand-rolled solutions.
// useMutation with optimistic update and query invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId) => {
const response = await fetch(`https://api.example.com/products/${productId}/favorite`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to toggle favorite');
}
return response.json();
},
// Optimistic update: flip the UI instantly, before the server responds
onMutate: async (productId) => {
const queryKey = ['products', { category: 'sale' }];
// Cancel outgoing refetches so they do not overwrite the optimistic value
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous value for rollback on error
const previousProducts = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old) =>
old?.map((product) =>
product.id === productId
? { ...product, isFavorite: !product.isFavorite }
: product
)
);
return { previousProducts, queryKey };
},
// Roll back to the snapshot if the mutation fails
onError: (error, productId, context) => {
if (context?.previousProducts) {
queryClient.setQueryData(context.queryKey, context.previousProducts);
}
},
// Always resync with the server after the mutation settles
onSettled: (data, error, productId, context) => {
queryClient.invalidateQueries({ queryKey: context.queryKey });
queryClient.invalidateQueries({ queryKey: ['product', productId] });
},
});
}
5. Background refetching: keeping data current
Background refetching is one of the places where TanStack Query makes the biggest difference over a manual solution, because it works almost invisibly in the background. A cached query counts as fresh until staleTime expires, after which it gets marked stale on the next mount or focus and refetches automatically, while the previous data continues to be displayed. It is worth understanding the difference between staleTime, which determines how long data counts as fresh, and gcTime, which determines how long an unused cache entry stays in memory at all before it gets garbage collected.
On React Native, background refetching is tightly coupled to the app lifecycle. Because native apps can move into a background state without the JavaScript environment restarting, the focusManager.setFocused call shown in the earlier setup, tied to AppState, registers every transition between active and background. When the app returns from background, TanStack Query automatically marks every stale query for a refetch, exactly the capability that is completely missing from plain fetch and useEffect, and that otherwise forces users into a manual pull to refresh.
Additionally, refetchInterval lets you define a polling interval, for example for an order status screen that should check every 15 seconds whether the status has changed, as long as the screen is visible. Combined with the AppState listener, this polling automatically pauses as soon as the app moves to background, so no unnecessary requests run while the user is not even looking at the app.
6. Refetch on reconnect: NetInfo and the onlineManager
In the browser, TanStack Query relies by default on navigator.onLine and the online/offline events of the window object to detect when a connection has been restored. In React Native, this browser API simply does not exist, which means the built-in onlineManager does not work at all in a native app without additional configuration. This is where @react-native-community/netinfo comes in, the standard library for connectivity status in React Native, which queries the device's actual network status through native modules.
The wiring happens through onlineManager.setEventListener, as shown in the setup code in section 2: NetInfo delivers an object with isConnected and isInternetReachable on every status change. The difference between the two matters, because a device can very well be connected to a WiFi router, meaning isConnected is true, without that router actually having internet access, which sets isInternetReachable to false. A robust implementation therefore checks both values before reporting to TanStack Query that the device is online.
Once the onlineManager is correctly wired to NetInfo, refetchOnReconnect kicks in automatically: if a device loses connectivity and regains it seconds or minutes later, for example after leaving an elevator or a subway tunnel, TanStack Query refetches every query marked stale on its own, without the application having to implement its own network monitoring logic.
7. Offline persistence with a custom AsyncStorage persister
While background refetching and reconnect handling govern how the cache stays current during a running app session, persistence solves a different problem: what does the app show right after a cold start, before any network response has arrived. Without persistence, the cache starts empty on every app launch, so even a user with a solid earlier data state sees a loading state again. TanStack Query solves this through persistQueryClient combined with a persister that stores the cache in persistent storage and restores it on the next launch.
For React Native, AsyncStorage is the natural storage location, and the @tanstack/query-async-storage-persister library provides a ready-made persister that internally calls AsyncStorage.getItem and AsyncStorage.setItem to read and write the serialized cache. Alternatively, you can write a custom persister that only needs to implement three methods, persistClient, restoreClient, and removeClient, which makes sense whenever a storage backend other than AsyncStorage should be used, for example an encrypted secure storage for sensitive data.
Important configuration options include maxAge, which defines how old a restored cache is allowed to be before it gets discarded, and buster, a string that can be bumped on every app update to automatically invalidate stale cache formats after a schema change. With dehydrateOptions, you can also control which queries get persisted at all, for example deliberately excluding auth tokens or very large lists.
{
"persistOptions": {
"maxAge": 86400000,
"buster": "app-version-1.4.0",
"dehydrateOptions": {
"shouldDehydrateQuery": "query.queryKey[0] !== 'auth' && query.state.status === 'success'"
}
},
"asyncStoragePersisterOptions": {
"storageKey": "TANSTACK_QUERY_OFFLINE_CACHE",
"throttleTime": 1000,
"serialize": "JSON.stringify(client)",
"deserialize": "JSON.parse(cachedString)"
}
}
8. useInfiniteQuery: paginated lists without offset tracking
For paginated lists, such as a product catalog or an activity feed loaded through endless scroll in a FlatList, TanStack Query offers useInfiniteQuery, a dedicated hook that makes manually tracking offset or page number in local state unnecessary. The query function receives a pageParam used to request the next page, while getNextPageParam reads from the API response whether and which cursor or page number should be used for the next request.
The return value of useInfiniteQuery includes, besides the usual fields like isLoading, additionally data.pages, an array of all pages already loaded, as well as fetchNextPage, hasNextPage, and isFetchingNextPage. In a FlatList, you combine onEndReached with a call to fetchNextPage once hasNextPage is true and no page is currently loading, while ListFooterComponent shows a loading indicator while more data loads.
It is important that caching and invalidation for useInfiniteQuery work exactly the same way as for useQuery: the query key identifies the entire paginated list, and an invalidateQueries call with that key reloads from the first page on the next access, which is especially useful after a mutation that may have changed the list's sorting or filtering.
// useInfiniteQuery for a paginated product list rendered in a FlatList
import { useInfiniteQuery } from '@tanstack/react-query';
import { FlatList, ActivityIndicator } from 'react-native';
function usePaginatedProducts() {
return useInfiniteQuery({
queryKey: ['products', 'paginated'],
queryFn: async ({ pageParam = 1 }) => {
const response = await fetch(`https://api.example.com/products?page=${pageParam}&limit=20`);
if (!response.ok) {
throw new Error('Failed to load page');
}
return response.json(); // { items: [...], nextPage: number | null }
},
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
}
function PaginatedProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = usePaginatedProducts();
if (isLoading) {
return <ActivityIndicator />;
}
const products = data?.pages.flatMap((page) => page.items) ?? [];
return (
<FlatList
data={products}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => <ProductRow product={item} />}
onEndReachedThreshold={0.4}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
9. fetch, RTK Query, SWR, and TanStack Query compared
For data access in React Native, several established libraries exist besides plain fetch with useEffect, all pursuing a similar purpose but differing considerably in scope and mobile-specific integration. The table below compares TanStack Query against the alternatives Redux Toolkit Query and SWR, as well as the manual solution, along the criteria covered in the previous sections.
| Criterion | fetch + useEffect + useState | Redux Toolkit Query | SWR | TanStack Query |
|---|---|---|---|---|
| Caching & deduplication | None, every mount reloads | Yes, integrated via the Redux store | Yes, similar cache model | Yes, query keys as cache keys |
| Offline persistence | Must be built entirely from scratch | Requires redux-persist on top | No built-in RN persister | persistQueryClient with an AsyncStorage persister |
| Optimistic updates | Manual, lots of boilerplate | Possible via createAsyncThunk | Via mutate() with optimistic data | Standardized onMutate/onError pattern |
| NetInfo/AppState integration | None, fully hand-wired | No built-in mechanism | Custom provider needed, little RN documentation | onlineManager/focusManager as official extension points |
| Bundle size / dependencies | No extra dependency | Requires the full Redux Toolkit | Very small, minimalist | Moderate, modular through plugins |
The table shows that plain fetch with useEffect does not win in any category against a dedicated library, and is only reasonable for small prototypes without meaningful concurrency. Among the three libraries, the existing stack usually decides: teams already on Redux benefit from RTK Query, teams wanting maximum leanness reach for SWR. For React Native apps with genuine offline requirements, optimistic updates, and clean NetInfo integration, TanStack Query offers the most complete, officially documented solution without additional wrapper libraries.
10. Summary
TanStack Query solves the three central weaknesses of plain fetch with useEffect and useState in React Native: race conditions on fast navigation, missing caching with duplicate requests, and no automatic refetch when returning from background. A central QueryClient with mobile-friendly defaults for staleTime and retry behavior forms the foundation, useQuery and useMutation replace manual state management, and query keys with invalidation keep lists and detail views in sync.
For the mobile-specific requirements, wiring onlineManager to NetInfo delivers reliable refetch on reconnect, while focusManager triggers background refetching on app resume through AppState. An AsyncStorage persister via persistQueryClient ensures users already see data right after a cold start instead of staring at an empty cache, and useInfiniteQuery makes paginated lists possible without manual offset tracking.
TanStack Query in React Native, the essentials at a glance
QueryClient setup
A central QueryClient with tuned staleTime, gcTime, and retry backoff, provided through QueryClientProvider at the app root.
useQuery & useMutation
Reads through useQuery with automatic deduplication, writes through useMutation with query key invalidation and optimistic updates.
NetInfo & persistence
onlineManager wired to NetInfo for reconnect handling, an AsyncStorage persister for an offline cache across app restarts.
Infinite queries
useInfiniteQuery with getNextPageParam for paginated FlatLists, without manual offset or page tracking in local state.