TanStack Query (React Query): Fetching, Caching and Updating Server Data

React.js Full Course: Beginner to Advanced · Advanced React · 9 min read · Updated

A query with a cache

  1. A component asks for data with a query key, such as ["posts"].
  2. The library checks its cache. Cached data shows at once, and stale data refreshes in the background.
  3. If it needs fresh data, it calls your queryFn, with retries and cancelling built in.
  4. Later a mutation saves a new post. It then invalidates the key.
  5. The list reloads by itself and shows the new post, with no manual state code.

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 the mutationFn.
  • isPending, isError and isSuccess tell you the state, so you can disable the button and show messages.
  • onSuccess runs 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 fetch in 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 queryFn but not in queryKey, so the cache shows the wrong data. Everything the query depends on belongs in the key.
  • Copying query data into useState. Use data directly.
  • 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

  1. Install the library and rewrite the posts list from lesson 12 with useQuery. Compare the number of lines.
  2. Add a page for one post using ['post', id]. Go back and forth and notice the instant cached result.
  3. Add a form with useMutation that creates a post and invalidates the list.
  4. 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.
  • useMutation changes data, and invalidateQueries refreshes the related lists.
  • Wrap queries in custom hooks and keep them in one place.

Frequently asked questions

What is TanStack Query used for?

For loading and changing data from a server, with caching, refetching, retries and loading and error states built in.

Is React Query the same as TanStack Query?

Yes. React Query was renamed TanStack Query when it grew to support other frameworks. The React package is @tanstack/react-query.

What is a query key?

An array that names a piece of data in the cache. Queries with the same key share one request and one result. Put every variable the query depends on in it.

Do I still need Redux if I use TanStack Query?

Often not for server data. You may still use a small store for shared client state.