React Query (TanStack Query) solves a problem that every mobile developer eventually hits: server state is fundamentally different from client state, and trying to manage both with the same tool (Redux, Zustand, or Context) leads to a tangled mess of loading flags, stale data, and manual cache invalidation.Server state is data you do not own — it lives on a remote server, can be changed by other users at any time, and becomes stale the moment you fetch it. React Query treats this as a caching problem: it fetches data, caches it, keeps it fresh in the background, and gives you simple hooks to read the cache. This module covers caching strategies, background updates, optimistic updates, and building a robust data fetching layer.What You’ll Learn:
The two most important settings are staleTime (how long cached data is considered fresh) and cacheTime (how long unused data stays in memory). Setting these correctly has a direct impact on your app’s battery consumption and perceived performance — too low and you waste bandwidth on redundant requests, too high and users see outdated information.
// src/api/queryClient.tsimport { QueryClient } from '@tanstack/react-query';import { ExpoQueryClient } from '@tanstack/react-query-expo';export const queryClient = new QueryClient({ defaultOptions: { queries: { // Data is "fresh" for 5 minutes -- no refetch during this window. // On mobile, this is critical for reducing unnecessary network calls // when users navigate back and forth between screens. staleTime: 5 * 60 * 1000, // Unused data lives in memory for 30 minutes before garbage collection. // This means navigating to a screen you visited 20 minutes ago loads instantly. cacheTime: 30 * 60 * 1000, // Retry failed requests 3 times retry: 3, // Don't refetch on window focus in production refetchOnWindowFocus: __DEV__, // Don't refetch on reconnect automatically refetchOnReconnect: 'always', }, mutations: { // Retry mutations once retry: 1, }, },});export const expoQueryClient = new ExpoQueryClient(queryClient);
// providers/QueryProvider.tsximport { QueryClientProvider } from '@tanstack/react-query';import { ExpoQueryClientProvider } from '@tanstack/react-query-expo';import { queryClient } from '@/api/queryClient';export function QueryProvider({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <ExpoQueryClientProvider> {children} </ExpoQueryClientProvider> </QueryClientProvider> );}
Mutations are for operations that change data on the server — creating, updating, or deleting records. While queries are read-only and can be freely cached, retried, and deduplicated, mutations are write operations with side effects. React Query gives you a structured lifecycle (onMutate, onError, onSettled) that makes implementing optimistic updates and rollbacks straightforward.
Infinite scrolling is the dominant pattern for mobile lists — users expect to scroll continuously and see new content loaded automatically, rather than tapping “Page 2” buttons. React Query’s useInfiniteQuery manages this seamlessly: it tracks page cursors, accumulates results across pages, and provides fetchNextPage / hasNextPage for your scroll handler.
Dependent queries let you chain data fetching — “fetch X, then use X’s result to fetch Y.” The enabled option is the key: when set to false, the query sits idle. When the dependency resolves and enabled becomes true, the query fires automatically.
// Fetch user data, then fetch their posts only after the user loads.// This avoids making the posts request with an undefined userId.function useUserPosts(userId: string) { const { data: user } = useUser(userId); return useQuery({ queryKey: ['users', userId, 'posts'], queryFn: async () => { const response = await apiClient.get(`/users/${userId}/posts`); return response.data; }, enabled: !!user, // Query stays disabled until user data is available });}
Optimistic updates are the secret to making your app feel instant. The idea: when a user performs an action (like toggling a status), immediately update the UI as if the server already confirmed it, then sync with the server in the background. If the server rejects the change, roll back to the previous state.This pattern is especially important on mobile where network latency is unpredictable. A 300ms delay that is barely noticeable on desktop WiFi feels sluggish on a cellular connection.
Not every screen needs the same fetching approach. The decision depends on how frequently the data changes, how critical freshness is to the user experience, and how expensive the API call is.
Strategy
staleTime
Best For
Trade-off
Always fresh
0 (default)
Chat messages, live scores, stock prices
High network usage, but users never see stale data
Short cache
1-5 min
User profiles, notification counts, dashboards
Good balance for data that changes occasionally
Long cache
15-60 min
Reference data, settings, feature flags
Minimal network usage, but users may see outdated data
Manual only
Infinity
Static content, app configuration, onboarding data
Zero automatic refetches — you control updates entirely via invalidateQueries
Decision framework:
How often does this data change on the server? If the answer is “rarely,” set a long staleTime. If “constantly,” keep it short or use WebSocket subscriptions instead of polling.
What happens if the user sees stale data? For a social feed, stale by 30 seconds is fine. For an account balance, stale by 30 seconds could cause a failed transaction.
How expensive is the API call? If the endpoint is slow or rate-limited, cache aggressively and prefetch on navigation intent rather than on mount.
Is the user on mobile data? This is the argument for longer staleTime defaults in React Native versus web apps. Every unnecessary refetch costs battery and cellular data.
A common question is when React Query is the right tool versus alternatives. Here is how they compare for mobile data fetching:
Capability
React Query
SWR
Redux Toolkit Query
Apollo Client
Caching
Automatic, configurable
Automatic, simpler API
Automatic, tag-based
Normalized cache
Optimistic updates
Built-in lifecycle hooks
Manual
Built-in
Built-in
Infinite scroll
useInfiniteQuery
Manual cursor tracking
Manual
fetchMore
Offline support
Via persistors and Expo plugin
Manual
Manual
apollo3-cache-persist
Bundle size
~13 KB gzip
~4 KB gzip
Part of RTK (~11 KB)
~33 KB gzip
React Native support
Excellent (official Expo plugin)
Good (no official RN plugin)
Good
Good
DevTools
Excellent (Flipper plugin)
Limited on RN
Redux DevTools
Apollo DevTools
Learning curve
Moderate
Low
High (requires Redux)
High (requires GraphQL)
When to use React Query: REST or GraphQL APIs, teams that want powerful caching without Redux boilerplate, apps where offline support and background refetching matter.When to consider alternatives: SWR if you want minimal bundle size and simpler API. Apollo Client if your backend is exclusively GraphQL and you need normalized caching. RTK Query if your app already uses Redux for client state.
When a parent query’s data changes (e.g., user switches accounts), dependent queries still hold cached data from the previous account. If you do not clear the cache on account switch, a user could briefly see another user’s data.
// Always reset the query cache on account/user switchconst handleAccountSwitch = async (newAccountId: string) => { // Remove all cached data before switching queryClient.removeQueries(); // Now switch accounts await switchAccount(newAccountId);};
A subtle bug: if your onSuccess callback references component state via a closure, it captures the state value at the time the mutation was initiated, not when it completes. For mutations that take several seconds, the state may have changed.
// Problematic: `selectedFilter` might be stale when onSuccess runsconst createTask = useMutation({ mutationFn: createTaskApi, onSuccess: () => { // This invalidates based on the filter value from when mutate() was called, // not when the server responded. If the user changed tabs, wrong query is invalidated. queryClient.invalidateQueries({ queryKey: ['tasks', selectedFilter] }); },});// Better: invalidate broadly, or read current state inside the callbackconst createTask = useMutation({ mutationFn: createTaskApi, onSuccess: () => { // Invalidate all task queries regardless of filter queryClient.invalidateQueries({ queryKey: ['tasks'] }); },});
useInfiniteQuery accumulates all fetched pages in memory. For feeds with hundreds of pages, this can consume significant memory on low-end devices. React Query v5 introduced maxPages to cap this:
useInfiniteQuery({ queryKey: ['feed'], queryFn: fetchFeedPage, getNextPageParam: (lastPage) => lastPage.nextCursor, // Keep at most 5 pages in memory. Scrolling back past this // re-fetches from the server. maxPages: 5, initialPageParam: 0,});
When a mobile device transitions from airplane mode or a tunnel back to connectivity, React Query’s refetchOnReconnect fires for every stale query simultaneously. On an app with 50+ query keys, this can overwhelm your API.
// Stagger refetches on reconnect instead of firing all at onceconst queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnReconnect: false, // Disable automatic reconnect refetch }, },});// Manually refetch in priority order when connectivity returnsNetInfo.addEventListener((state) => { if (state.isConnected) { // Refetch critical data first queryClient.invalidateQueries({ queryKey: ['auth'] }); // Delay non-critical refetches setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['feed'] }); }, 2000); }});
Use query key factories. Instead of spreading string arrays across your codebase, centralize query keys in a factory object. This prevents typos, makes invalidation patterns discoverable, and keeps your code DRY:
export const queryKeys = { users: { all: ['users'] as const, detail: (id: string) => ['users', id] as const, posts: (id: string) => ['users', id, 'posts'] as const, }, projects: { all: ['projects'] as const, detail: (id: string) => ['projects', id] as const, tasks: (id: string) => ['projects', id, 'tasks'] as const, },};
Separate query hooks from components. Every useQuery call should live in its own custom hook file, not inline in a component. This makes queries reusable, testable in isolation, and keeps components focused on rendering.
Set retry: false for mutations. Queries are safe to retry because they are read-only. Mutations are not — retrying a POST /orders could create duplicate orders. Set retry: false or retry: 1 for mutations, and use idempotency keys on the server side.
Use placeholderData instead of separate loading states for detail screens. When navigating from a list to a detail screen, seed the detail query with the list item data so the screen renders instantly while the full data loads in the background:
export function useUser(userId: string) { const queryClient = useQueryClient(); return useQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), placeholderData: () => { // Try to use data from the users list as placeholder const usersData = queryClient.getQueryData<User[]>(queryKeys.users.all); return usersData?.find((user) => user.id === userId); }, });}