Server State with TanStack Query
Fetch, cache and keep server data fresh with one hook — the modern replacement for hand-written useEffect fetching.
What you will learn
- Explain why server state is different from local state
- Fetch data with the useQuery hook
- Send changes with useMutation
Server state is a different beast
You already fetch data with useEffect + fetch + three pieces of state (loading, error, data). That works, but real apps need much more: caching so you do not re-fetch the same data, refetching when the user comes back to the tab, retrying failed requests, and keeping many screens in sync. Writing all that by hand for every screen is a lot of repetitive, bug-prone code.
Server state — data that lives on a server and you only borrow a copy of — is genuinely different from local state (a counter, a form field). TanStack Query (the library is imported as @tanstack/react-query, and people often call it React Query) is built specifically for server state. It does the caching, refetching and retrying for you, so your components stay tiny.
One-time setup
You install the library and wrap your app once in a provider that holds the cache. This is real project code, shown with its Output.
npm install @tanstack/react-query// main.jsx — wrap the app once
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
// <QueryClientProvider client={queryClient}>
// <App />
// </QueryClientProvider>The QueryClient is the cache — the shared memory where fetched data is stored. Wrapping <App /> in <QueryClientProvider> makes that cache available to every component, the same way a context provider shares a value. You do this once and forget about it.
Reading data with useQuery
Now any component can fetch data with the useQuery hook. Compare how much smaller this is than the useEffect version.
import { useQuery } from '@tanstack/react-query';
function fetchUsers() {
return fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json());
}
function UserList() {
const { data, isLoading, isError } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Could not load users.</p>;
return (
<ul>
{data.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}The two things you give useQuery and the three things you get back:
queryKey: ['users']is a unique name for this piece of data. React Query uses it as the cache label — ask for['users']again anywhere in the app and it serves the cached copy instead of re-fetching.queryFn: fetchUsersis the function that actually fetches. React Query calls it for you, and will retry it automatically if it fails.- You get back
isLoading,isErroranddataready to use — nouseState, nouseEffect, no manual flags. The three-state pattern is handled for you.
Note: Output: Briefly Loading…, then the list of user names. Navigate away and back, and the list appears instantly from the cache while React Query quietly refetches in the background to keep it fresh.
Changing data with useMutation
Reading data is useQuery; changing it (create, update, delete) is useMutation. After a successful change you tell React Query to refetch the affected data so the screen updates.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) =>
fetch('/api/users', { method: 'POST', body: JSON.stringify(newUser) }),
onSuccess: () => {
// refetch the 'users' list so the UI updates
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
return (
<button onClick={() => mutation.mutate({ name: 'Asha' })}>
Add user
</button>
);
}The flow: mutationFn is the function that sends the change to the server (here a POST). Clicking the button calls mutation.mutate({ name: 'Asha' }) to run it. When it succeeds, onSuccess runs queryClient.invalidateQueries({ queryKey: ['users'] }) — this marks the cached ['users'] list as stale, so React Query automatically refetches it and every screen showing that list updates. You never manually push the new user into state.
Tip: In MERN this points at your own Node/Express API instead of a public URL — exactly like the plain-fetch lesson, but with caching, retries and background refresh handled for you.
Watch out: React Query replaces useEffect fetching for server data only. Keep useState/useReducer for local UI state (form text, a toggle, the current tab). The two solve different problems.
Q. What is the queryKey used for in useQuery?
✍️ Practice
- Rewrite the JSONPlaceholder users fetch using
useQueryinstead ofuseEffect, withisLoadingandisError. - Add a second
useQueryfor/postsand confirm each has its ownqueryKey.
🏠 Homework
- Add a
useMutationthat “adds” an item and usesinvalidateQueriesto refresh the list. Write two sentences on what React Query did for you that useEffect did not.