React State Management in Depth #2: Server State with TanStack Query
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.
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 #
npm install @tanstack/react-queryWrap the top of your app in a QueryClientProvider. A single QueryClient manages all the caches.
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.
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,fetchoraxiosmakes 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.
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).
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.
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.
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.
| Item | useEffect + useState | TanStack Query |
|---|---|---|
| Loading / error state | managed by hand | isPending / isError provided |
| Caching | none | automatic by queryKey |
| Request deduplication | none | automatic |
| Refetch on window focus | implement yourself | provided by default |
| Refresh list after a change | edit state by hand | one 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 automaticqueryKey— the key that identifies a cache; embed a variable to cache per parameterstaleTime— how fresh to consider the datauseMutation+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.