React State Management in Depth #2: Server State with TanStack Query

6 min read

In #1 we split state into client state and server state. This post deals with server state: data like a post list, user info, or search results, where the source of truth lives on the server and the screen only shows a copy.

The standard tool for server state is TanStack Query. It was known as React Query in the past, and now that it supports frameworks beyond React, it was renamed to TanStack Query. The code in this post is based on v5.

The problem with the useEffect approach #

First, let’s recall the familiar approach we learned in Basics #10.

Data fetching with useEffect + useState
function PostList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/posts")
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data) => setPosts(data))
      .catch((err) => setError(err))
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

It works. But this code is missing a lot.

  • No caching. Leave this component and come back, and it refetches from scratch every time.
  • No request deduplication. If two components use the same data, the request goes out twice.
  • No freshness management. There’s no way to know whether the data you fetched once has gone stale.
  • Three state variables (data, loading, error) to manage by hand every time.

TanStack Query provides all of this out of the box.

Installation and Provider setup #

Install
npm install @tanstack/react-query

Wrap the top of your app in a QueryClientProvider. A single QueryClient manages all the caches.

main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

useQuery — reading data #

Rewriting the PostList above with TanStack Query looks like this.

PostList rewritten with useQuery
import { useQuery } from "@tanstack/react-query";

function PostList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await fetch("/api/posts");
      if (!res.ok) throw new Error("Request failed");
      return res.json();
    },
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p>Error: {error.message}</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

The three state variables are gone, replaced by a single useQuery. You only pass two things.

  • queryKey — the key that identifies this data. You write it as an array, like ["posts"]. This key is the heart of caching. Requests with the same key share a cache, and duplicate requests get merged into one.
  • queryFn — the async function that actually fetches the data. As long as it returns a Promise, fetch or axios makes no difference.

Now you can leave this component and come back, and the cached data shows up instantly while it quietly re-checks for the latest data in the background.

Putting a variable in queryKey #

To fetch one specific post, include the id in the key.

A query with a parameter
function PostDetail({ postId }) {
  const { data, isPending } = useQuery({
    queryKey: ["posts", postId],
    queryFn: async () => {
      const res = await fetch(`/api/posts/${postId}`);
      return res.json();
    },
  });

  if (isPending) return <p>Loading...</p>;
  return <article><h1>{data.title}</h1><p>{data.body}</p></article>;
}

When postId changes, the queryKey changes too, and TanStack Query treats it as different data and refetches automatically. It also caches each ID’s result separately. The dependency array you used to fuss over in useEffect is naturally absorbed into the queryKey.

staleTime — how fresh do you consider it? #

In #1 we said server state goes stale if you leave it alone. TanStack Query considers fetched data “fresh” for a set period, and marks it “stale” once that time passes. It still shows stale data on screen, but refetches it in the background at appropriate moments (such as when the window regains focus).

Setting staleTime
useQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
  staleTime: 60 * 1000, // don't refetch for 60 seconds
});

staleTime defaults to 0, so with the default setting, data is considered stale the moment it arrives and is re-checked often. For data that doesn’t change frequently, you can raise staleTime to cut down unnecessary requests.

Note
A value easily confused with staleTime is gcTime (garbage collection time, default 5 minutes). staleTime governs “when to refetch,” while gcTime governs “when to drop a cache from memory once it’s no longer in use on screen.” In v5, the former cacheTime was renamed to gcTime.

useMutation — changing data #

If reading is useQuery, then writing (create, update, delete) is useMutation. Here’s an example that creates a new post.

Adding a post with useMutation
import { useMutation, useQueryClient } from "@tanstack/react-query";

function NewPostForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newPost) => {
      const res = await fetch("/api/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newPost),
      });
      return res.json();
    },
    onSuccess: () => {
      // invalidate the list cache so it refetches automatically
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const title = e.target.title.value;
        mutation.mutate({ title });
      }}
    >
      <input name="title" />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? "Submitting..." : "Submit"}
      </button>
    </form>
  );
}

The most important line here is invalidateQueries inside onSuccess. After adding a post, invalidating the cache for the ["posts"] key makes every component that was watching that data automatically refetch the latest list. You don’t need to update the list state by hand. This is what managing server state consistently in one place actually looks like.

A summary of what disappeared #

Compared with the useEffect approach, here’s what TanStack Query handles automatically.

ItemuseEffect + useStateTanStack Query
Loading / error statemanaged by handisPending / isError provided
Cachingnoneautomatic by queryKey
Request deduplicationnoneautomatic
Refetch on window focusimplement yourselfprovided by default
Refresh list after a changeedit state by handone line of invalidateQueries

Wrapping up #

Leave server state to TanStack Query and you get caching, refetching, and freshness management along with it.

  • useQuery({ queryKey, queryFn }) — reading, with caching and loading/error states automatic
  • queryKey — the key that identifies a cache; embed a variable to cache per parameter
  • staleTime — how fresh to consider the data
  • useMutation + invalidateQueries — invalidate related caches after a write to refresh automatically

This tool is the most practical way to avoid the “dump server data into a global store” anti-pattern from #1. Once server state moves out into TanStack Query, what’s left in a global store is only true client state. We look at tools for handling that client state lightly starting with the next post.

In the next post, “React State Management in Depth #3: Lightweight Client State with Zustand,” we cover Zustand, which shares global client state with the least possible code.

X