Module 7: Real-World Usage
TanStack Query and Axios
Caching server data with queries and mutations, how freshness and invalidation work, and where Axios fits in as the HTTP client.
The previous lesson ended with a list of things hand-rolled fetching can't do. TanStack Query (formerly React Query) does all of them. It manages server state: a local cache of data that lives on a server, with rules about when to trust it and when to refresh it.
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
Setup#
Create one QueryClient, which holds the cache, and provide it at the top.
// main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
);
The devtools show you every query in the cache, its status and its data. They only load in development, and I recommend using them from the first day.
Reading data: useQuery#
import { useQuery } from "@tanstack/react-query";
async function fetchUsers() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error("Failed to load users");
return res.json();
}
function UserList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
Compare that to the hand-written version from the last lesson. No useState, no effect, no cleanup, no ignore flag. And you got the cache, retries (three tries with backoff by default), deduplication and refetch-on-focus, for free.
Two required ingredients:
queryFnis any function that returns a promise of data. If it throws or rejects, the query is in an error state. This is why we throw whenres.okis false, since TanStack Query has no idea what an HTTP status means.queryKeyis an array that identifies the data in the cache.
Query keys are dependency arrays#
If the key changes, it's a different query. Include everything the data depends on.
function UserProfile({ id }) {
const { data } = useQuery({
queryKey: ["users", id],
queryFn: () => fetchUser(id),
});
// ...
}
Change id from 1 to 2 and a new query runs. Go back to 1 and the cached result is shown instantly while a background refresh checks if it's changed. That also kills the race condition from last lesson, since each key has its own entry and an old response can never land on the wrong screen.
cache
├─ ["users"] → [ ...all users ]
├─ ["users", 1] → { id: 1, ... }
└─ ["users", 2] → { id: 2, ... }
Fresh, stale and garbage-collected#
Two settings explain most of the behavior you'll see.
staleTime (default 0): how long data counts as fresh. While fresh, the cached value is returned and no request is made. Once stale, the cached value is still shown immediately, but the next trigger (component mount, window refocus, reconnect) refetches in the background.
gcTime (default 5 minutes): how long an unused query stays in the cache before being thrown away.
useQuery({
queryKey: ["countries"],
queryFn: fetchCountries,
staleTime: 1000 * 60 * 60, // country data barely changes: treat as fresh for an hour
});
With the default staleTime of zero, you'll see a lot of background refetching, such as every time you click back to the tab. It confuses people at first, because they see extra requests in the Network tab. It's by design. Set staleTime to a value that matches how quickly your data really changes.
Also know the two flags that differ:
isPending: there's no data yet at all (first load).isFetching: a request is in flight, which includes background refreshes while old data is still shown.
Use isPending to show the full-page spinner, and isFetching for a subtle "refreshing" indicator.
Changing data: useMutation#
Reading is a query. Creating, updating and deleting are mutations.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) =>
fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTodo),
}).then((res) => {
if (!res.ok) throw new Error("Could not save");
return res.json();
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] }); // refresh the list
},
});
return (
<button
disabled={mutation.isPending}
onClick={() => mutation.mutate({ title: "Learn Query" })}
>
{mutation.isPending ? "Saving..." : "Add"}
</button>
);
}
Invalidation is the key idea. Instead of manually updating the list after a successful save, you tell the cache "everything under ['todos'] is out of date." Any component showing that data refetches, and the screen catches up. It's simple and it scales, because you don't have to know who is displaying the data.
For snappier UIs you can do optimistic updates: change the cached data immediately, then roll back if the server fails. It's built in (via onMutate and onError, or by rendering mutation.variables while pending). Learn it when you need it.
Pagination and dependent queries#
import { keepPreviousData, useQuery } from "@tanstack/react-query";
const { data, isPlaceholderData } = useQuery({
queryKey: ["products", page],
queryFn: () => fetchProducts(page),
placeholderData: keepPreviousData, // show the previous page while loading the next
});
// only run once we have a user id
const { data: orders } = useQuery({
queryKey: ["orders", userId],
queryFn: () => fetchOrders(userId),
enabled: Boolean(userId),
});
Where Axios comes in#
TanStack Query doesn't make HTTP requests. It manages whatever your queryFn returns. You can use fetch or Axios, a popular HTTP client.
npm install axios
// src/lib/api.js
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
async function fetchUsers() {
const { data } = await api.get("/users"); // already parsed JSON
return data;
}
async function createUser(user) {
const { data } = await api.post("/users", user);
return data;
}
What Axios gives you over plain fetch:
- It rejects on non-2xx responses, so there's no
res.okcheck to forget. - JSON is parsed automatically, and request bodies are serialized.
- Interceptors let you attach auth headers or handle 401s in one place.
- A shared instance with a
baseURLand defaults.
What it costs: a dependency. Modern fetch is capable, and plenty of teams write a 20-line wrapper instead. Both approaches are fine. What matters is that all your requests go through one function or instance, so auth, error handling and base URLs live in a single place.
A tidy pattern#
Keep key construction and fetch functions together, away from components:
// src/features/users/queries.js
export const userKeys = {
all: ["users"],
detail: (id) => ["users", id],
};
export const useUsers = () =>
useQuery({ queryKey: userKeys.all, queryFn: () => api.get("/users").then((r) => r.data) });
export const useUser = (id) =>
useQuery({ queryKey: userKeys.detail(id), queryFn: () => api.get(`/users/${id}`).then((r) => r.data) });
Components call useUsers() and don't know or care how.
Where people get burned#
- Not throwing on a bad HTTP status inside a
fetch-basedqueryFn, so errors look like successes. - Leaving key variables out of
queryKey, so changing a filter shows stale data. - Copying query data into
useState. Use the data directly, and derive from it. - Being surprised by background refetches and "fixing" it with
refetchOnWindowFocus: falseeverywhere, instead of choosing an appropriatestaleTime. - Creating
new QueryClient()inside a component, which resets the cache on every render. - Storing server data in Redux or Zustand on top of using TanStack Query.
Practice#
- Replace your hand-written
UserListwithuseQueryand open the devtools to inspect the cache. - Build a user detail page whose key includes the id. Navigate between users, then go back and notice the instant cached render.
- Create an Add Todo mutation that invalidates
['todos']. Confirm the list refreshes without you writing any state code. - Set up an Axios instance with an interceptor that logs every request, then route all your queries through it.