React Query
TanStack Query (a.k.a. React Query) is the de-facto standard for data fetching in React Native. It handles caching, deduping, retries, background refetch, offline behaviour, and mutations — everything you’d otherwise reinvent badly with useEffect.
queries, mutations, offline, focus refetch
EXAMPLE
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
import { AppState, AppStateStatus, Platform } from 'react-native';
import { focusManager, onlineManager } from '@tanstack/react-query';
import React, { useEffect } from 'react';
// 1) One client per app
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 min — never refetch within this window
gcTime: 5 * 60_000, // formerly cacheTime
retry: 2,
refetchOnReconnect: true,
},
},
});
// 2) Online / focus integration — RN doesn't have window.onFocus / navigator.onLine
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => setOnline(!!state.isConnected));
});
focusManager.setEventListener((handleFocus) => {
const sub = AppState.addEventListener('change', (status: AppStateStatus) => {
if (Platform.OS !== 'web') handleFocus(status === 'active');
});
return () => sub.remove();
});
// 3) Provider
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppNavigator />
</QueryClientProvider>
);
}
// 4) Basic query
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: () => fetch('https://api.example.com/todos').then((r) => r.json()),
});
}
function TodoList() {
const { data, isPending, isError, refetch, isFetching } = useTodos();
if (isPending) return <ActivityIndicator />;
if (isError) return <Text>Failed to load. <Button title="Retry" onPress={() => refetch()} /></Text>;
return (
<FlatList
data={data}
keyExtractor={(t) => t.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
refreshing={isFetching}
onRefresh={refetch}
/>
);
}
// 5) Parameterised query
function useTodo(id) {
return useQuery({
queryKey: ['todo', id],
queryFn: () => fetch(`https://api.example.com/todos/${id}`).then((r) => r.json()),
enabled: !!id,
});
}
// 6) Mutations
function useToggleTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, done }: { id: string; done: boolean }) =>
fetch(`https://api.example.com/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done }),
}).then((r) => r.json()),
onSuccess: (_data, { id }) => {
qc.invalidateQueries({ queryKey: ['todo', id] });
qc.invalidateQueries({ queryKey: ['todos'] });
},
});
}
function TodoRow({ todo }) {
const toggle = useToggleTodo();
return (
<Pressable onPress={() => toggle.mutate({ id: todo.id, done: !todo.done })}>
<Text style={{ textDecorationLine: todo.done ? 'line-through' : 'none' }}>{todo.title}</Text>
</Pressable>
);
}
// 7) Optimistic updates
function useToggleTodoOptimistic() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, done }) =>
fetch(`https://api.example.com/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done }),
}),
onMutate: async ({ id, done }) => {
await qc.cancelQueries({ queryKey: ['todos'] });
const previous = qc.getQueryData(['todos']);
qc.setQueryData(['todos'], (rows) => rows.map((t) => t.id === id ? { ...t, done } : t));
return { previous };
},
onError: (_e, _vars, ctx) => qc.setQueryData(['todos'], ctx.previous),
onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
}
// UI flips instantly; if the server rejects, the previous list rolls back.
// 8) Infinite scroll
import { useInfiniteQuery } from '@tanstack/react-query';
function useFeed() {
return useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) =>
fetch(`https://api.example.com/feed?cursor=${pageParam ?? ''}`).then((r) => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();
const items = data?.pages.flatMap((p) => p.items) ?? [];
return (
<FlatList
data={items}
keyExtractor={(i) => i.id}
renderItem={({ item }) => <PostCard post={item} />}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
// 9) Persisting the cache across launches
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
persistQueryClient({ queryClient, persister, maxAge: 1000 * 60 * 60 * 24 });
// On launch, queries hydrate from AsyncStorage → app shows last-known data immediately, then refetches in the background.
// 10) Suspense mode (React 18+)
function TodoTitle({ id }) {
const { data } = useSuspenseQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) });
return <Text>{data.title}</Text>;
}
// Wrap in <Suspense fallback={…}>; the loading state moves to a boundary instead of every component.
// 11) Query keys — design them like API paths
// ['todos'] list
// ['todos', { filter: 'open' }] filtered list
// ['todos', id] single doc
// ['users', uid, 'todos'] user's todos
// Stable keys = predictable caching. Use a 'query-key factory' for big apps.
// 12) Throw on error vs return error
// queryFn should throw on non-2xx. Otherwise React Query treats the response as success.
async function fetchTodos() {
const r = await fetch('https://api.example.com/todos');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
// 13) Cancellation
useQuery({
queryKey: ['search', term],
queryFn: async ({ signal }) => {
const r = await fetch(`/search?q=${term}`, { signal });
return r.json();
},
});
// When the query key changes (new search term), the previous request is aborted.
// 14) Background sync on app focus
// Already handled by focusManager above. New screens automatically refetch when they mount
// if data is older than staleTime.
// 15) Devtools
// In RN, use @tanstack/react-query-devtools-rn or expose state in a debug screen.
// For Expo + React Navigation, a 'devtools' drawer screen helps a lot.
// 16) Common bugs
// • Query keys not stable → infinite refetch loops (objects with new identity each render)
// • staleTime: 0 → every focus triggers a fetch; battery and bandwidth hit
// • Mutating data directly instead of invalidateQueries → cache shows stale data after server change
// • Optimistic update without onError rollback → stuck UI on failures
// • queryFn returns a non-throwing error response → cache 'success' with error body
// • useEffect-based loading + React Query in the same screen → competing fetches
// • Refresh control without isFetching guard → UI flickers between idle and pulling
Why it matters
TanStack Query in RN means hooking onlineManager to NetInfo and focusManager to AppState so reconnect-refetch and app-resume-refetch actually fire. Layer in persistQueryClient with AsyncStorage so the app boots with last-known data, optimistic mutations for snappy UI, and stable query keys so the cache works for you instead of against you.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import { useQuery } from '@tanstack/react-query';
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/users').then(r => r.json())
});
Try it Yourself »
Discussion
Loading…