Data Fetching Patterns & TanStack Query
What Raw Effects Don’t Give You
The useEffect fetch pattern works, but every component refetches data the app already has, nothing is cached, nothing revalidates, and loading flags multiply. Server data has different needs than UI state: it’s shared, cacheable, and goes stale. TanStack Query treats it that way:
npm install @tanstack/react-query// main.jsx — one-time setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(root).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);useQuery — Reading Data
import { useQuery } from "@tanstack/react-query";
function CourseList() {
const { data, isPending, error } = useQuery({
queryKey: ["courses"], // cache identity
queryFn: () => api("/courses"), // how to fetch
});
if (isPending) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data.map(c => <li key={c.id}>{c.title}</li>)}</ul>;
}What you get free: two components with the same key share one request and one cache entry; revisiting a page shows cached data instantly while refetching in the background; refetch on window focus; retries with backoff.
Parameters go in the key — change the key, get a new cache entry:
useQuery({
queryKey: ["courses", { track, page }],
queryFn: () => api(`/courses?track=${track}&page=${page}`),
});useMutation — Writing Data
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddCourseForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (course) => api("/courses", { method: "POST", body: course }),
onSuccess: () => {
// the list is now stale — refetch it
queryClient.invalidateQueries({ queryKey: ["courses"] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title: e.target.title.value });
}}>
<input name="title" required />
<button disabled={mutation.isPending}>
{mutation.isPending ? "Adding…" : "Add course"}
</button>
{mutation.isError && <p>{mutation.error.message}</p>}
</form>
);
}Invalidate-on-success is the core loop: mutate on the server, mark affected queries stale, let Query refetch. No manual cache surgery for most apps.
The Architecture This Buys You
- Server state → TanStack Query (courses, users, anything fetched).
- UI state → useState/useReducer (open modals, form inputs, toggles).
- App-wide client state → Context (theme, auth session).
Components stop owning data-fetching lifecycles and just declare what they need. Loading flags, error states, and caches stop being your problem. This split — server state vs client state — is the single biggest architectural upgrade for a React codebase.