DevPath · Learn to code ESPTEN

Data and global state

Modern data fetching: TanStack Query and SWR

The problem with hand-written useEffect + fetch

Loading data from the server seems simple: a useEffect that runs fetch and stores the result in state. It's the first thing we learn, and for an isolated case it works:

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((r) => {
        if (!r.ok) throw new Error("Error " + r.status);
        return r.json();
      })
      .then((data) => { if (!cancelled) setUser(data); })
      .catch((e) => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [userId]);

  // ...render based on loading / error / user
}

Look at how much plumbing code there is for a single request, and it's still missing things. As the app grows, this pattern falls short because every screen has to reinvent:

These problems don't belong to client state (what the user types or selects): they belong to server state, data that lives remotely, that we cache on the client and that can become outdated. Data fetching libraries are born to manage exactly that.

TanStack Query (React Query)

TanStack Query treats server state as a cached resource. The central piece is the useQuery hook:

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

function Profile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () =>
      fetch(`/api/users/${userId}`).then((r) => r.json()),
    staleTime: 60_000, // data "fresh" for 60 s
  });

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong</p>;
  return <h1>{data.name}</h1>;
}

The keys:

Everything you used to write by hand —cache, deduplication, revalidation, cancellation— comes out of the box.

SWR

SWR (from Vercel) follows the same philosophy with a more minimalist API. Its name comes from stale-while-revalidate: it shows the cached data (stale) instantly and, in parallel, revalidates in the background.

import useSWR from "swr";

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

function Profile({ userId }) {
  const { data, error, isLoading } = useSWR(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Something went wrong</p>;
  return <h1>{data.name}</h1>;
}

Here the cache key is the URL itself (first argument) and the fetcher is the download function. SWR also deduplicates requests, revalidates on refocus and reconnect, and exposes mutate() to update the cache.

Key idea: separate the server state (cached remote data → TanStack Query / SWR) from the client state (local UI → useState, Zustand...). Don't put server data into a global store "by hand"; let a query library manage its cache and freshness.

(These libraries are installed separately; here we study them conceptually. In the validable exercises we keep using React's native hooks.)

Put this into practice

DevPath is a hands-on course: you read the theory here; in the app you put it into practice with exercises that really run, offline.

Start free in the app →
Global state: beyond Context →