Server Data with TanStack Query
Raw fetch in useEffect means juggling loading, error and refetch by hand — TanStack Query does all of it for you and caches the result.
What you will learn
- Explain why server data needs more than useState
- Fetch with useQuery and get loading/error/data for free
- Refetch, cache and send data back with useMutation
Why raw fetch gets messy
Earlier you fetched data with fetch inside useEffect, plus a loading flag and an error state you managed by hand. That works for one screen, but real apps do this on every screen and quickly need more: show a cached result instantly, refetch when the user pulls to refresh, avoid re-fetching the same thing twice, retry on a flaky network. Writing all that yourself, everywhere, is a lot of fiddly code.
Server state is data that actually lives on a server — users, products, posts — not in your app. It is different from normal local state because it can go stale (the server’s copy changed) and must be re-fetched. TanStack Query (also called React Query) is the modern standard for handling exactly this: it fetches, caches (remembers the last result so it shows instantly next time), tracks loading and error states, and refetches when needed — all from a couple of hooks.
One-time setup
Install the library, then wrap your app once in a QueryClientProvider (the holder for the shared cache):
npx expo install @tanstack/react-queryNote: Output: (Adds the package. Nothing on screen yet.)
Next, create one QueryClient (the object that owns the cache) and wrap your whole app in a QueryClientProvider that hands it down. You do this once, at the very top — every screen inside the provider can then use queries and share the same cache:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<UsersScreen />
</QueryClientProvider>
);
}Note: Output:
No screen output — this creates the shared cache (queryClient) and makes it available to every screen inside the provider.
Fetching with useQuery
Now the payoff. useQuery takes a queryKey (a unique label for this data, used as its cache entry) and a queryFn (a function that fetches and returns the data). In return it hands you data, isLoading and isError — the three states you used to manage by hand:
import { useQuery } from '@tanstack/react-query';
import { FlatList, Text, ActivityIndicator } from 'react-native';
function UsersScreen() {
const { data, isLoading, isError } = useQuery({
queryKey: ['users'],
queryFn: () =>
fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json()),
});
if (isLoading) return <ActivityIndicator size="large" />;
if (isError) return <Text>Could not load users.</Text>;
return (
<FlatList
data={data}
keyExtractor={(u) => String(u.id)}
renderItem={({ item }) => <Text style={{ padding: 12 }}>{item.name}</Text>}
/>
);
}Note: Output:
A spinner for a moment, then a scrollable list of names like:
Leanne Graham
Ervin Howell
...
Compare with the earlier raw-fetch lesson: there is no useEffect, no useState for loading or data, no [] dependency to remember. useQuery provides isLoading, isError and data for you.
The caching win, step by step
The biggest difference is the cache. Here is what happens the second time you visit this screen, in order:
- The first visit fetches the users and stores the result in the cache under the key
['users']. - You navigate away to another tab. The data stays in the cache.
- You come back to the users screen.
useQueryfinds['users']already in the cache and shows it instantly — no spinner, no blank screen. - In the background, if the data is considered stale, it quietly re-fetches and updates the list if anything changed.
- Because the key is shared, two different screens asking for
['users']reuse the same cached data instead of fetching twice.
Refetch and pull-to-refresh
useQuery also gives you a refetch function and an isRefetching flag, which plug straight into a FlatList’s pull-to-refresh:
const { data, refetch, isRefetching } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
<FlatList
data={data}
onRefresh={refetch}
refreshing={isRefetching}
keyExtractor={(u) => String(u.id)}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>Note: Output:
Pull the list down -> a spinner shows (isRefetching is true) while refetch runs -> the list updates with fresh data. You wrote no extra state for this.
Sending data back with useMutation
useQuery is for reading data. To change data on the server (create, update, delete) you use useMutation. It gives you a mutate function to call, and you can tell it to refresh related queries afterwards so the screen shows the new data:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useAddUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name) =>
fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify({ name }),
}),
onSuccess: () => {
// refresh the users list so the new one appears
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}Note: Output:
Calling mutate('Rohan') POSTs the new user. On success, invalidateQueries marks ['users'] stale, so that list re-fetches and Rohan appears — no manual state shuffling.
Tip: A good mental split: useQuery to read server data, useMutation to write it. After a mutation, invalidateQueries is the usual way to tell the matching query to refresh.
Watch out: Keep using plain useState for UI state (a text box, a modal’s open/closed flag). TanStack Query is for server state — data that comes from and goes back to an API. Mixing the two up leads to confusing code.
Q. What does useQuery give you that a raw fetch in useEffect does not?
useQuery provides isLoading, isError and data for you and caches the result by its query key, so revisiting a screen shows data instantly and avoids duplicate fetches.✍️ Practice
- Convert the earlier raw-fetch users screen to
useQueryand delete theuseState/useEffectyou no longer need. - Add pull-to-refresh to a FlatList using
refetchandisRefetching.
🏠 Homework
- Fetch a list from any public API with
useQuery, show loading and error states, and add a refetch button that re-runs the query.