Skip to content

TanStack Query in 2026: What I Reach For Instead of useEffect

TanStack Query in 2026: What I Reach For Instead of useEffect

Short version for the impatient: every time I write useEffect to fetch data in a React component now, a small voice in my head says “you’re going to regret this on the staleness handling.” That voice is correct about 80% of the time. The rest of this post is what I do instead, and why I don’t think TanStack Query is hype.

I’ve been on Query (formerly React Query) for about four years across maybe twelve projects. The hooks have changed names. The mental model hasn’t. If you’ve been avoiding it because the docs are intimidating, this is the friendly tour I wish someone had written me.

The pattern it actually replaces

Here’s the version of data fetching I wrote in every React app for years:

function Invoices() {
  const [invoices, setInvoices] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch("/api/invoices")
      .then((r) => r.json())
      .then((data) => { if (!cancelled) setInvoices(data); })
      .catch((err) => { if (!cancelled) setError(err); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, []);

  if (loading) return <Spinner />;
  if (error) return <ErrorBox error={error} />;
  return <InvoiceTable rows={invoices} />;
}

That’s 20-ish lines of boilerplate per component, and it does almost none of the things you actually need: no cache, no refetch on focus, no retry, no deduplication if two components ask for the same data, no way to invalidate it when a mutation lands. You end up writing your own context-based cache eventually, and then a colleague writes a slightly different one in another part of the app, and now you have two.

Here’s the same thing with TanStack Query:

function Invoices() {
  const { data, isPending, error } = useQuery({
    queryKey: ["invoices"],
    queryFn: () => fetch("/api/invoices").then((r) => r.json()),
  });

  if (isPending) return <Spinner />;
  if (error) return <ErrorBox error={error} />;
  return <InvoiceTable rows={data} />;
}

That’s the whole component. Six lines for the hook, the same three render branches, and you get caching, deduplication, background refetch, retries with exponential backoff, and staleTime controls for free. If two components on the page both call useQuery({ queryKey: ["invoices"] }), it’s one network request. I shouldn’t need to sell anyone on that anymore but here we are.

Why useEffect for data fetching is the wrong tool

I used to defend useEffect for fetching. “It’s just React, no library lock-in.” That argument falls apart the moment you need any of the following: refetch when the user comes back to the tab, refetch when the network reconnects, share data between two components without prop-drilling, retry a failed request without writing your own backoff, or invalidate a list after a POST without manually re-running the effect.

The React team has been pretty explicit about this. The docs literally say “if you’re not using a framework… or want to make your own custom solution, consider using a data fetching library or building your own.” The reason is that useEffect runs on the client only, has no shared cache, has no race-condition handling, and re-runs on every dependency change without coordination. Suspense and React 19’s use hook help, but for any non-trivial app you still need cache and invalidation, and that’s not what those hooks give you.

Server Components are the other thing that gets brought up. They’re great, and I wrote about how I actually use them. They handle the initial fetch beautifully. They do not handle the interactive client-side stuff: a list that updates when you delete a row, an optimistic toggle, a search box that re-fetches as you type. You still need a client cache for those, and TanStack Query is what I use for it.

The mental model in one sentence

If you only remember one thing from this post: a query key is a global cache key, and any mutation that changes the data behind that key needs to invalidate it.

That’s it. Everything else is API surface area on top of that idea. queryKey: ["invoices"] is a slot in a global cache. queryKey: ["invoice", id] is a different slot per id. When you POST /api/invoices, you call queryClient.invalidateQueries({ queryKey: ["invoices"] }) and any mounted component reading that key refetches.

What made it click for me was TkDodo’s blog, specifically his “Effective React Query Keys” post. He frames keys as the cache identity, not as parameters to a function, and the rest of the library makes sense from there.

What I actually configure on every project

Here are the defaults I set on the QueryClient for every app I start. Not optional in my view:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: (failureCount, error) => {
        if (error.status >= 400 && error.status < 500) return false;
        return failureCount < 3;
      },
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
  },
});

A few things I learned the hard way. staleTime: 0 (the default) is too aggressive for most apps; data refetches on every component mount and you’ll get the dreaded waterfall on a dashboard with twelve widgets. Thirty seconds is a much saner default. The 4xx skip on retry is critical: if the server says 401 or 404, retrying three times with backoff just makes the user wait longer for the same error. refetchOnWindowFocus is the single feature I’d miss most if I had to give up Query. That one almost feels like cheating once you’ve shipped it.

Mutations get their own pattern:

const { mutate, isPending } = useMutation({
  mutationFn: (invoice) =>
    fetch("/api/invoices", { method: "POST", body: JSON.stringify(invoice) })
      .then((r) => { if (!r.ok) throw r; return r.json(); }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["invoices"] });
  },
});

One thing the v5 API got right is consolidating to isPending everywhere instead of the v4 mix of isLoading and isPending. If you’re still on v4, the v5 migration guide is short and worth doing. Type inference is better, the API is more consistent, and you stop apologizing for the legacy names.

SWR vs TanStack Query, briefly

I get asked this every couple of months. They solve the same problem. SWR is smaller, the API is leaner, and if you’re inside Vercel’s ecosystem the defaults line up well with Next.js. TanStack Query has more features (mutations, infinite queries, suspense integration, devtools that are actually good, framework support for Vue/Solid/Svelte) and a deeper config surface.

My honest take after using both: SWR is great for sites that mostly read data and rarely mutate it. TanStack Query is what you want the moment mutations and cache invalidation start to matter. I have a marketing site on SWR and an admin dashboard on Query, and that split has been stable for years.

The devtools is the thing nobody talks about and it’s a quality-of-life difference. You hit a keyboard shortcut, see every query in the cache, click one, see its raw data, see who’s subscribed to it, manually mark it stale. When I’m debugging a “why isn’t this list updating” bug, devtools tells me in five seconds whether the query is even mounted. SWR’s devtools story is fine, but it’s not in the same ballpark.

When I don’t reach for it

For full honesty, here’s when I skip Query.

If the data is loaded once on mount and never refetched and never invalidated, a plain useEffect or a Server Component is simpler. Examples: a static config blob, the user’s locale, a feature flag set at startup. Don’t put those in Query; you’re adding an indirection for nothing.

If the state is fully derived from user input and never round-trips to a server, that’s useState or Zustand territory. I switched a lot of Redux code over to Zustand and the distinction between “client state” (Zustand) and “server state” (Query) is the cleanest mental separation I’ve found. Pick the tool that matches the kind of state.

If you’re rendering a stream, like chat messages, log tails, or anything that’s push-not-pull, Query is the wrong shape. Use a WebSocket or SSE and a reducer, and call setQueryData to nudge the cache when something relevant lands. Don’t try to model a stream as a query.

This is the kind of “pick the right tool” thinking I lean on in my project work. Most architecture mistakes I’ve made came from forcing one abstraction to cover two problems.

What to do this week

If you’ve never used Query, install it (npm i @tanstack/react-query), wrap your root in <QueryClientProvider>, and convert one useEffect-fetch component to useQuery. Pick the one with the most bugs around staleness or duplicate requests; you’ll feel the difference fastest there. Don’t try to convert the whole app in one go.

If you’re already on Query but still on v4, set aside an afternoon and run the codemod. The names are saner now and the type inference catches stuff v4 missed silently.

And if you find yourself writing a useEffect with a fetch in it, ask whether the data has any of the following properties: does it need a cache, does it change behind your back, does another component want it too. If the answer is yes to any of them, you’re writing a cache library. Use one that’s already been written.