In lesson 12 you wrote data loading by hand: loading state, error state, cancelling old requests. It works, but you repeat it in every component, and many things are still missing: caching, refreshing, retrying, and keeping the screen in step after a change. TanStack Query, also called React Query, solves all of this. It is one of the most useful libraries in the React world.
Server state is different
Data that comes from a server is not like your own UI state. It lives somewhere else, it can change without you knowing, it can be shared by many components, and it can become stale (out of date). Handling that with useState and useEffect is hard to do well. TanStack Query is built for exactly this kind of state, so you can stop writing that code.
Install and set up
npm install @tanstack/react-query
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
The QueryClient is the cache. The provider makes it available to every component, like the context you saw before.
useQuery: reading data
import { useQuery } from '@tanstack/react-query';
async function fetchPosts() {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5');
if (!response.ok) throw new Error('Request failed');
return response.json();
}
function Posts() {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p role="alert">Could not load posts. <button onClick={() => refetch()}>Try again</button></p>;
return <ul>{data.map((post) => <li key={post.id}>{post.title}</li>)}</ul>;
}
Compare this with lesson 12: there is no useEffect, no three useState calls, and no AbortController. You give two things:
- `queryKey`: an array that names this data. It works like an address in the cache.
- `queryFn`: a function that returns a promise with the data. If it throws, the query is in the error state.
You get back flags such as isPending, isError, data and error, and a refetch function. Note that fetch does not throw on a 404, so we check response.ok and throw ourselves.
Query keys: the heart of caching
The key identifies the data. Two components that ask for the same key share one request and one cached result. If the key has a variable in it, a new value means a new query:
function Post({ id }) {
const { data } = useQuery({
queryKey: ['post', id],
queryFn: () => fetch('/api/posts/' + id).then((r) => r.json()),
});
return <h2>{data?.title}</h2>;
}
When id changes from 1 to 2, the key changes from ['post', 1] to ['post', 2], so TanStack Query loads post 2. If you go back to post 1, it shows the cached result at once and refreshes it quietly in the background. That is why apps built with it feel fast. Race conditions, where old data overwrites new data, are handled for you.
Stale data and refetching
Cached data is marked stale after a time, and by default that time is zero, so data refetches in the background when a component mounts, when the window gets focus again, and when the connection comes back. You can change this with staleTime, how long the data counts as fresh:
useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
});
For data that rarely changes, use a long staleTime. For live data such as prices, use a short one, or add refetchInterval. Failed requests are also retried a few times by default, which is kind to flaky connections.
useMutation: changing data
Reading uses useQuery. Creating, updating and deleting use useMutation:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddPost() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const mutation = useMutation({
mutationFn: (newPost) =>
fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
}).then((r) => {
if (!r.ok) throw new Error('Save failed');
return r.json();
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
setTitle('');
},
});
function handleSubmit(event) {
event.preventDefault();
mutation.mutate({ title });
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Add'}</button>
{mutation.isError && <p role="alert">Could not save. Please try again.</p>}
</form>
);
}
mutate(data)runs themutationFn.isPending,isErrorandisSuccesstell you the state, so you can disable the button and show messages.onSuccessruns when it works. Here we call `invalidateQueries`, which marks every query with the key['posts']as stale, so the list reloads by itself and shows the new post.
This is a very nice pattern: after any change, you invalidate the related keys, and the screen stays correct without you wiring state by hand.
Optimistic updates (a short look)
Sometimes you want the screen to change at once, before the server answers, for example when ticking a to-do item. This is an optimistic update. You update the cache straight away, and if the server fails, you put the old data back. TanStack Query supports this with onMutate and onError, and there is a guide in its documentation. Use it for actions that almost always succeed. It is a small thing that makes an app feel very fast.
Where to put your queries
Do not write fetch code inside every component. Put your query functions in one file (such as api.js), and wrap a query in a custom hook, as in lesson 17:
// hooks/usePosts.js
export function usePosts() {
return useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
}
// in a component
const { data, isPending } = usePosts();
Now every component that needs posts shares one cache entry and one request, and changing how posts are fetched happens in one place.
Developer tools
TanStack Query has a devtools panel (@tanstack/react-query-devtools) that shows every query, whether it is fresh or stale, and its cached data. It is a great way to learn what the library is doing.
When not to use it
- For a tiny app with one simple request, plain
fetchin an effect is fine. - For data that never comes from a server, use normal state.
- If you use a framework such as Next.js, it has its own data tools, which you can still combine with TanStack Query.
Common mistakes
- Forgetting to throw on non-OK responses in
queryFn, so errors look like success. - Putting a changing value in
queryFnbut not inqueryKey, so the cache shows the wrong data. Everything the query depends on belongs in the key. - Copying query data into
useState. Usedatadirectly. - Not invalidating after a mutation, so the list stays old.
- Forgetting the
QueryClientProvider.
Before and after: the same screen
Look at what you wrote in lesson 12 for a simple list: three pieces of state, an effect, a try and catch block, a finally block, and an abort controller. That was about thirty lines, and it still did not cache, retry or refresh. With useQuery the same screen is a few lines, and it gains all of those features. This is the reason the library is so popular: it removes code you would write over and over, and does it better than most hand-written versions.
It is still worth understanding the manual way first, as you did in lesson 12. When you know what loading, error and race conditions are, the library makes sense, and you can find the cause when something goes wrong. Learn the problem first, then the tool that solves it.
Your task
- Install the library and rewrite the posts list from lesson 12 with
useQuery. Compare the number of lines. - Add a page for one post using
['post', id]. Go back and forth and notice the instant cached result. - Add a form with
useMutationthat creates a post and invalidates the list. - Add the devtools and watch queries become stale.
How to check it worked
- The list shows Loading, then the data, and an error with a retry button when the URL is wrong.
- Going back to a visited page shows data at once.
- A new item appears in the list after saving, with no manual refresh.
Summary
- TanStack Query manages server state: caching, refetching, retries and cancelling are built in.
useQuery({ queryKey, queryFn })reads data. The key names the data, and everything the query depends on goes in it.useMutationchanges data, andinvalidateQueriesrefreshes the related lists.- Wrap queries in custom hooks and keep them in one place.