TanStack Query

React doesn’t provide an out-of-the-box system for handling asynchronous server state. Developers usually have to manually set up useEffect, write boilerplate code for loading & error states, coordinate manual re-fetching & handle race conditions. TanStack Query eliminates this “async-spaghetti code” by providing a declarative mechanism to manage server state seamlessly.

I’m using TanStack Query for the first time on a project right now. Like everyone else, I’ve been fetching data in React with a useEffect & a couple of useState calls until this fell into my lap & I tried it out.

This is how every React tutorial teaches you to fetch data from an outside source:

const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetch('/api/things')
    .then((res) => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setIsLoading(false));
}, []);

It works but there are some issues that can come up because the data doesn’t live where the app does:

  • What happens when two components need the same data? Do you fetch it twice?
  • What if the user navigates away & comes back? Do you refetch & show a spinner again?
  • How do you refetch when the data changes on the server?
  • What about caching, retries, request de-duplication, old data & race conditions where an old request resolves after a newer one?

I only really thought about that stuff when I actually ran into those problems & I mostly just fetched data all over again to solve them. Sometimes, tools I used, like Firebase, had real-time updates that pushed data out to clients whenever it was updated, so it solved problems before I had to.

TanStack Query handles all of these issues & probably other things that I haven’t thought of yet.

What is it & what does it do?

The data you fetch from a server isn’t really your state. It’s a copy of state that lives somewhere else, on a machine you don’t control, that can change without telling you. TanStack Query calls this server state & it treats it as a different thing from client/UI state (form inputs, toggles, “is this modal open”).

Trying to stick server state into useState is the root of most of the issues above. TanStack Query is a tool built specifically for managing that borrowed copy. It keeps it fresh, cached & synchronized. “Cached” is key here. Its basically a cache of server-state that your React apps can render from while updated data is retrieved from the server. In a way, its like getting a cold plate put in front of you at dinner while a hot one is being brought out to replace it.

TanStack Query is not a data-fetching library. It doesn’t fetch anything. You still write the fetch, or the Axios call, or whatever you use to make an async call. You hand it your fetch function & it manages the state around the async data that comes back: the caching, the loading & error flags, the revalidation, the race conditions. Its actually an “async state management library“. “Data-fetching library” is how a lot of people think of it because that’s what we think we’re using it for, but its more like the catcher than the pitcher (see Sam? Its not basketball, but I made a sports reference!). Its not there to make the request, its there to manage everything that happens around the request.

Caching & updating

Watching live news & seeing it update is similar to how caching & updating works in TanStack Query. Events out in the world like a fire, game, or police chase are the source of truth. But you’re not out there seeing it happen. You’re watching a broadcast, a report the newsroom already gathered & packaged. That broadcast is your cache. What’s on your screen is a copy of reality, put together a moment ago, always running a little behind the event that’s happening. That’s server state. Its what’s been reported at the station & shared with you. Its already playing. You’re not waiting for events to re-happen; the current report is right there. That’s the cache serving data on sight.

Then “BREAKING NEWS cuts in. You didn’t refresh anything. A fresher version arrives on its own & swaps in while you’re watching, so nobody stares at a blank screen. That’s a stale query revalidating in the background: you keep seeing the last-known version until the update is ready, then it quietly replaces it.

The setting that controls this, staleTime, is just how live you demand the channel to be. A rolling 24-hour news channel interrupts constantly: that’s staleTime: 0, revalidate at every opportunity (the default). The nightly recap refreshes once a day: that’s a long staleTime, for data that rarely changes.

And if you’re watching from your phone, you’re not just a spectator. Some breaking stories ask viewers to call in with a tip, and that’s a mutation. You send information back to the newsroom (the server); it gets verified, worked into the story & the broadcast everyone’s watching updates to match. Reads come out of the broadcast, a call-in writes back to the source.

Taking out the trash

When a story resolves (the chase ends, the missing kid is found, the fire gets extinguished), viewers drift off. And because nobody’s watching that feed anymore, the network eventually stops keeping it cued up & clears it. That’s garbage collection. The event ending isn’t the trigger. The audience leaving moves a story out of current events & into the pit of old news.

In TanStack Query, that’s gcTime. Its not instant: the network holds the feed ready for a little while after the last viewer leaves, in case someone flips back, then clears it. That grace period is exactly what gcTime is: five mins by default, which is why it’s a duration, not an on/off switch. Before TanStack Query v5, gcTime was called cacheTime. Systems/network folk can think of it as TTL (time-to-live).

Core concepts

This is what you need to understand TanStack Query at a basic level & to use it:

  • QueryClient is the central cache instance that stores & manages all of your query data. You create one for your app.
  • QueryClientProvider is the React context provider that hands that QueryClient to every component in the tree. You wrap your app in it once.
  • useQuery is the hook for reading server data. You give it a key & a fetch function & it returns the data plus status flags like isPending & isError.
  • queryKey is a unique, serializable label for a piece of query data (for example, ['things']). Having the same key means having the same cached entry, so components sharing a key also share one request & one result.
  • queryFn is a callback function that actually performs the fetch & returns a promise. You supply it, & TanStack Query calls it for you.
  • useMutation is the hook for writing server data (create, update, delete). It runs an async action & gives you its status & result, but unlike a query it doesn’t cache.
  • invalidateQueries marks matching cached queries as stale & triggers a refetch. You usually call it right after a mutation succeeds, so the UI reflects the change.
  • staleTime is how long fetched data is treated as fresh before it becomes eligible for a background refetch. It defaults to 0, meaning data is considered stale the moment it lands.
  • gcTime is how long unused data (a query with no components currently mounted) lingers in the cache before it’s garbage-collected. It defaults to five minutes & was called cacheTime before v5.

This is what it looks like in action:

import { useQuery } from '@tanstack/react-query';

function Things() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['things'],
    queryFn: () => fetch('/api/things').then((res) => res.json()),
  });

  if (isPending) return <p>Loading…</p>;
  if (isError) return <p>Something broke: {error.message}</p>;

  return <ul>{data.map((t) => <li key={t.id}>{t.name}</li>)}</ul>;
}

That’s what the useEffect block did up top except with TanStack Query it’s cached, de-duplicated, retried on failure & refetched intelligently, without us having to write code to do any of that.

Benefits

1. It gets rid of a whole category of boilerplate: No more manually juggling data / loading / error state in every component that touches the network. One hook, three-ish variables, done. Across a real app, it’s a lot of code & a lot of bugs that just stop existing.

2. Caching & de-duplication are automatic & shared: Two components asking for ['things'] trigger one request & share the result. Navigate away & back & the cached data is there instantly while a background refetch keeps it current. It’ll even revalidate automatically when you tab back to the window, so stale data corrects itself without a reload.

3. The DevTools look really good: The @tanstack/react-query-devtools panel shows you every query, its current state (fresh / stale / fetching), its data & its timing, live. I haven’t tried it yet, but having what’s essentially a query dashboard sounds amazing.

How I’m using it

Right now, I’m mostly using it to fetch data from Supabase for an app that I’m building to collect client data to build websites & web applications. So, I’m using useQuery to grab seeded data. I’m not pushing anything to Supabase to be saved yet, so no useMutation. That should change this week as I build out the app’s connectivity.

There’s also a whole tier of features I haven’t reached for yet. Infinite loading is one I could have used in some previous projects. There’s a dedicated useInfiniteQuery hook built for “load more” buttons & infinite-scroll feeds. It fetches data one page at a time & stitches the pages together as you go. There’s also optimistic updates, which I haven’t wrapped my head around yet. The high-level idea is that instead of waiting for the server to confirm a change, you update the UI immediately, as if it already succeeded, so the app feels instant. Then TanStack Query quietly rolls it back if the request ends up failing.

As a replacement for fetching with useEffect & managing state its great.

Leave a comment