Topic 12 / 14

Data Fetching Patterns & TanStack Query

~10 min read  //  React Series  //  Coding India

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.