AnyLearn
All lessons
Programmingintermediate

Data Fetching in React

From hand-rolled useEffect fetches to TanStack Query v5 and SWR: race conditions, caching, mutations, optimistic updates, error boundaries, and when to push fetching to the server.

Not signed in β€” your progress and quiz score won't be saved.
Lesson progress1 / 10

The naive useEffect fetch

Start with what works for a one-off: fetch on mount, store in state, render.

function User({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    fetch(`/api/users/${id}`).then(r => r.json()).then(setUser);
  }, [id]);
  if (!user) return <Spinner />;
  return <div>{user.name}</div>;
}

This is fine for a quick prototype. It's wrong everywhere else: no caching, no dedup, no error state, no cancellation. Open the page twice in adjacent tabs and you double-fetch. Navigate away mid-flight and you setState on an unmounted component. We'll fix all of that, one footgun at a time.

Full lesson text

All 10 steps on one page β€” for reading, reference, and search.

Show

1. The naive useEffect fetch

Start with what works for a one-off: fetch on mount, store in state, render.

function User({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    fetch(`/api/users/${id}`).then(r => r.json()).then(setUser);
  }, [id]);
  if (!user) return <Spinner />;
  return <div>{user.name}</div>;
}

This is fine for a quick prototype. It's wrong everywhere else: no caching, no dedup, no error state, no cancellation. Open the page twice in adjacent tabs and you double-fetch. Navigate away mid-flight and you setState on an unmounted component. We'll fix all of that, one footgun at a time.

2. Race conditions and cleanup

If id changes faster than fetches resolve, the older response can land last and overwrite the newer one. Two fixes, both via useEffect cleanup:

useEffect(() => {
  let cancelled = false;
  fetch(`/api/users/${id}`).then(r => r.json()).then(u => {
    if (!cancelled) setUser(u);
  });
  return () => { cancelled = true; };
}, [id]);

Better, use AbortController and actually cancel the network request:

useEffect(() => {
  const ctrl = new AbortController();
  fetch(`/api/users/${id}`, { signal: ctrl.signal })
    .then(r => r.json()).then(setUser)
    .catch(e => { if (e.name !== 'AbortError') setError(e); });
  return () => ctrl.abort();
}, [id]);

This is exactly the kind of plumbing data-fetching libraries handle for you.

3. Why a data layer (cache, dedup, refetch)

Any non-trivial app needs four things useEffect doesn't give you:

  • Caching: don't refetch the same key on every render.
  • Deduplication: two components asking for users/42 at the same time make ONE request.
  • Stale-while-revalidate: show cached data instantly, refetch in the background, re-render when fresh.
  • Garbage collection: drop unused entries after a while so you don't grow memory forever.

In 2026 the two mainstream libraries are TanStack Query v5 (formerly React Query) and SWR. Both are excellent. Pick one per app and stop hand-rolling fetchers.

4. TanStack Query v5: queries

A useQuery call has a key (the cache identity) and a fetcher (returns the data or throws).

import { useQuery } from '@tanstack/react-query';

function User({ id }: { id: string }) {
  const q = useQuery({
    queryKey: ['user', id],
    queryFn: async ({ signal }) =>
      fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
    staleTime: 30_000,            // fresh for 30s
  });
  if (q.isPending) return <Spinner />;
  if (q.isError)   return <Error msg={q.error.message} />;
  return <div>{q.data.name}</div>;
}

Mount the same key in three components and you get one network request and three subscribers. Switch tabs and come back, TanStack revalidates in the background. Cancellation is automatic via the signal argument.

5. TanStack Query: mutations and invalidation

Mutations are how you write. Combine with queryClient.invalidateQueries to refresh anything that just became stale.

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const qc = useQueryClient();
  const m = useMutation({
    mutationFn: (title: string) =>
      fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) })
        .then(r => r.json()),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
  });
  return (
    <form onSubmit={e => { e.preventDefault(); m.mutate(new FormData(e.currentTarget).get('title') as string); }}>
      <input name="title" /> <button disabled={m.isPending}>add</button>
    </form>
  );
}

The invalidateQueries call marks every ['todos', ...] entry stale; any currently mounted useQuery(['todos']) will refetch automatically.

6. Optimistic updates

For UI that should feel instant, write the new state into the cache BEFORE the server confirms, then reconcile on response.

const m = useMutation({
  mutationFn: (title: string) => fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }),
  onMutate: async (title) => {
    await qc.cancelQueries({ queryKey: ['todos'] });
    const prev = qc.getQueryData<Todo[]>(['todos']);
    qc.setQueryData<Todo[]>(['todos'], (old = []) => [...old, { id: 'temp', title, done: false }]);
    return { prev };                  // context for onError
  },
  onError: (_e, _v, ctx) => qc.setQueryData(['todos'], ctx?.prev),
  onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});

Three handlers cover the lifecycle: write optimistic, roll back on error, reconcile on settle.

7. SWR: the lighter alternative

SWR (from Vercel) has a smaller surface. The mental model is useSWR(key, fetcher); you get data, error, isLoading, and mutate for cache writes.

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(r => r.json());

function User({ id }: { id: string }) {
  const { data, error, isLoading } = useSWR(`/api/users/${id}`, fetcher);
  if (error) return <Error msg={error.message} />;
  if (isLoading) return <Spinner />;
  return <div>{data.name}</div>;
}

Tradeoffs: SWR is tiny and great for read-mostly UIs. TanStack Query has richer mutation tooling, better devtools, more knobs (offline support, persistence, infinite queries). For new projects in 2026 I default to TanStack Query unless bundle size is critical.

8. Query lifecycle in TanStack Query

Components subscribe to a key. The store dedups requests, caches results, and notifies all subscribers when data changes.

flowchart LR
  A["Component A: useQuery('user', 42)"] --> Q["Query Cache"]
  B["Component B: useQuery('user', 42)"] --> Q
  Q -->|miss| F["queryFn (one fetch)"]
  F --> N["Network"]
  N --> Q
  Q -->|notify subscribers| A
  Q -->|notify subscribers| B

9. Error boundaries and Suspense

TanStack Query and SWR both support a Suspense mode where pending queries throw a Promise (Suspense) and errors throw to the nearest error boundary.

import { ErrorBoundary } from 'react-error-boundary';

<ErrorBoundary fallback={<p>Failed to load.</p>}>
  <Suspense fallback={<Spinner />}>
    <User id={id} />
  </Suspense>
</ErrorBoundary>

With useQuery({ ..., suspense: true }) (or useSuspenseQuery), your component stops branching on isPending/isError; React handles both at the boundary. The component body assumes data is there. The code reads like the happy path, which is the whole point.

10. When to lift fetching to the server

Every fetch you do in the browser is a round-trip you pay for: it costs latency, a JS bundle to make the request, and a loading state to render. In 2026, React Server Components and frameworks like Next.js App Router let you do the fetch on the server and ship rendered HTML + serialized data to the client.

Rule of thumb:

  • Server-fetch the data that is the same for everyone (or for the logged-in user on first paint).
  • Client-fetch the data that mutates frequently, depends on client state (filters, selection), or needs polling.

TanStack Query still earns its keep on the client for interactive widgets and mutations; you just stop using it as a global data layer for everything. The next two lessons cover the Next.js App Router and the server-vs-client split in depth.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Two components mount with `useQuery({ queryKey: ['user', 42], queryFn })`. How many network requests does TanStack Query fire?
    • One β€” the second subscriber gets the in-flight Promise.
    • Two β€” each component fetches independently.
    • Zero β€” the cache always serves stale data first.
    • Three β€” TanStack Query retries on duplicate keys.
  2. Your `useEffect` fetch ignores the most recent change to `id` and shows old data. The MOST robust fix is…
    • Use an AbortController in the effect and call ctrl.abort in cleanup.
    • Wrap setUser in a startTransition to defer the update.
    • Use a global mutex flag in module scope.
    • Switch the fetch to XMLHttpRequest.
  3. After a successful POST /todos mutation, what's the idiomatic way to refresh the visible todos list with TanStack Query?
    • queryClient.invalidateQueries({ queryKey: ['todos'] }) in onSuccess.
    • Force a window.location.reload().
    • Manually setQueryData with a guessed copy of the server response.
    • Disable caching globally and let useQuery refetch on every render.
  4. Which workload is the BEST candidate to fetch in a React Server Component instead of from the browser?
    • A product page's catalog data that's identical for every logged-out user on first paint.
    • A live chart driven by a WebSocket stream.
    • An autocompletion dropdown reacting to every keystroke.
    • A draggable kanban board with optimistic reorder.
  5. In an optimistic update with TanStack Query, what's the right place to roll back if the mutation fails?
    • onError, restoring the previous cache snapshot captured in onMutate.
    • onSuccess, after detecting a non-2xx status.
    • onSettled, ignoring the error.
    • Inside the queryFn of an unrelated useQuery.

Related lessons