Query libraries and dedupe
A query library turns server state into a cached, self-revalidating resource — caching, request dedupe, background revalidation, retries, and mutation-with-invalidation — so you stop copying server data into useState/Redux and manually syncing it.
You wired up data fetching the obvious way: useEffect fires a fetch, the result lands in useState, and a loading flag flips. It works for one component. Then a second component needs the same user. Then a third. Each mounts its own effect, fires its own request, stores its own copy. Now the same /me response lives in three places, three times over the wire, and the three copies drift the moment one of them mutates.
The fix is not “add more useState”. The fix is to stop treating server data as client state at all. A query library — TanStack Query, SWR — turns a remote endpoint into a cached, self-revalidating resource that every component reads from one place. Caching, deduplication, retries, and revalidation come with it, for free, correct. This lesson is about what that buys you and the anti-pattern it retires.
After this lesson you can explain what a query library actually provides — a shared cache, request deduplication, background revalidation (stale-while-revalidate), and retries — without you writing any of it by hand; write a useQuery read and a mutation that invalidates the relevant cache key so dependent reads refetch; and recognize the duplicated-server-state failure mode (copying fetched data into useState/Redux and manually syncing it) and why a query library is the senior alternative.
A query library is a cache keyed by a query key — the key is the whole mechanism. You don’t fetch into a component; you declare “this key maps to this fetcher”, and the library owns the cache entry. Every component that asks for the same key reads the same entry. That single idea is what gives you dedupe, sharing, and invalidation for free.
"use client";
import { useQuery } from "@tanstack/react-query";
function useUser(id: string) {
return useQuery({
queryKey: ["user", id], // identity of the cached resource
queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
staleTime: 30_000, // 30s: data is "fresh", no refetch
});
}Mount useUser("42") in three components and you do not get three requests. The library sees one in-flight promise for ["user","42"] and hands all three the same result. That is request deduplication, and it is the first thing you’d otherwise reinvent badly.
Background revalidation is stale-while-revalidate: serve the cached value instantly, refetch underneath, swap when fresh. Your component never blocks on a refetch it didn’t ask for. When a query goes stale (past staleTime) and something re-triggers it — a remount, a window refocus, a manual invalidate — the library returns the cached data immediately and fires a background fetch. The UI shows last-known-good data with zero spinner, then updates.
function UserName({ id }: { id: string }) {
const { data, isPending, isFetching } = useUser(id);
if (isPending) return <Skeleton />; // first load, no cache yet
// isFetching === true here means a *background* revalidation is running;
// `data` is still the cached value, fully usable — no spinner needed
return <span>{data.name}{isFetching && <Dot title="refreshing" />}</span>;
}Note the two distinct states: isPending (no data at all, show a skeleton) versus isFetching (have data, refreshing in the background). Hand-rolled fetching collapses both into one loading boolean and flashes a spinner on every revalidation. The library separates them because they are different UX.
Mutations don’t write to the cache directly — they invalidate keys, and dependent reads revalidate themselves. After a write, the truth is on the server, so the correct move is to mark the affected query keys stale and let the library refetch. You declare which keys this mutation invalidates; everything reading those keys refreshes. You never manually reach into other components to “also update their copy”.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useRenameUser(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
fetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["user", id] }); // this user refetches
qc.invalidateQueries({ queryKey: ["users"] }); // any list including them refetches
},
});
}One invalidateQueries call fans out to every consumer of that key. There is no prop-drilling the new value, no Redux action rippling through reducers — the cache is the single source and invalidation is the single sync mechanism.
Retries and the failure axis come built in — and the failure mode of not using a library is duplicated server state. Query libraries retry failed fetches with backoff, expose error/isError, and let you tune retry count per query. You get a real error path, not a half-finished try/catch per component. But the deeper win is what you stop doing: the moment you copy a fetched response into useState or dispatch it into Redux, you’ve created a second source of truth that the server can invalidate at any time, and now you own the sync code that keeps drifting.
// ANTI-PATTERN: server data copied into client state, synced by hand
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch(`/api/users/${id}`).then(r => r.json()).then(setUser); }, [id]);
async function rename(name: string) {
await fetch(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify({ name }) });
setUser((u) => u && { ...u, name }); // optimistic guess — now this copy can drift from the server,
// and any OTHER component holding its own copy won't update at all
}That setUser is the trap: it’s a guess about server state, it doesn’t propagate to the other copies, and there’s no dedupe, no revalidation, no retry. The library replaces all of it with “read the key, invalidate the key”.
A profile screen and a header both show the current user; renaming must update both. Watch the hand-rolled version drift, then watch the query version stay coherent.
Before — each component owns a copy, the rename only patches one:
// Header.tsx
function Header() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
return <span>{user?.name}</span>;
}
// Profile.tsx — a SECOND copy, a SECOND request
function Profile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []);
async function rename(name: string) {
await fetch("/api/me", { method: "PATCH", body: JSON.stringify({ name }) });
setUser((u) => u && { ...u, name }); // updates Profile's copy only — Header still shows the old name
}
// ...
}Two /api/me requests on load, and after rename the header is stale until a full reload. Now the query version — one key, both read it, the mutation invalidates it:
function useMe() {
return useQuery({ queryKey: ["me"], queryFn: () => fetch("/api/me").then(r => r.json()) });
}
function Header() {
const { data } = useMe(); // same cache entry as Profile — ONE request, deduped
return <span>{data?.name}</span>;
}
function Profile() {
const { data } = useMe();
const qc = useQueryClient();
const rename = useMutation({
mutationFn: (name: string) =>
fetch("/api/me", { method: "PATCH", body: JSON.stringify({ name }) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), // BOTH refetch from one source
});
// ...
}One request on load (deduped), and after rename invalidateQueries(["me"]) refetches the single entry — Header and Profile update together because they were never separate copies. The library replaced the sync code you were going to write, get subtly wrong, and maintain forever.
▸Why this works
Why is server state categorically different from client state? Client state (a modal’s open/closed, a form’s draft) is owned by your app — you are the source of truth. Server state is borrowed: the server owns it, it can change without your app doing anything, and your copy is stale the instant it arrives. Treating borrowed state like owned state — pinning it in useState/Redux — means you’ve signed up to manually re-sync it forever, and you can’t, because you don’t get notified when the server changes. A query library models the borrowing correctly: a cache with an expiry and a revalidation strategy, not a variable you own.
▸Common mistake
The seductive mistake is using a query library and then still copying its data into useState “so I can edit it”. The moment you do const [name, setName] = useState(data.name), you’ve re-created the duplicated-state problem the library exists to prevent — your local copy won’t track background revalidations or other mutations. For editable forms, keep the draft in local state (that draft is genuinely client-owned) but never mirror the whole server object; read it live from the query and submit through a mutation. The rule: local state holds what the user is editing, the query holds what the server knows — and they meet only at submit.
Three components mount on the same screen and each calls useUser('42') with the same query key. The data is currently stale. What does a query library do?
A query library (TanStack Query, SWR) turns a remote endpoint into a cached, self-revalidating resource addressed by a query key. From that one idea you get request deduplication (same key → one fetch shared by all readers), stale-while-revalidate (serve cached data instantly, refetch in the background, distinguish isPending from isFetching), and retries with backoff — none of which you write. Writes use a mutation that invalidates keys: invalidateQueries(["user", id]) marks the entry stale and every consumer refetches from the single source, so there’s no manual cross-component sync. The failure mode this retires is duplicated server state — copying fetched data into useState/Redux and patching it by hand, which drifts the moment the server changes or a second copy exists. The senior rule: server state is borrowed, not owned; model it as a cache with revalidation, keep only genuinely client-owned state (drafts, UI toggles) in useState, and don’t reinvent caching, dedupe, and retry by hand.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.