🪴 GoDeep
Search
← Shipping Software

Data fetching and caching in React

Why hand-rolled useEffect fetching goes wrong, how TanStack Query's cache actually works, and how to build the four states every fetch needs into a research dashboard.

13 cards · 6 quiz questions · 10 min read

An agent built you a dashboard. It lists survey responses for a project, pulled from a FastAPI endpoint. It works when you click around slowly. Then someone switches projects twice in a second and sees the wrong project’s numbers under the right project’s title, and nobody can reproduce it. This article is about why that happens, and why the fix is almost never “add another useEffect”.

Every fetch has four states, not two

A component that loads data from a server is always in one of four states:

StateWhat it meansWhat the user should see
LoadingNo data yetA skeleton or spinner, ideally shaped like the content
ErrorThe request failedA plain sentence and a retry button
EmptyIt succeeded, and there are zero rows”No responses yet” plus what to do about it
DataThere are rowsThe content

Agent-written components reliably handle loading and data. Error is hit and miss. Empty is almost always missed, which is why a fresh project’s dashboard shows a blank white panel and you can’t tell whether it’s broken or just new. That blank panel is the single cheapest bug to find in your own code: open a dashboard for a project with no data and look.

Why hand-rolled useEffect fetching goes wrong

Here is the version agents emit by default:

function Responses({ projectId }: { projectId: string }) {
  const [rows, setRows] = useState<Row[] | null>(null);

  useEffect(() => {
    fetch(`/api/projects/${projectId}/responses`)
      .then((r) => r.json())   // a 500 sails straight through here
      .then(setRows);          // no cleanup, so a late response still wins
  }, [projectId]);

  if (!rows) return <Spinner />;  // "no data yet" and "error" are the same thing
  return <Table rows={rows} />;
}

Four separate problems live in those ten lines.

It has a race. Click project 7, then project 9. Two requests are in flight. Nothing orders them. If 7 answers last, setRows runs last, and the table shows 7’s data under 9’s heading. React does not cancel fetches for you.

It swallows server errors. fetch rejects only on network-level failure. A 500 with a JSON error body is a successful fetch, so r.json() resolves and your error payload becomes your data. This is the most common defect in agent-written fetch helpers: a missing res.ok check.

It has no cache. Navigate away and back and you refetch from scratch, staring at a spinner over data the browser had thirty seconds ago. Two components that need the same data fire two requests.

It has no cancellation. The request keeps running after the component unmounts, and on a slow connection a component that mounts and unmounts repeatedly (a tab, a modal) stacks up requests — a refetch storm.

The hand-written fix for just the race and the cancellation looks like this:

useEffect(() => {
  const controller = new AbortController();
  (async () => {
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);  // now a 500 is an error
    setRows(await res.json());
  })().catch((e) => {
    if (e.name !== "AbortError") setError(e);  // ignore our own cancellation
  });
  return () => controller.abort();  // cleanup runs before the next effect
}, [url]);

That is correct, and it still has no cache, no shared state between components and no retry policy. Multiply it by every panel on the dashboard and you understand why people reach for a library.

TanStack Query’s model

TanStack Query (formerly React Query) is a cache with hooks attached. You don’t tell it when to fetch; you declare what data a component needs, and it works out the rest.

const { data, isPending, isError, error, isFetching } = useQuery({
  queryKey: ["responses", projectId, { status }],   // identity of this data
  queryFn: async ({ signal }) => {                  // signal is handed to you
    const res = await fetch(`/api/projects/${projectId}/responses?status=${status}`, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json() as Promise<Row[]>;
  },
  staleTime: 30_000,   // trust this data for 30 seconds
});

Query keys

The query key is an array that identifies a cache entry. Keys are hashed deterministically, so the order of properties inside an object doesn’t matter, but the order of array items does. The rule worth memorising: every variable the query function reads must appear in the key. It works like the dependency array of a hook. Miss projectId and two projects share one cache entry, which is the race condition again, only now it’s permanent.

Keep the shape consistent — resource, then identifiers, then filters — because invalidation matches by prefix.

Staleness

Data in the cache is either fresh or stale. Stale data is still shown immediately; it just becomes eligible for a background refetch. By default staleTime is 0, meaning data is stale as soon as it arrives, and a stale query refetches when a new component mounts, when the window regains focus, and when the network reconnects.

That default is why dashboards feel jumpy: alt-tab back and everything refetches. The cure is to say how long each piece of data is actually worth trusting. A project’s metadata changes monthly, so staleTime: 5 * 60_000. Live job progress changes constantly, so leave it at 0.

gcTime is a different clock: how long an unused cache entry is kept in memory before being discarded. The default is five minutes, which is what makes going back to a page you just left feel instant.

Loading and error UI

Two flags matter, and conflating them is the usual mistake:

  • isPending — there is no data. Show the skeleton.
  • isFetching — a request is in flight, including a background refresh over data already on screen. Show a thin progress line, not a skeleton.
if (isPending) return <TableSkeleton rows={8} />;
if (isError) return <ErrorPanel message={error.message} onRetry={refetch} />;
if (data.length === 0) return <EmptyState hint="No responses have arrived for this wave yet." />;
return <Table rows={data} busy={isFetching} />;   // busy dims, it does not unmount

One more default to know: a failing query is retried three times with exponential backoff before you see the error. That’s sensible for a flaky network and wrong for a 404 or a FastAPI 422, which will never succeed on retry. Narrow it:

retry: (failureCount, error) =>
  failureCount < 3 && !(error instanceof HttpError && error.status < 500),

Invalidation after a mutation

Writes go through useMutation, and the way you refresh the screen afterwards is to invalidate the keys the write affected.

const queryClient = useQueryClient();
const upload = useMutation({
  mutationFn: (file: File) => postFile(`/api/projects/${projectId}/import`, file),
  onSuccess: () =>
    // returned promise keeps isPending true until the list has actually refreshed
    queryClient.invalidateQueries({ queryKey: ["responses", projectId] }),
});

Invalidation marks matching entries stale and refetches the active ones. Because matching is by prefix, invalidating ["responses", projectId] covers every status filter under it. This is the pattern to look for when a table “doesn’t update after saving” — the agent’s usual workaround is a full page reload, which throws away every other panel’s cache along with it.

Polling a job

Your document-processing and LLM pipelines are long-running, so the front end polls. The agent version:

useQuery({ queryKey: ["job", jobId], queryFn: fetchJob, refetchInterval: 2_000 });

A numeric interval polls for as long as the component is mounted, forever, whatever the job says. Pass a function of the query instead and stop on a terminal status:

useQuery({
  queryKey: ["job", jobId],
  queryFn: () => fetchJobStatus(jobId),
  refetchInterval: (query) => {
    const status = query.state.data?.status;
    return status === "complete" || status === "failed" ? false : 2_000;
  },
});

Polling pauses when the tab is hidden unless you set refetchIntervalInBackground: true — useful for a job you want to finish unattended, and a good way to quietly triple your request volume if you set it without meaning to.

What to check in your own code

  • Search for useEffect next to fetch. Each hit is a candidate race with no cache. Does it return a cleanup that aborts or sets an ignore flag?
  • Search for .then(r => r.json()) and await res.json(). Is there a res.ok check before it? If not, server errors are being rendered as data.
  • Open a dashboard for a brand-new, empty project. Do you get a sentence, or a blank panel?
  • Kill your API (or block the endpoint in devtools) and reload. Do you get a readable error and a retry, or a spinner that never stops?
  • Grep for refetchInterval. Any plain number is polling forever; check whether the job can finish.
  • Grep for queryKey. Does each key contain every variable its query function uses? Any key built with Date.now() or Math.random() is a cache that never hits.
  • Watch the network tab while you alt-tab away and back. A burst of requests means staleTime is still 0 on things that don’t change.

Sources