{"id":258,"date":"2026-05-20T13:01:20","date_gmt":"2026-05-20T13:01:20","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/tanstack-query-2026-what-i-reach-for-instead-of-useeffect\/"},"modified":"2026-05-20T13:01:20","modified_gmt":"2026-05-20T13:01:20","slug":"tanstack-query-2026-what-i-reach-for-instead-of-useeffect","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/tanstack-query-2026-what-i-reach-for-instead-of-useeffect\/","title":{"rendered":"TanStack Query in 2026: What I Reach For Instead of useEffect"},"content":{"rendered":"<p>Short version for the impatient: every time I write <code>useEffect<\/code> to fetch data in a React component now, a small voice in my head says &ldquo;you&rsquo;re going to regret this on the staleness handling.&rdquo; That voice is correct about 80% of the time. The rest of this post is what I do instead, and why I don&rsquo;t think <a href=\"https:\/\/tanstack.com\/query\/latest\" rel=\"nofollow noopener\" target=\"_blank\">TanStack Query<\/a> is hype.<\/p>\n<p>I&rsquo;ve been on Query (formerly React Query) for about four years across maybe twelve projects. The hooks have changed names. The mental model hasn&rsquo;t. If you&rsquo;ve been avoiding it because the docs are intimidating, this is the friendly tour I wish someone had written me.<\/p>\n<h2 id=\"the-pattern-it-actually-replaces\">The pattern it actually replaces<\/h2>\n<p>Here&rsquo;s the version of data fetching I wrote in every React app for years:<\/p>\n<pre><code class=\"language-jsx\">function Invoices() {\n  const [invoices, setInvoices] = useState([]);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(null);\n\n  useEffect(() =&gt; {\n    let cancelled = false;\n    setLoading(true);\n    fetch(&quot;\/api\/invoices&quot;)\n      .then((r) =&gt; r.json())\n      .then((data) =&gt; { if (!cancelled) setInvoices(data); })\n      .catch((err) =&gt; { if (!cancelled) setError(err); })\n      .finally(() =&gt; { if (!cancelled) setLoading(false); });\n    return () =&gt; { cancelled = true; };\n  }, []);\n\n  if (loading) return &lt;Spinner \/&gt;;\n  if (error) return &lt;ErrorBox error={error} \/&gt;;\n  return &lt;InvoiceTable rows={invoices} \/&gt;;\n}\n<\/code><\/pre>\n<p>That&rsquo;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.<\/p>\n<p>Here&rsquo;s the same thing with TanStack Query:<\/p>\n<pre><code class=\"language-jsx\">function Invoices() {\n  const { data, isPending, error } = useQuery({\n    queryKey: [&quot;invoices&quot;],\n    queryFn: () =&gt; fetch(&quot;\/api\/invoices&quot;).then((r) =&gt; r.json()),\n  });\n\n  if (isPending) return &lt;Spinner \/&gt;;\n  if (error) return &lt;ErrorBox error={error} \/&gt;;\n  return &lt;InvoiceTable rows={data} \/&gt;;\n}\n<\/code><\/pre>\n<p>That&rsquo;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 <code>staleTime<\/code> controls for free. If two components on the page both call <code>useQuery({ queryKey: [\"invoices\"] })<\/code>, it&rsquo;s one network request. I shouldn&rsquo;t need to sell anyone on that anymore but here we are.<\/p>\n<h2 id=\"why-useeffect-for-data-fetching-is-the-wrong-tool\">Why useEffect for data fetching is the wrong tool<\/h2>\n<p>I used to defend <code>useEffect<\/code> for fetching. &ldquo;It&rsquo;s just React, no library lock-in.&rdquo; 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.<\/p>\n<p>The React team has been <a href=\"https:\/\/react.dev\/reference\/react\/useEffect#fetching-data-with-effects\" rel=\"nofollow noopener\" target=\"_blank\">pretty explicit about this<\/a>. The docs literally say &ldquo;if you&rsquo;re not using a framework&hellip; or want to make your own custom solution, consider using a data fetching library or building your own.&rdquo; The reason is that <code>useEffect<\/code> 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&rsquo;s <code>use<\/code> hook help, but for any non-trivial app you still need cache and invalidation, and that&rsquo;s not what those hooks give you.<\/p>\n<p>Server Components are the other thing that gets brought up. They&rsquo;re great, and I <a href=\"https:\/\/abrarqasim.com\/blog\/react-server-components-eight-months-in-production\/\" rel=\"noopener\">wrote about how I actually use them<\/a>. 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.<\/p>\n<h2 id=\"the-mental-model-in-one-sentence\">The mental model in one sentence<\/h2>\n<p>If you only remember one thing from this post: <strong>a query key is a global cache key, and any mutation that changes the data behind that key needs to invalidate it.<\/strong><\/p>\n<p>That&rsquo;s it. Everything else is API surface area on top of that idea. <code>queryKey: [\"invoices\"]<\/code> is a slot in a global cache. <code>queryKey: [\"invoice\", id]<\/code> is a different slot per id. When you <code>POST \/api\/invoices<\/code>, you call <code>queryClient.invalidateQueries({ queryKey: [\"invoices\"] })<\/code> and any mounted component reading that key refetches.<\/p>\n<p>What made it click for me was <a href=\"https:\/\/tkdodo.eu\/blog\" rel=\"nofollow noopener\" target=\"_blank\">TkDodo&rsquo;s blog<\/a>, specifically his &ldquo;Effective React Query Keys&rdquo; post. He frames keys as the cache identity, not as parameters to a function, and the rest of the library makes sense from there.<\/p>\n<h2 id=\"what-i-actually-configure-on-every-project\">What I actually configure on every project<\/h2>\n<p>Here are the defaults I set on the <code>QueryClient<\/code> for every app I start. Not optional in my view:<\/p>\n<pre><code class=\"language-jsx\">const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 30_000,\n      gcTime: 5 * 60_000,\n      retry: (failureCount, error) =&gt; {\n        if (error.status &gt;= 400 &amp;&amp; error.status &lt; 500) return false;\n        return failureCount &lt; 3;\n      },\n      refetchOnWindowFocus: true,\n      refetchOnReconnect: true,\n    },\n  },\n});\n<\/code><\/pre>\n<p>A few things I learned the hard way. <code>staleTime: 0<\/code> (the default) is too aggressive for most apps; data refetches on every component mount and you&rsquo;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. <code>refetchOnWindowFocus<\/code> is the single feature I&rsquo;d miss most if I had to give up Query. That one almost feels like cheating once you&rsquo;ve shipped it.<\/p>\n<p>Mutations get their own pattern:<\/p>\n<pre><code class=\"language-jsx\">const { mutate, isPending } = useMutation({\n  mutationFn: (invoice) =&gt;\n    fetch(&quot;\/api\/invoices&quot;, { method: &quot;POST&quot;, body: JSON.stringify(invoice) })\n      .then((r) =&gt; { if (!r.ok) throw r; return r.json(); }),\n  onSuccess: () =&gt; {\n    queryClient.invalidateQueries({ queryKey: [&quot;invoices&quot;] });\n  },\n});\n<\/code><\/pre>\n<p>One thing the v5 API got right is consolidating to <code>isPending<\/code> everywhere instead of the v4 mix of <code>isLoading<\/code> and <code>isPending<\/code>. If you&rsquo;re still on v4, the <a href=\"https:\/\/tanstack.com\/query\/latest\/docs\/framework\/react\/guides\/migrating-to-v5\" rel=\"nofollow noopener\" target=\"_blank\">v5 migration guide<\/a> is short and worth doing. Type inference is better, the API is more consistent, and you stop apologizing for the legacy names.<\/p>\n<h2 id=\"swr-vs-tanstack-query-briefly\">SWR vs TanStack Query, briefly<\/h2>\n<p>I get asked this every couple of months. They solve the same problem. SWR is smaller, the API is leaner, and if you&rsquo;re inside Vercel&rsquo;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.<\/p>\n<p>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.<\/p>\n<p>The devtools is the thing nobody talks about and it&rsquo;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&rsquo;s subscribed to it, manually mark it stale. When I&rsquo;m debugging a &ldquo;why isn&rsquo;t this list updating&rdquo; bug, devtools tells me in five seconds whether the query is even mounted. SWR&rsquo;s devtools story is fine, but it&rsquo;s not in the same ballpark.<\/p>\n<h2 id=\"when-i-dont-reach-for-it\">When I don&rsquo;t reach for it<\/h2>\n<p>For full honesty, here&rsquo;s when I skip Query.<\/p>\n<p>If the data is loaded once on mount and never refetched and never invalidated, a plain <code>useEffect<\/code> or a Server Component is simpler. Examples: a static config blob, the user&rsquo;s locale, a feature flag set at startup. Don&rsquo;t put those in Query; you&rsquo;re adding an indirection for nothing.<\/p>\n<p>If the state is fully derived from user input and never round-trips to a server, that&rsquo;s <code>useState<\/code> or Zustand territory. <a href=\"https:\/\/abrarqasim.com\/blog\/zustand-vs-redux-2026-why-i-replaced-redux-toolkit\/\" rel=\"noopener\">I switched a lot of Redux code over to Zustand<\/a> and the distinction between &ldquo;client state&rdquo; (Zustand) and &ldquo;server state&rdquo; (Query) is the cleanest mental separation I&rsquo;ve found. Pick the tool that matches the kind of state.<\/p>\n<p>If you&rsquo;re rendering a stream, like chat messages, log tails, or anything that&rsquo;s push-not-pull, Query is the wrong shape. Use a <a href=\"https:\/\/abrarqasim.com\/blog\/websockets-vs-server-sent-events-when-i-reach-for-each\/\" rel=\"noopener\">WebSocket or SSE<\/a> and a reducer, and call <code>setQueryData<\/code> to nudge the cache when something relevant lands. Don&rsquo;t try to model a stream as a query.<\/p>\n<p>This is the kind of &ldquo;pick the right tool&rdquo; thinking I lean on in my <a href=\"https:\/\/abrarqasim.com\/work\/\" rel=\"noopener\">project work<\/a>. Most architecture mistakes I&rsquo;ve made came from forcing one abstraction to cover two problems.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>If you&rsquo;ve never used Query, install it (<code>npm i @tanstack\/react-query<\/code>), wrap your root in <code>&lt;QueryClientProvider&gt;<\/code>, and convert one <code>useEffect<\/code>-fetch component to <code>useQuery<\/code>. Pick the one with the most bugs around staleness or duplicate requests; you&rsquo;ll feel the difference fastest there. Don&rsquo;t try to convert the whole app in one go.<\/p>\n<p>If you&rsquo;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.<\/p>\n<p>And if you find yourself writing a <code>useEffect<\/code> 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&rsquo;re writing a cache library. Use one that&rsquo;s already been written.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why I stopped writing useEffect-for-data-fetching and what TanStack Query actually replaces in 2026, with real before\/after code from production.<\/p>\n","protected":false},"author":2,"featured_media":257,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Why I stopped writing useEffect-for-data-fetching and what TanStack Query actually replaces in 2026, with real before\/after code from production.","rank_math_focus_keyword":"tanstack react query","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[301,38,44,41,300,299,195],"class_list":["post-258","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-data-fetching","tag-frontend","tag-javascript","tag-react","tag-react-query","tag-tanstack-query","tag-useeffect"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/258","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=258"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/258\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/257"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=258"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=258"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=258"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}