Concept
The beginner framing: instead of writing useEffect + fetch by hand every time a component needs server data, a data-fetching library like TanStack Query handles the fetching, caching, and re-fetching for you.
The precise mental model: a manual useEffect-based fetch (see Effects) has no memory beyond the single component it's written in, every component that needs the same data fires its own independent request, with no shared cache, no deduplication, and no protection against the race conditions already covered in Effects. A query library's core idea is a cache keyed by a query key, typically an array describing exactly what the data depends on (["user", userId]), so that any component asking for that same key gets the same cached result, and any two components asking for it at the same time share a single in-flight request instead of firing two.
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <h1>{data.name}</h1>;
}If a second component elsewhere on the same page also calls useQuery({ queryKey: ["user", userId], ... }) for the same userId, TanStack Query recognizes the identical key, serves both from the same cache entry, and, if both mount at nearly the same time, deduplicates the request into a single network call rather than two.
Query keys: the entire cache is organized around them
useQuery({ queryKey: ["todos", { status: "done", page: 2 }], queryFn: fetchTodos });The query key must include every value the query result actually depends on, if status or page changed but weren't part of the key, the cache would serve stale results under the wrong conditions, since the library has no other way to know the query's "identity" has changed.
Mutations: actions, not cached data
const mutation = useMutation({
mutationFn: (newTodo) => createTodo(newTodo),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), // tell the cache it's stale
});
mutation.mutate({ text: "Buy milk" });A mutation is an action with a side effect (creating, updating, deleting), it isn't itself cached the way a query is. After a mutation succeeds, the relevant queries are typically invalidated, telling the cache "this data may now be stale, refetch it next time it's needed," rather than trying to cache the mutation's own result as reusable data.
Try It
Predict what happens before checking the solution.
function Header() {
const { data } = useQuery({ queryKey: ["user", 1], queryFn: () => fetchUser(1) });
return <span>{data?.name}</span>;
}
function Sidebar() {
const { data } = useQuery({ queryKey: ["user", 1], queryFn: () => fetchUser(1) });
return <span>{data?.name}</span>;
}
How many network requests fire when App mounts?
Solution
One, not two. Both Header and Sidebar use the exact same query key, ["user", 1]. TanStack Query recognizes the identical key and, since both components mount close together, deduplicates the requests into a single underlying network call, sharing the result between both consumers. A naive useEffect + fetch implementation in each component, by contrast, would fire two separate, redundant requests for identical data.
Implement It Yourself
Build a minimal cache with request deduplication, the core mechanism these libraries are built around:
const cache = new Map();
function queryCache(key, queryFn) {
const cacheKey = JSON.stringify(key); // the query key, serialized as an identity
if (cache.has(cacheKey)) {
return cache.get(cacheKey); // already in-flight OR already resolved, reuse it
}
const promise = queryFn().then((data) => {
cache.set(cacheKey, Promise.resolve(data)); // replace with the RESOLVED value for future callers
return data;
});
cache.
The critical detail: the promise is cached immediately, before it resolves, a second call arriving while the first is still in-flight gets the same promise (and therefore the same eventual result) rather than kicking off a redundant fetch, which is exactly the deduplication behavior demonstrated in Try It.
Under the Hood
This deduplication mechanism is a direct, practical application of Promises: the same promise object can have multiple .then() consumers, all of which receive the same eventual result, a query cache is essentially "store the promise itself, keyed by what it represents, and hand the same promise to every caller who asks for that key while it's still pending." Query libraries exist specifically because the naive useEffect+fetch baseline from Effects has none of this, no shared cache, no deduplication, and (as covered there) real exposure to race conditions when a component's inputs change faster than its requests resolve.
Common Mistakes
1. Hand-rolling useEffect + fetch for anything beyond a single, simple, one-off request
Reasonable for a truly isolated, one-time fetch, but the moment the same data might be needed by more than one component, or the request needs retry/refetch/cache semantics, a hand-rolled effect reimplements (usually incompletely, and usually without the race-condition guard from Effects) what a query library already solves.
2. Omitting a value the query actually depends on from the query key
useQuery({ queryKey: ["todos"], queryFn: () => fetchTodos(status) }); // ❌ status isn't in the keyIf status changes but isn't part of the key, the cache has no way to know this is now a different query, it may serve a stale result cached under the same key from before status changed.
3. Treating a mutation's result as reusable cached data
A mutation is an action, not a cacheable read, after it succeeds, the correct response is usually to invalidate the queries it affects (telling the cache to refetch), not to try to manually merge the mutation's return value into the query cache as if it were itself a query result (which some libraries support carefully, but is easy to get subtly wrong).
Best Practices
- Include every value the query result depends on in the query key, treat the key as the query's full identity, not just a label.
- Invalidate the relevant queries after a mutation succeeds, letting the library refetch fresh data, rather than manually reconstructing the post-mutation state by hand.
- Reach for a query library once data is shared across components, needs caching, or needs retry/refetch behavior, a single, truly isolated fetch with no reuse doesn't necessarily need one.
- Let the library's built-in
staleTime/retryconfiguration do the work rather than reimplementing ad hoc versions of the same behavior in application code.
Performance Tips
- Deduplication directly eliminates redundant network requests when multiple components need the same data at the same time, a meaningful win the moment a page has more than one consumer of the same data.
- Stale-while-revalidate (showing cached data immediately while quietly refetching in the background) avoids a loading spinner on every re-navigation to already-seen data, trading a small chance of briefly-stale data for a much snappier perceived experience.
- These libraries integrate with Suspense (see Suspense) for cases where a component should genuinely block on data, the same throw/catch mechanism, now backed by a cache instead of a bare, uncached promise.
