React

Data Fetching with TanStack Query

Thirdy Gayares
16 min read

πŸŽ“ What You Will Learn

  • Why hand-rolled useEffect fetching hurts: loading flags, race conditions, zero caching
  • useQuery: queryKey + queryFn, and the isPending / isError / data states
  • Query keys as cache identity: parameterized queries like ['user', userId]
  • Retries done right: automatic exponential backoff + honest loading UI
  • useMutation: POST/PUT/DELETE with invalidateQueries to keep lists fresh
  • Optimistic updates: the onMutate snapshot + rollback pattern
  • staleTime vs gcTime: the mental model that makes the cache predictable
  • When to skip it: server components, tiny apps, and where SWR fits

Prerequisites: you should be comfortable with useEffect and custom hooks β€” this post builds directly on both.

1

The Problem with Hand-Rolled useEffect Fetching

Every React dev writes this component at some point. You need a user profile from the API, so you reach for useEffect + fetch, add a loading flag, add an error state… and before you know it, your "simple fetch" looks like this:

UserProfile.jsx (the hand-rolled version)
import { useState, useEffect } from 'react';

export function UserProfile({ userId }) {
  // Three pieces of state just to track ONE request
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Guard flag: if userId changes mid-request, the OLD response
    // must not overwrite the NEW one (the classic race condition)
    let ignore = false;
    const controller = new AbortController();

    setIsLoading(true);
    setError(null);

    async function load() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) {
          throw new Error(`Request failed with status ${res.status}`);
        }
        const data = await res.json();
        if (!ignore) {
          setUser(data);
        }
      } catch (err) {
        // AbortError is not a real error β€” we cancelled it ourselves
        if (err.name !== 'AbortError' && !ignore) {
          setError(err);
        }
      } finally {
        if (!ignore) {
          setIsLoading(false);
        }
      }
    }

    load();

    // Cleanup: runs when userId changes or the component unmounts
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [userId]); // refetch whenever userId changes

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!user) return null;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.role}</p>
    </div>
  );
}

Almost 60 lines β€” and honestly, this is the careful version. Most tutorials skip the ignore flag and the AbortController, which means most real apps ship the race condition. And even this careful version still has real problems:

  • No cache. Navigate away and back? Full loading spinner again, even for data you fetched 5 seconds ago.
  • No deduplication. Two components need the same user? Two identical requests hit your API.
  • No retries. One flaky network blip = error screen. The user has to refresh manually.
  • No background refresh. Data silently goes stale while the tab sits open.
  • Copy-pasted everywhere. Every fetching component repeats this same 60-line dance, with slightly different bugs.
⚠️ The race condition is real, ha. Without the ignore flag: user clicks profile A (slow response), then profile B (fast response). B renders… then A's late response arrives and overwrites B. The user is looking at the wrong person's profile. This bug ships to production constantly because it only appears on slow networks.

Ang totoo niyan: fetching data is not the hard part. Managing server state over time β€” caching it, syncing it, invalidating it β€” is the hard part. That's exactly the job TanStack Query was built for.

2

What TanStack Query Gives You

TanStack Query (formerly React Query) is a server-state manager. It treats data from your API as a cache that needs syncing β€” not as ordinary component state. You describe what data you need and how to get it; the library handles everything else:

ConcernHand-rolled useEffectTanStack Query
Loading / error state3 useState per request, everywhereBuilt-in: isPending, isError, data
Race conditionsManual ignore flag + AbortControllerHandled automatically per query key
CachingNone β€” refetch on every mountAutomatic, keyed cache with instant replays
Request deduplicationNone β€” N components = N requestsN components, 1 request, shared result
RetriesWrite your own retry loopAutomatic with exponential backoff
Background refetchWrite your own polling/focus logicOn window focus, reconnect, interval β€” built in
Devtoolsconsole.logDedicated devtools panel showing every query's state
πŸ’‘ The mental model shift: useState + useEffect is for client state (form inputs, open modals, toggles). Data that lives on a server is server state β€” you don't own it, it can change without you, and multiple components need the same copy. Different problem, different tool. Once this clicks, you'll stop fighting useEffect for fetching entirely.
3

Install & Setup: QueryClient + QueryClientProvider

Setup is two steps: install the package, then wrap your app in a provider β€” the same pattern you already know from useContext.

1Install the library (+ optional devtools)
terminal
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
2Create ONE QueryClient and provide it at the root
src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';

// One client for the whole app β€” it OWNS the cache.
// Create it OUTSIDE the component so it isn't recreated on re-render.
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // data is "fresh" for 1 minute (more in section 10)
    },
  },
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      {/* Devtools only render in development builds */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);
⚠️ Next.js App Router users: QueryClientProvider uses context, so it must live in a "use client" component. Make a small Providers.tsx client component that creates the client with useState(() => new QueryClient()) (so each request gets its own client during SSR) and wrap it around children in your root layout.
4

Your First useQuery

A query needs exactly two things: a queryKey (the cache's name for this data) and a queryFn (any async function that returns the data or throws). In return you get the full request lifecycle as plain values β€” no useState, no useEffect, no ignore flags.

Try it below. Mount the component and watch isPending do its thing. Then unmount and mount again β€” instant data, because the cache already has it, while a background refetch quietly updates it. One heads-up: since @tanstack/react-query isn't installed in this article's codebase, the demo simulates the library's behavior with plain React (an in-memory Map as the cache, setTimeout as the network). The Code tab shows the real TanStack Query code you'd write.

Component is unmounted. Mount it to fire the query. Then unmount and mount again β€” the second time, the list appears instantly from the cache while a background refetch runs. (This demo simulates the cache with an in-memory Map so it can run inside this article.)

πŸ’‘ isPending vs isFetching β€” this trips everyone up. isPending means "no data yet at all" (first load β€” show a spinner). isFetching means "a request is in flight" β€” including background refetches where you already have data on screen. Show a subtle indicator for isFetching, never a full-page spinner. (In v4 this was called isLoading; v5 renamed it to isPending.)
5

Query Keys Are Cache Identity (and Dependencies)

The queryKey array is the single most important concept in the whole library. It plays two roles at once:

  • Cache identity: ['user', 1] and ['user', 2] are two separate cache entries. Same key anywhere in the app = same shared data, one request.
  • Dependency array: when any value inside the key changes, TanStack Query automatically fetches the new key β€” like a useEffect dependency array, but with caching built in.

Rule of thumb: everything your queryFn uses must be in the key. Fetching page 3 of search results for "shoes"? The key is ['products', { search: 'shoes', page: 3 }]. Try the demo β€” pick a user, then come back to a user you already visited (again, cache simulated in-article; real code in the Code tab):

Pick a user. First visit = loading spinner. Visit the same user again = instant, straight from the cache β€” that's the query key doing its job. (Cache simulated with a Map for this article.)

query cache contents:

(empty)

βœ… See what just happened? The first click on each user showed a loading state; every revisit was instant with zero requests. You didn't write a single line of caching logic β€” you just named your data properly. That cache viewer at the bottom is essentially what the React Query Devtools show you, live, for every query in your app.
6

Loading, Error & Retry States Done Right

When a queryFn throws, TanStack Query doesn't give up immediately β€” it retries (3 times by default) with exponential backoff before setting isError. That means one flaky network blip usually never reaches your users at all.

The demo below simulates the retry flow. Leave the failure toggle on and watch the backoff (1s, then 2s). Or β€” my favorite trick β€” start a fetch, then uncheck the toggle mid-retry to "fix the server" and watch a retry quietly succeed, exactly like a real transient outage:

(request log β€” click "Fetch data")

⚠️ Don't retry everything. Retries are for transient failures (timeouts, 502s, dropped connections). A 404 or 403 will fail all 4 attempts and just make your user wait longer for the same error. In production, make retry conditional: retry: (count, error) => error.status >= 500 && count < 3.
7

Mutations with useMutation

Queries read; mutations write (POST, PUT, DELETE). The magic is what happens after the write: invalidateQueries marks the related cached data as stale, and TanStack Query refetches it automatically. Your todo list updates without you ever manually syncing state.

Add a todo below and read the log: the POST fires, then invalidateQueries(['todos']), then the list refetches itself. (Server + cache simulated in plain React so it runs here.)

['todos'] query

⏳ Loading todos…

(mutation log β€” add a todo and watch the invalidate β†’ refetch flow)

The flow to internalize β€” this is the pattern you'll use for 90% of writes:

the-mutation-flow.txt
mutate(newTodo)
  β†’ POST /api/todos                     (mutationFn runs)
  β†’ onSuccess fires
  β†’ invalidateQueries({ queryKey: ['todos'] })
  β†’ every ['todos'] query is marked STALE
  β†’ active ones refetch automatically
  β†’ every component using useQuery(['todos']) re-renders with fresh data
πŸ’‘ Why invalidate instead of updating the cache by hand? Because the server is the source of truth. The server might set the real id, timestamps, computed fields, or apply validation you don't know about. Refetching guarantees your UI matches reality. Manually patching the cache (via setQueryData) is an optimization β€” which brings us to optimistic updates.
8

Optimistic Updates (onMutate + Rollback)

For interactions that should feel instant β€” likes, toggles, drag reorders β€” waiting even 300ms for the server feels laggy. An optimistic update flips the order: update the UI first, send the request, and roll back only if the server says no. Users get instant feedback; the rare failure snaps back gracefully.

Click the like button β€” the count jumps before the "network" responds. Then turn on the failure toggle and watch the rollback:

Notice the count jumps before the fake network finishes. With the failure toggle on, it snaps back β€” that's the onMutate snapshot + onError rollback flow, simulated here in plain React.

The three-step recipe in the Code tab is always the same:

  • onMutate: cancel in-flight refetches, snapshot the current cache, apply the optimistic value, return the snapshot as context
  • onError: restore the snapshot from context β€” the rollback
  • onSettled: invalidate, so the cache re-syncs with the server either way
⚠️ Don't skip cancelQueries in onMutate. If a background refetch is already in flight when you apply your optimistic value, its response can land a moment later and overwrite your update with stale data. Cancelling first closes that race window.
9

Pagination & Infinite Queries

Since the page number lives in the query key, pagination is almost free: ['products', page] gives each page its own cache entry. The one UX problem: clicking "Next" on an uncached page normally blanks the list to a spinner. Fix it with placeholderData: keepPreviousData β€” the old page stays on screen (flagged by isPlaceholderData) until the new one arrives:

ProductList.jsx
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useState } from 'react';

async function fetchProducts(page) {
  const res = await fetch(`/api/products?page=${page}&limit=10`);
  return res.json(); // { items: [...], hasMore: true }
}

export function ProductList() {
  const [page, setPage] = useState(1);

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ['products', page], // page is part of the key
    queryFn: () => fetchProducts(page),
    // v5: keepPreviousData is a helper you pass to placeholderData.
    // (The old v4 boolean option `keepPreviousData: true` is gone.)
    placeholderData: keepPreviousData,
  });

  if (isPending) return <p>Loading products…</p>;

  return (
    <div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
      <ul>
        {data.items.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
      <button onClick={() => setPage((p) => Math.max(p - 1, 1))} disabled={page === 1}>
        Previous
      </button>
      <span> Page {page} </span>
      <button
        onClick={() => setPage((p) => p + 1)}
        disabled={isPlaceholderData || !data.hasMore}
      >
        Next
      </button>
    </div>
  );
}

For infinite scroll / "Load more" feeds, switch to useInfiniteQuery β€” it accumulates pages into data.pages and tracks the cursor for you:

Feed.jsx
import { useInfiniteQuery } from '@tanstack/react-query';

export function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['feed'],
      queryFn: ({ pageParam }) =>
        fetch(`/api/feed?cursor=${pageParam}`).then((res) => res.json()),
      initialPageParam: 0, // required in v5
      // Return the next cursor, or undefined when there are no more pages
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    });

  return (
    <div>
      {data?.pages.map((page) =>
        page.items.map((post) => <PostCard key={post.id} post={post} />)
      )}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more…'
          : hasNextPage
            ? 'Load more'
            : 'No more posts'}
      </button>
    </div>
  );
}
10

staleTime vs gcTime β€” The Mental Model

These two settings confuse everyone, pero simple lang talaga once you see that cached data moves through three states:

cache-lifecycle.txt
fetched ──[staleTime elapses]──▢ stale ──[unused + gcTime elapses]──▢ garbage-collected

FRESH   : "this data is good" β€” components use it, NO refetch happens
STALE   : "usable but maybe outdated" β€” shown instantly, refetched in background
GC'd    : removed from memory β€” next use is a full loading-spinner fetch
SettingQuestion it answersDefaultEffect
staleTimeHow long is data considered fresh?0 (stale immediately)Fresh data is served from cache with NO refetch at all
gcTimeHow long does unused data stay in memory?5 minutesAfter this, the cache entry is deleted β€” next mount shows isPending again

With the default staleTime: 0, every mount and every window focus triggers a background refetch. That's safe but chatty. Tune it per query based on how often the data actually changes:

query-config-examples.jsx
// Country list β€” changes basically never
useQuery({
  queryKey: ['countries'],
  queryFn: fetchCountries,
  staleTime: Infinity,        // never refetch automatically
});

// Product catalog β€” changes a few times a day
useQuery({
  queryKey: ['products', page],
  queryFn: () => fetchProducts(page),
  staleTime: 5 * 60 * 1000,   // fresh for 5 minutes
});

// Notifications β€” should feel live
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 0,               // always stale β†’ refetch on focus/mount
  refetchInterval: 30 * 1000, // and poll every 30s
});
πŸ’‘ v5 rename: gcTime was called cacheTime in v4. The rename is honest β€” it never controlled "how long things are cached", it controls when unused entries get garbage-collected. Remembering "staleTime = freshness, gcTime = memory" will save you from 90% of cache-tuning confusion.
11

When You DON'T Need TanStack Query

Honest talk: hindi ito kailangan ng lahat ng app. TanStack Query shines when the client owns interactive server state. Skip it when:

  • You're in a React Server Component. A one-shot read that renders on the server needs no client cache at all β€” just await the fetch:
app/users/page.tsx (Next.js server component)
// No "use client", no useQuery, no loading state to manage.
// The server fetches, renders HTML, and Next.js handles caching.
export default async function UsersPage() {
  const res = await fetch('https://api.example.com/users', {
    next: { revalidate: 60 }, // Next.js-level caching
  });
  const users = await res.json();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
  • Tiny apps with 1–2 fetches that load once and never change β€” a well-written useEffect (or a small custom hook like the one from the custom hooks post) is fine. Adding a library to fetch one config file is over-engineering.
  • You prefer a smaller API: SWR (by Vercel) covers the same core ideas β€” cache keys, revalidate on focus, dedupe β€” with fewer features and a lighter footprint. If your app is mostly reads with simple writes, SWR is a legit choice. TanStack Query pulls ahead on mutations, optimistic updates, infinite queries, and devtools.
βœ… Quick decision rule: interactive dashboards, lists the user edits, anything with pagination or live-ish data β†’ TanStack Query. Content that renders once on the server β†’ server component fetch. One-off static lookup in a tiny SPA β†’ plain useEffect, walang arte.
12

Common Mistakes

1Unstable query keys
⚠️ Keys are hashed by value β€” but only if the value is stable. Putting a value in the key that changes on every render (a new Date, a non-memoized computed object) makes every render look like a brand-new query: infinite refetch loop, cache never hit.
mistake-unstable-key.jsx
// ❌ BAD: new Date() changes every render β†’ new key β†’ refetch forever
useQuery({
  queryKey: ['report', new Date()],
  queryFn: fetchReport,
});

// βœ… GOOD: derive a stable value first
const today = new Date().toISOString().slice(0, 10); // "2026-07-18"
useQuery({
  queryKey: ['report', today],
  queryFn: () => fetchReport(today),
});
2Still fetching in useEffect, then dumping into the cache
mistake-useeffect-bridge.jsx
// ❌ BAD: fetching in useEffect and pushing into TanStack Query manually β€”
// you get NONE of the dedupe/retry/race protection
useEffect(() => {
  fetch('/api/users')
    .then((res) => res.json())
    .then((data) => queryClient.setQueryData(['users'], data));
}, [queryClient]);

// βœ… GOOD: let the library own the fetch
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
3Ignoring `enabled` for dependent queries
⚠️ Firing a query before its inputs exist is a classic: ['orders', user.id] when user is still undefined sends /api/orders/undefined to your backend. Gate it with enabled β€” the query simply waits.
mistake-missing-enabled.jsx
const { data: user } = useQuery({
  queryKey: ['me'],
  queryFn: fetchMe,
});

// βœ… This query won't run until user?.id actually exists
const { data: orders } = useQuery({
  queryKey: ['orders', user?.id],
  queryFn: () => fetchOrders(user.id),
  enabled: !!user?.id,
});
4Over-invalidating
⚠️ queryClient.invalidateQueries() with no arguments nukes the entire cache β€” every query in the app refetches after every mutation. On a dashboard with 15 queries, that's a 15-request stampede because someone renamed a todo. Always scope the invalidation to the keys the mutation actually affected: invalidateQueries({ queryKey: ['todos'] }).
13

Best Practices Checklist

βœ…Everything the queryFn uses goes in the queryKey β€” treat it like a useEffect dependency array
βœ…Wrap queries in custom hooks (useUsers(), useTodos()) so keys and fetchers live in one place, not scattered across components
βœ…Set a sensible default staleTime (30–60s) on the QueryClient, then tune per query
βœ…Invalidate narrowly after mutations β€” only the keys the write actually changed
βœ…Use enabled for dependent queries instead of conditional hook calls (Rules of Hooks!)
βœ…Save optimistic updates for instant-feel interactions β€” plain invalidation is simpler and right for most writes
βœ…Install the devtools β€” watching queries flip between fresh/stale/inactive teaches the cache model faster than any article (kahit itong isa πŸ˜…)
APIWhat it's forReturns / key fields
useQueryRead data with cachingdata, isPending, isError, isFetching, refetch
useMutationCreate / update / deletemutate, isPending, onSuccess / onError
useInfiniteQueryInfinite scroll / load-moredata.pages, fetchNextPage, hasNextPage
useQueryClientAccess the cache imperativelyinvalidateQueries, setQueryData, cancelQueries
staleTimeHow long data counts as fresh0 by default β€” fresh data never refetches
gcTimeWhen unused cache entries are dropped5 min by default (was cacheTime in v4)
14

Practice Project + What's Next

Cement all of this by building a GitHub repo explorer (the GitHub API is free, no key needed for light use):

  • Search input β†’ useQuery({ queryKey: ['repos', searchTerm], ... }) hitting https://api.github.com/search/repositories?q=…
  • Click a repo β†’ detail view with ['repo', owner, name] β€” verify revisits are instant from cache
  • Paginate results with placeholderData: keepPreviousData β€” no spinner flash between pages
  • Add a client-side "favorite" toggle with an optimistic update (fake the server with a delay, like our demos did)
  • Open the React Query Devtools and watch each query go fresh β†’ stale β†’ inactive β†’ garbage-collected

If you can explain to a friend why the detail view is instant the second time β€” sa cache galing, hindi sa network β€” you've got it.

πŸš€ Next Learning Topics:
  • useEffect Deep Dive β€” everything TanStack Query replaced still matters for non-fetch side effects (subscriptions, timers, DOM APIs)
  • Custom Hooks β€” the pattern behind useUsers() / useTodos() wrappers that keep your query logic organized
  • State Management β€” TanStack Query owns server state; learn where Context, Zustand, and Redux fit for client state

Keep practicing! πŸ’ͺ The day TanStack Query really clicked for me was the day I stopped thinking "how do I fetch this?" and started thinking "what is this data's name, and when does it go stale?" Once server state has proper names, whole categories of bugs β€” race conditions, stale screens, duplicate requests β€” just stop existing in your codebase.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.