Writing · Data
TanStack Query, visualised
The cache you never see, drawn: query keys, fresh and stale data, one request for many components, retries, mutations, optimistic updates and pages, each played on a network you can slow down and break. 13 minutes, 7 demos.
Welcome back
—
devtools
Queries1
- inactive['user', 1]0 readers
Network0 requests
On this page
01 · The idea
Data you don't own
Some of what an interface shows is its own: whether a menu is open, what’s typed in a field. The rest lives on a server and is only borrowed: a user’s profile, a list of orders, the number of likes. Borrowed data has problems the first kind doesn’t. It takes time to arrive. It can fail to arrive. Two parts of the page may ask for the same thing at once. And the moment it lands, it starts going out of date, because someone else can change it.
TanStack Query is a library for that second kind. Underneath, it’s a cache that sits between your components and the server: components ask the cache, and the cache decides whether to answer from what it has, ask the server, or both. Most of what it does happens on its own, which is why it can feel like magic and why it’s worth watching.
So every demo here is a small app, Inkwell, running the real library (version 5) against a pretend server that lives in the page, with the devtools a developer would keep open under it: every entry in the cache, and every request on the network. You can slow the network down and make it fail. The one at the top is the whole idea, small.
02 · The cache
A store you can see
Every piece of data in the cache is filed under a query key: an array that names it, like ['user', 1]. A component asks with useQuery, giving the key and a function that fetches the data. If the cache has nothing under that key, it calls the function; if it has something, it hands that over at once.
Welcome back
—
devtools
Queries1
- inactive['user', 1]0 readers
Network0 requests
1function Profile({ id }) {2 const { data, isPending, isFetching } = useQuery({3 queryKey: ['user', id],4 queryFn: () => fetchUser(id),5 staleTime: 10_000, // fresh for 10s6 gcTime: 8_000, // kept 8s once no page reads it7 });8}
Open Alan’s profile and it asks for ['user', 2]: the cache has nothing under that key, so a request goes out (watch the Network pane) and an entry appears under Queries. Go home and open Alan again: his profile is there at once, with no request, because the entry is still fresh (this app keeps data fresh for 10 seconds). Meanwhile Ada, the signed-in user, is read by the header and the home page together: two readers, one entry, one request.
Now go home and leave Alan. With no page reading his entry it turns inactive, but it doesn’t vanish: it stays in case someone comes back, and is thrown away only after gcTime has passed (5 minutes by default, 8 seconds here). Open him again before then and his profile is waiting; after, it loads from scratch.
function UserCard({ id }) {
const { data, isPending, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
if (isPending) return <Spinner />;
if (error) return <p>Couldn't load this user.</p>;
return <h2>{data.name}</h2>;
}The key is the whole contract: anything the fetch depends on belongs in it. ['user', id] with the id inside means a new id is a new entry, fetched on its own and cached on its own.
03 · Fresh and stale
When it refetches on its own
Cached data is either fresh or stale. Fresh data is trusted: the cache answers from it and doesn’t bother the server. Stale data is still shown (stale doesn’t mean wrong, it means it might be), but at certain moments the cache fetches it again in the background and swaps the new data in when it lands. How long data stays fresh is staleTime, and by default it’s 0: everything is stale the moment it arrives.
The moments are three: a component that reads it mounts, the tab comes back into focus, the network comes back after being offline. Each one refetches only if the data is stale. In the app, moving between pages mounts them; the two buttons under it stand in for leaving the tab and losing the network.
Welcome back
—
Movearoundtheapp,orpressabutton:eachisamomentthelibrarymayrefetch.
devtools
Queries1
- inactive['user', 1]0 readers
Network0 requests
1useQuery({2 queryKey: ['user', id],3 queryFn: () => fetchUser(id),4 staleTime: 0,5 // refetch when stale, on each of these6 // (all three default to true):7 refetchOnMount: true,8 refetchOnWindowFocus: true,9 refetchOnReconnect: true,10});
With staleTime at 0, every moment sends a request: open Alan, go home, open him again, and each visit asks the server, while his profile is already on screen with “updating…” beside his name. At 5 seconds, come back quickly and the cache answers on its own; wait 5 seconds and the next moment refetches. With 'static', nothing ever refetches by itself. Notice what the app shows while a refetch runs: what it already had, never a skeleton. That’s the point of stale data: show what you have, fetch what’s new, swap it in.
So choosing staleTime is choosing how often the server is asked. Data that changes every second wants 0. A user’s name can be fresh for minutes. A list of countries never goes stale at all.
04 · One request, many readers
Deduplication
Build a page out of small components and several of them will want the same data: the header shows the user’s avatar, the sidebar their name, the settings panel their email. Fetched by hand, each component sends its own request. Through the cache, a request already on its way is shared: every component asking for the same key while it’s in flight waits for that one answer.
Welcome back
—
ThehomepagereadsAdainfourplaces:eachfetchesforitself,0requests.
devtools
Queries0
- nothing cached
Network0 requests
1function Greeting() {2 const [user, setUser] = useState();3 useEffect(() => {4 fetchUser(me).then(setUser);5 }, []);6}
Inkwell’s home page reads the signed-in user in four places: the header, the greeting, the box to write in and the numbers under it. Reload it with each one fetching by hand and count the requests in the Network pane: four, all for the same user at the same moment. Switch to useQuery and reload: one. This is why you can call useQuery wherever the data is needed instead of fetching once at the top and passing it down through every layer: the cache makes asking twice cost nothing.
05 · Retries
When a request fails
Networks fail, often briefly: a server restarting, a phone between towers. So a query that fails isn’t given up on straight away. By default the library tries 3 more times, waiting longer before each: 1 second, then 2, then 4 (doubling, never more than 30). Only when the last try fails does the component see the error.
Welcome back
—
devtools
Queries1
- inactive['user', 1]0 readers
Network0 requests
1useQuery({2 queryKey: ['user', id],3 queryFn: () => fetchUser(id),4 retry: 3, // the default5 // the default wait: 1s, 2s, 4s… at most 30s6 retryDelay: (attempt) =>7 Math.min(1000 * 2 ** attempt, 30_000),8});
Visit Grace with two failing requests: the third try gets through, and the page never shows an error, only a skeleton that stayed a few seconds longer (the devtools count the failures as they happen). With five, it runs out of tries and gives up, and the page says so, with a button to try again. Set retry to 0 and the first failure is final. While it’s retrying, the page knows: failureCount says how many tries have failed so far, which is how it can say so under the skeleton.
The wait growing each time is deliberate. If a server is down because it’s overloaded, a thousand clients retrying every second keep it down; waiting longer each time gives it room to come back.
06 · Changing data
Mutations and invalidation
Reading is half of it. To change something on the server (post a comment, rename a project) you use useMutation. A mutation is just the request; the hard part is what happens after. The server now has new data, and the cache still holds the old.
comments
Thesecommentsnevergostaleontheirown(staleTime:Infinity).
0 on screen · 2 on the server
devtools
Queries3
- inactive['user', 1]0 readers
- inactive['post', 1]0 readers
- inactive['comments', 1]0 readers
Network0 requests
1const add = useMutation({2 mutationFn: (text) => postComment(text),3});
With Nothing after, post a comment on Ada’s post and watch the counts under the app: the server has three, the page still shows two, and nothing in the cache knows it’s out of date (these comments never go stale on their own: their staleTime is Infinity). Press “Refresh” and the new comment appears. That button is what Invalidate does for you: when the save succeeds, invalidateQueries marks the entry under ['comments', 1] stale and refetches it, since it’s on screen.
Invalidating by key is why keys are arrays. invalidateQueries({ queryKey: ['comments'] }) matches ['comments', 1], ['comments', 2] and ['comments', 1, 'newest']: everything that starts with it. One line after a save, and every list that might show the new comment is fetched again.
07 · Optimistic updates
Show it first, take it back if it fails
Invalidating is safe but slow: the comment appears only after two round trips, the save and the refetch. For actions that almost always succeed (a like, a checkbox, a new comment) you can show the result before the server has answered, and undo it in the rare case it says no. That’s an optimistic update.
Presstheheart:onewaywaitsfortheserver,theotherdoesn't.
devtools
Queries2
- inactive['user', 1]0 readers
- inactive['post', 1]0 readers
Network0 requests
1const like = useMutation({2 mutationFn: (liked) => sendLike(liked),3 onMutate: async (liked) => {4 await queryClient.cancelQueries({ queryKey: ['post', 1] });5 const before = queryClient.getQueryData(['post', 1]);6 queryClient.setQueryData(['post', 1], (old) => ({7 ...old, liked, likes: old.likes + (liked ? 1 : -1),8 }));9 return { before };10 },11 onError: (error, liked, result) =>12 queryClient.setQueryData(['post', 1], result.before),13 onSettled: () =>14 queryClient.invalidateQueries({ queryKey: ['post', 1] }),15});
Press the heart with Invalidate: nothing moves until the server has answered and the post has been fetched again. Switch to Optimistic and press it: the heart fills and the count goes up at once. Turn on “Server refuses it” and press it again: it changes, the save fails, and it’s put back. The code does four things, in order. It cancels any refetch of the post already in flight, so an old answer can’t land on top of the new like. It keeps a copy of the post as it was. It writes the like into the cache. And if the save fails, it puts the copy back. Either way, it ends by invalidating, so the post settles on what the server really has.
For the simplest cases there’s a lighter way: don’t touch the cache at all, and draw the pending item from the mutation itself (variables holds what’s being saved, isPending says it’s on its way). It disappears by itself if the save fails, so there’s nothing to roll back.
08 · Pages
Pages, and lists that grow
Long lists come in pages, and the page number goes in the key: ['feed', 2] is a different entry from ['feed', 1]. That gives you something for free. Go to page 2, then back to 1, and page 1 is still in the cache: it’s on screen at once.
devtools
Queries2
- inactive['user', 1]0 readers
- inactive['feed', 1]0 readers
Network0 requests
1const { data, isPlaceholderData } = useQuery({2 queryKey: ['feed', page],3 queryFn: () => fetchFeed(page),4 placeholderData: keepPreviousData,5});
Turn keepPreviousData off and press “Older”: the feed empties to a loading state while the new page arrives. Turn it back on and the old page stays up, dimmed, until the new one replaces it. isPlaceholderData says when what’s on screen is the previous page.
Switch to Load more for a list that grows instead. useInfiniteQuery keeps every page under one key, adds a page with each fetchNextPage(), and asks getNextPageParam where the next one starts; when that returns nothing, hasNextPage is false and the list is complete.
09 · Worth knowing
The defaults, and when to change them
Most of what the demos showed happens without a single option. These are the defaults behind it, and the usual reasons to change them:
staleTime: 0: data is stale on arrival, so it’s refetched at every chance. Raise it for anything that doesn’t change by the second; most apps set a project-wide default of a minute or so.gcTime: 5 minutes: how long an entry with no readers is kept. Rarely worth changing.retry: 3, waiting 1s, 2s, 4s. Lower it for requests a person is staring at, where an honest error beats a long wait.- Refetch on mount, window focus and reconnect: on, but only for stale data. Turning focus off is common in dashboards that open many tabs.
- Mutations don’t retry, and don’t update anything unless you tell them to.
- The cache is the model: components ask it, it decides whether to ask the server.
- The key names the data: everything the fetch depends on goes in it.
- Stale isn’t wrong: it’s shown at once and refreshed behind it.
staleTimedecides how often the server is asked. - Ask wherever you need it: requests for the same key are shared.
- After a change, invalidate: by key prefix, so everything that might show it refetches.
- Optimistic for actions that rarely fail: cancel, snapshot, write, and roll back on error.
- Pages in the key, and
keepPreviousDataso the screen doesn’t empty between them.