AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 12 — Data Fetching in React — TanStack Query, SWR, and Why a Cache Beats a Fetch

12 — Data Fetching in React — TanStack Query, SWR, and Why a Cache Beats a Fetch

August 13, 20268 min read
Download as Markdown

"Just use fetch" stopped being enough almost immediately in React, and the reimplementation cost taught me the boundary. The framing that organized it: the moment I need caching, request deduplication, loading/error states, retries, background refresh, or mutation sync across components, a dedicated data-fetching library earns its keep. [1][2] Raw fetch in a useEffect does none of that, and reimplementing each piece by hand is how half-finished data layers are born.

Why fetch in useEffect isn't enough

The starting point almost everyone writes first:

useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);

This works for a hello-world. The moment it hits a real app it breaks down, because it doesn't handle:

  • Deduplication. Two components mounting both fire the same /api/users request — a duplicate round trip.
  • Caching. Navigating away and back re-fetches, even if the data hasn't changed.
  • Loading and error states. I'm hand-rolling isLoading, error, and data flags every time.
  • Background refresh. Stale-while-revalidate, refetch on focus, polling — all DIY.
  • Race conditions. A late response overwriting a newer one because I didn't track the latest request.
  • Mutations. After a POST, I have to manually refetch or update local state across every component that read that data.

Each of those is a real problem I'll hit. A data-fetching library solves them as a bundle.

TanStack Query: server state as a first-class concern

TanStack Query (formerly React Query) is the dominant answer for REST-style data [1]. Its core move is to treat server state as distinct from client state — fetched data lives in a query cache keyed by an identifier, and components subscribe to that cache rather than holding their own copy.

const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});

Out of the box, this one hook gives me: request deduplication (two components with the same queryKey share one request), a cache (navigating back returns cached data instantly and revalidates in the background), loading and error states, automatic retries, and stale-while-revalidate on window focus. Mutations work through useMutation, and queryClient.invalidateQueries(['users']) after a mutation triggers a refetch for every component reading users — the cross-component sync problem disappears.

fetch in useEffect — duplicate Comp A Comp B Comp C /api/users 3 requests 3 loading flags no cache · no dedup manual refetch after mutation TanStack Query — shared cache Comp A Comp B Comp C query cache ['users'] 1 request · cached auto refetch dedup · cache · stale-while-revalidate invalidateQueries syncs every reader

SWR: the stale-while-revalidate original

SWR (from Vercel) is the other main REST-side option, named after the HTTP cache invalidation strategy stale-while-revalidate [2]. Its model is similar to TanStack Query's: a hook returns cached data first (stale), fires a background request (revalidate), then returns the up-to-date data. With one hook, the boilerplate of loading flags, error handling, and refetch logic collapses.

const { data, error } = useSWR('/api/users', fetcher);

SWR is smaller and simpler than TanStack Query — fewer features, less API surface. For straightforward data fetching where I don't need the full mutation/invalidation machinery, SWR is lighter. TanStack Query is the choice when mutations, optimistic updates, and fine-grained cache control become central; the two share the same fundamental insight (server state in a cache, components subscribe).

Axios: the HTTP client layer

Axios sits below the cache layer — it's an HTTP client, not a data-fetching library [3]. It's a popular alternative to the native fetch API, offering interceptors (global request/response transforms), automatic JSON parsing, request cancellation, and a more ergonomic API than raw fetch. The roadmap lists it under API calls [3], and the way I use it: as the queryFn/fetcher implementation inside TanStack Query or SWR, not as a standalone data layer. Axios handles the HTTP mechanics; the cache library handles the React integration.

The GraphQL clients: Apollo, Relay, urql

When the API is GraphQL rather than REST, a GraphQL-specific client takes the cache-library job. The frontend notes cover GraphQL in depth; for these notes, the React-side mapping:

  • Apollo Client is the general-purpose default — caching, queries, mutations, loading state, with strong React bindings [4]. The normalized cache deduplicates entities across queries. For most React + GraphQL apps, Apollo is the "pick this one" answer.
  • Relay is Meta's client, built for data-heavy apps at scale, with co-located fragments compiled at build time [5]. Powerful, but a strict way of thinking and a mandatory build step.
  • urql is the lightweight alternative — simpler and smaller than Apollo, with a flexible extensible architecture [6]. Good for small-to-medium apps where Apollo's surface is too much.

The decision is the same shape as the REST side: what's the equivalent of "cache + loading + mutation sync" for my data source? For REST it's TanStack Query or SWR; for GraphQL it's Apollo/Relay/urql. The GraphQL clients fold the HTTP layer in too, so Axios isn't in the picture there.

RTK Query: Redux Toolkit's bundled answer

RTK Query is worth naming because it's the option if I'm already in Redux [7]. It's a data-fetching and caching tool built into Redux Toolkit, designed to simplify fetching, caching, polling, and invalidation — the same problem space as TanStack Query, integrated with the Redux store. If the project already uses Redux, RTK Query is the natural fit; if not, I'd reach for TanStack Query rather than adopting Redux just to get it.

How I use this

The way of thinking I run is "server state is not client state." Fetched data lives in a cache, and components subscribe to that cache — they don't each hold their own copy in useState. For REST APIs, TanStack Query is my default: it handles the full lifecycle, and after a mutation I call invalidateQueries rather than threading updates through the component tree. For GraphQL, Apollo Client covers the same ground. SWR is my pick for simpler fetches where the full TanStack Query API would be overkill. Axios is the HTTP client underneath, when I need its interceptors or ergonomics over raw fetch. The thing I never do anymore is fetch in a bare useEffect for anything beyond a throwaway — every one of those is a future bug report about loading spinners, stale data, or duplicate requests.

References

[1] TanStack, "TanStack Query — powerful asynchronous state management," tanstack.com, 2024. [Online]. Available: https://tanstack.com/query/latest

[2] Vercel, "SWR — React hooks for data fetching," swr.vercel.app, 2024. [Online]. Available: https://swr.vercel.app/

[3] Axios, "Axios — getting started," axios-http.com, 2024. [Online]. Available: https://axios-http.com/docs/intro

[4] Apollo GraphQL, "Get started with Apollo Client," apollographql.com, 2024. [Online]. Available: https://www.apollographql.com/docs/react/

[5] Meta, "Relay — a JavaScript framework for building data-driven React applications," relay.dev, 2024. [Online]. Available: https://relay.dev/

[6] Formidable Labs, "urql — Universal React Query Library," formidable.com, 2024. [Online]. Available: https://formidable.com/open-source/urql/

[7] Redux team, "RTK Query — overview," redux-toolkit.js.org, 2024. [Online]. Available: https://redux-toolkit.js.org/rtk-query/overview

Knowledge check · Question 1 of 5

The core problem with raw fetch in a useEffect is:

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!