DEV Community

Matteo
Matteo

Posted on

React Query, Part 1 — The Mental Model (with running JS examples)

Series plan:

  1. Fundamentals you'll actually use (this post)
  2. Mutations, optimistic UIs & cache mastery
  3. Architecture, error UX, testing & performance

React Query (TanStack Query) treats the server as the source of truth and gives you a cache with a clock. If you grok these five ideas…

  • query keys (what data is this?)
  • staleTime (how long before it's "old"?)
  • enabled (should we fetch now?)
  • keepPreviousData (don't flicker between pages)
  • select (reshape on read, not after)

…then the API becomes muscle memory.

What this guide assumes

  • 100% JavaScript (no TypeScript)
  • One fetch wrapper that throws a typed HttpError
  • Cookies via credentials: 'include'
  • A dependent query chain: /me → /me/summary
  • A keyset-paginated feed that uses keepPreviousData
  • Notes on v5 changes (e.g., side effects belong in components—not onSuccess)

This post explains those ideas with realistic snippets modeled after Inkline (React + fetch wrapper, session-style auth).
There's a tiny mock API so everything runs without a backend. This demo comes with barebone CSS, feel free to tinker with it if you'd like a better-looking result!


Table of Contents

  1. Why React Query?
  2. Project setup
  3. Our fetch wrapper (Inkline-style)
  4. Bootstrapping QueryClient
  5. Reading React Query Devtools like a pro
  6. Idea #1: staleTime (cache with a clock)
  7. Idea #2: query keys (addresses for cached data)
  8. Idea #3: enabled (don't fetch when you can't)
  9. Idea #4: keepPreviousData (pagination that doesn't flash)
  10. Idea #5: select (shape data once, not per render)
  11. Common pitfalls & how we fixed them
  12. Cheatsheet (copy/paste)
  13. What's next (Part 2)
  14. Appendix — Mock API (MSW), App.jsx

Why React Query?

Most of what clutters our components isn't rendering, it's data orchestration:

  • Are we loading? Did this error? Should I refetch?
  • If I visit this route again, can I reuse the data?
  • If another tab already fetched the same thing, can we dedupe?
  • When I change a note, which lists should auto-update?
  • How long is this data fresh before I nag the server again?

React Query (TanStack Query) treats anything from your API as server state, not UI state, and gives that state a cache with a clock plus battle-tested behaviors (deduping, retries, background refetch, GC), so you can focus on rendering.

What "server state" means (and why it's tricky)

UI state (which modal is open, form inputs) lives in your app and changes synchronously.
Server state lives elsewhere, is shared across screens, can be stale, and changes outside your control. That's why rolling your own useEffect + fetch + useState often becomes a web of flags.

You get, for free:

  • Cache + staleness: staleTime, cacheTime (GC), background refetch
  • Deduping & cancellation: concurrent requests for the same key collapse into one
  • Retries with exponential backoff (configurable)
  • Dependent queries (enabled) and derived data (select)
  • Pagination without flicker (keepPreviousData) or full-blown infinite queries
  • DevTools to see cache, observers, states in real time
  • Transport-agnostic: fetch, Axios, GraphQL—doesn't care

When not to use it

  • Purely local UI state (toggled sections, uncontrolled inputs) → stick to React state or a tiny store.
  • Data that must be kept in sync across users in real time (WebSocket/AppSync/etc.): you can still use React Query for initial load + retries + caching, but push updates via your real-time channel.

The mental model we'll use in this series

  1. Query keys are addresses in the cache (['feed', { before: 'cursor' }]).
  2. Data has a clock: fresh → stale → garbage-collected.
  3. Mutations change the server; you invalidate what became stale.
  4. Side effects (toasts, navigation) belong in your component, not in onSuccess handlers (v5 best practice).

If you only internalize staleTime, query keys, enabled, keepPreviousData, and select, the rest of the API becomes muscle memory.


Project setup

Prereqs: Node 18+ (or 20+), npm or pnpm. The demo is 100% JS (no TS).

Expected directory layout

rq-inkline-examples-js-part-1/
├─ public/
│  └─ mockServiceWorker.js      # generated by `npx msw init public --save`
├─ src/
│  ├─ api/
│  │  ├─ client.js              # fetch wrapper (throws HttpError, credentials: 'include')
│  │  └─ keys.js                # central query keys (me, summary, feed, note…)
│  ├─ features/
│  │  ├─ feed/
│  │  │  └─ Feed.jsx            # keyset pagination example (useQuery + keepPreviousData, then useInfiniteQuery)
│  │  └─ me/
│  │     └─ Summary.jsx         # dependent query example (/me → /me/summary)
│  ├─ mocks/
│  │  ├─ browser.js             # MSW boot (worker.start)
│  │  └─ handlers.js            # mock handlers for /me, /me/summary, /feed/public
│  ├─ App.jsx                   # mounts <Summary/> and <Feed/>
│  ├─ index.css                 
│  └─ main.jsx                  # QueryClientProvider, Devtools, and MSW startup (dev only)
├─ index.html
├─ package.json
└─ vite.config.js
Enter fullscreen mode Exit fullscreen mode

Why this shape?

  • src/api/ keeps network concerns in one place: a small client and stable query keys.
  • src/features/* mirrors “screens/feature areas,” so examples read like real code.
  • src/mocks/ isolates MSW boot + handlers; public/mockServiceWorker.js is auto-generated.
  • main.jsx is the only place that knows about QueryClient and starts MSW in dev.
  • App.jsx is intentionally tiny, just composes the two demos so the focus stays on React Query.
# 1) Create app
npm create vite@latest react-query-fundamentals -- --template react
cd react-query-fundamentals

# 2) Install runtime deps
npm i @tanstack/react-query @tanstack/react-query-devtools msw

# 3) Generate the service worker for MSW (mocks)
npx msw init public/ --save
Enter fullscreen mode Exit fullscreen mode

We use MSW to mock endpoints (/me, /me/summary, /feed/public) so the demo is runnable without a server (check out /public/mockServiceWorker.js).


Our fetch wrapper (Inkline-style)

One place that sets headers, includes credentials, and throws errors.
Components handle { data, isLoading, error } — no res.ok branches.

// src/api/client.js
const API = import.meta.env.VITE_API_URL || '';

export class HttpError extends Error {
  constructor(message, status, details) {
    super(message);
    this.status = status;
    this.details = details;
  }
}

export async function request(path, opts = {}) {
  const res = await fetch((API + path), {
    credentials: 'include', // important for cookie sessions
    headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
    ...opts,
  });

  if (!res.ok) {
    let payload = null;
    try { payload = await res.json(); } catch {}
    const msg = payload?.error || res.statusText || 'Request failed';
    throw new HttpError(msg, res.status, payload);
  }
  return res.status === 204 ? null : res.json();
}
Enter fullscreen mode Exit fullscreen mode

Bootstrapping QueryClient

At the very top of the app we create a single QueryClient and pass it to QueryClientProvider. Think of QueryClient as React Query's brain: it holds the cache, timers, and global defaults. Putting the provider at the root lets every component use hooks like useQuery without extra wiring.

A quick tour of the important lines:

  • MSW setup (dev-only):
    1. We start the mock service worker only in development. The BASE_URL trick ensures the service worker is registered under the right path even if your app is hosted at a sub-path.
    2. serviceWorker: { url: swUrl.pathname } keeps the SW same-origin.
    3. onUnhandledRequest: 'bypass' means if a request has no mock handler, MSW lets it go to the network (useful when you only mock some endpoints).
  • QueryClient defaults (the "house rules" for all queries): staleTime, gcTime, refetchOnWindowFocus, retry
  • Devtools: <ReactQueryDevtools /> is a huge time saver. You can see cache entries, status, and force-refetch. It only adds a tiny overlay in development.
// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

// MSW mock API (so you can run without a backend)
if (import.meta.env.DEV) {
  // Construct the correct path whether base is "/" or "/subpath/"
  const swUrl = new URL(
    `${import.meta.env.BASE_URL}mockServiceWorker.js`,
    window.location.origin
  )

  const { worker } = await import('./mocks/browser')
  await worker.start({
    serviceWorker: { url: swUrl.pathname }, // pathname keeps it same-origin
    onUnhandledRequest: 'bypass',
  })
}

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,     // fresh for 30s
      gcTime: 5 * 60_000,    // garbage collect after 5m of inactivity
      refetchOnWindowFocus: true,
      retry: (count, err) => {
        const status = err?.status ?? 0
        if (status === 401 || status === 403) return false // auth endpoints shouldn't retry
        return count < 2
      },
    },
  },
})

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools buttonPosition="bottom-right" />
    </QueryClientProvider>
  </React.StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

Reading React Query Devtools like a pro

Add Devtools once at the root of your project like we did in the main.jsx. Then head to the browser, toggle the devtools, and open the panel (bottom-right button with Tanstack logo) and you’ll see something like this:

React Query Devtools — sample state

What you’re looking at:

  • Badges across the top – quick counters by state: Fresh, Fetching, Paused, Stale, Inactive.
  • Each row = one query – the left column shows the query key (e.g. ["me"], ["me","summary"], ["feed",{ "before": null }]).
  • Status dot – green (fresh), yellow (stale), purple (fetching), gray (inactive).
  • Actions on each row:
    • Remove (clear from cache)
    • Refetch (run the queryFn now)
    • Focus (simulate window refocus to trigger refetch if refetchOnWindowFocus is enabled)
    • Online/Offline (simulate network status)
    • Details (inspect observers, last updated, data preview)

How to verify the concepts from this guide

1) staleTime is actually working

  • Click your ["me"] query; note “Last updated”.
  • Watch the top badges: it should live under Fresh until your staleTime elapses, then move to Stale.
  • Hit Focus (or switch tabs and come back). If refetchOnWindowFocus is true and the query is stale, you’ll see a refetch.

2) Dependent queries via enabled

  • When logged out (or your mock returns 401), you should not see ["me","summary"].
  • After ["me"] resolves to a user, ["me","summary"] appears and fetches once. No more 401 spam.

3) Pagination keys are distinct

  • Click “Load more” once in the feed.
  • You’ll see separate entries like ["feed",{"before":null}] and ["feed",{"before":""}].
  • With keepPreviousData: true, the first page stays fresh/visible while the second page fetches—no flicker.

4) Retry rules

  • If you simulate Offline, trigger a refetch.
  • Your retry function should back off and then stop; for 401/403 you’ll see no retries.

5) Data shape via select

  • Open ["me"] and ["me","summary"]; the Data panel shows already-selected shapes if you used select (e.g., attributes plucked out).
  • That’s why your components don’t do ?.data?.attributes on every render.

Handy moves in Devtools

  • Filter: search by key substring (e.g. type feed or summary).
  • Sort by status → Asc/Desc to bring fetching/stale items to the top.
  • Remove a query to force a “cold” fetch and watch how staleTime and retries behave.
  • Open details and expand Observers to see which components are subscribed.

Official docs: link

Tip: keep Devtools open while writing your rules. If something refetches more than you expect, the panel will tell you why (window focus, stale transition, new observer, manual refetch).


💡 Idea #1

staleTime (cache with a clock)

staleTime controls how long data is considered fresh.
Fresh → no refetch on mount/focus.
Stale → refetch on mount/focus.

Inkline defaults:
staleTime

    staleTime: 30_000
Enter fullscreen mode Exit fullscreen mode

After a successful fetch, data is considered "fresh" for 30 seconds. During that window, React Query won't refetch on mount or when windows refocus. It reduces unnecessary network chatter. You can still manually refetch at any time.

gcTime

     gcTime: 5 * 60_000
Enter fullscreen mode Exit fullscreen mode

Once no component is subscribing to a query, the cached data hangs around for 5 minutes before being garbage collected. This keeps "recently seen" data available if the user navigates back quickly.

Tip: Increase staleTime for data that doesn't change often (e.g., /me), and decrease for highly volatile data.


💡 Idea #2

Centralize and use stable array keys and include params.

// src/api/keys.js
export const keys = {
  me:      () => ['me'],
  summary: () => ['me','summary'],
  feed:    (p) => ['feed', p || {}],          // { before?: string }
  note:    (id) => ['note', String(id)],
  friends: (p) => ['friendships', p || {}],   // { status?: 'accepted' | ... }
}
Enter fullscreen mode Exit fullscreen mode

Why arrays? They're safely serializable and parameterized, and React Query can partially match prefixes.


💡 Idea #3

 
enabled (don't fetch when you can't)

Dependent queries: don't fetch /me/summary until /me resolves.
This avoids 401 spam when the user is logged out.

// src/features/me/Summary.jsx
import { useQuery } from '@tanstack/react-query';
import { request } from '../../api/client';
import { keys } from '../../api/keys';

const useMe = () => useQuery({
  queryKey: keys.me(),
  queryFn: () => request('/me'),
  select: (json) => json?.data?.attributes || null,
  retry: 0, // auth shouldn't retry
});

const Summary = () => {
  const me = useMe();
  const enabled = Boolean(me.data);

  const summary = useQuery({
    queryKey: keys.summary(),
    queryFn: () => request('/me/summary'),
    enabled,   // ← gate the query
    retry: 0,
  });

  if (me.isLoading) return <p>Loading…</p>;
  if (!me.data)     return <p>Log in to see your summary.</p>;

  return (
    <div className="card">
      {summary.isLoading ? (
        <p>Loading summary…</p>
      ) : (
        <p>
          Notes: <strong>{summary.data?.notes_count ?? ''}</strong> ·
          Friends: <strong>{summary.data?.friends_count ?? ''}</strong>
        </p>
      )}
    </div>
  );
};

export default Summary;
Enter fullscreen mode Exit fullscreen mode

💡 Idea #4

keepPreviousData(pagination that doesn't flash)

A cursor-based feed with useQuery and keepPreviousData: true (hybrid), then the native useInfiniteQuery

Why this is a "hybrid"

We keep a local list state and append each newly fetched page to it. That's the "component-managed" part.
We still use React Query to fetch/cache each page (keepPreviousData, staleTime, retries, etc.). That's the "RQ-managed" part.

This gives you full control over how items appear (replace vs append), but it means you must:

  • prevent duplicate pages on refetch,
  • decide when to reset the list,
  • juggle cursors manually.

React Query's native way to do this is useInfiniteQuery, which stores all pages for you. We'll show that right after.

The robust useQuery hybrid

Key ideas:

  1. Replace when cursor === null (first page), append otherwise.
  2. Guard duplicates with a ref that tracks which cursor you've already applied.
  3. Use keepPreviousData: true so the list doesn't flicker during the next page fetch.
// src/features/feed/Feed.jsx
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { request } from '../../api/client';
import { keys } from '../../api/keys';

const Feed = () => {
    const [cursor, setCursor] = useState(null);
    const [list, setList] = useState([]);
    const lastAppliedCursorRef = useRef(null); // avoid dupes/races

    const { data, isSuccess, isLoading, isFetching } = useQuery({
        queryKey: keys.feed({ before: cursor }), // e.g. ['feed', { before: null|cursor }]
        queryFn: () =>
            request(`/feed/public${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`),
        keepPreviousData: true,
        staleTime: 10_000,
        refetchOnWindowFocus: false,
    });

    const pageItems = data?.data ?? [];
    const nextCursor = data?.meta?.next_cursor ?? null;

    useEffect(() => {
        if (!isSuccess) return;

        // Apply each cursor exactly once to avoid duplicates from refetch/retry.
        if (lastAppliedCursorRef.current === cursor && cursor !== null) return;
        lastAppliedCursorRef.current = cursor;

        setList(prev =>
            cursor === null ? pageItems : prev.concat(pageItems)
        );
    }, [isSuccess, cursor, pageItems]);

    if (isLoading && list.length === 0) {
        return <div className="card">Loading…</div>;
    }

    return (
        <section className="feed">
            <div className="feed-list">
                {list.map(n => (
                    <article key={n.id} className="note">
                        <h3>{n.attributes.title}</h3>
                        <p>{n.attributes.body.slice(0, 100)}</p>
                    </article>
                ))}
            </div>

            <div className="row">
                <button
                    className="btn"
                    disabled={!nextCursor || isFetching}
                    onClick={() => setCursor(nextCursor)}
                >
                    {isFetching ? 'Loading…' : nextCursor ? 'Load more' : 'No more'}
                </button>
            </div>
        </section>
    );
};

export default Feed;

Enter fullscreen mode Exit fullscreen mode

Tip: Why the guard matters? Because React Query can refetch the same page (focus, network retry, manual refetch). Without the lastAppliedCursorRef check, the effect would append those items again.

When to prefer useInfiniteQuery (native infinite)

useInfiniteQuery stores pages internally; you don't need a local list or a "last cursor" guard. You only tell it how to get the next cursor. For most infinite feeds, prefer useInfiniteQuery—it’s less code and harder to get wrong.

// src/routes/feed/Feed.jsx
import { useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { request } from '../../api/client';

const Feed = () => {
  const q = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) =>
      request(`/feed/public${pageParam ? `?before=${encodeURIComponent(pageParam)}` : ''}`),
    initialPageParam: null,
    getNextPageParam: (lastPage) => lastPage?.meta?.next_cursor ?? undefined,
    staleTime: 10_000,
    refetchOnWindowFocus: false,
  });

  // derive the full list; memoize to avoid re-flattening on every render
  const list = useMemo(
    () => (q.data?.pages ?? []).flatMap(p => p?.data ?? []),
    [q.data?.pages]
  );

  if (q.isLoading && list.length === 0) {
    return <div className="card">Loading…</div>;
  }

  return (
    <section className="feed">
      <div className="feed-list">
        {list.map(n => (
          <article key={n.id} className="note">
            <h3>{n.attributes.title}</h3>
            <p>{n.attributes.body.slice(0, 100)}</p>
          </article>
        ))}
      </div>

      <div className="row">
        <button
          className="btn"
          disabled={!q.hasNextPage || q.isFetchingNextPage}
          onClick={() => q.fetchNextPage()}
        >
          {q.isFetchingNextPage ? 'Loading…' : q.hasNextPage ? 'Load more' : 'No more'}
        </button>
      </div>
    </section>
  );
};

export default Feed;
Enter fullscreen mode Exit fullscreen mode

Tip: Why useMemo here? q.data.pages is a nested array of page payloads. Flattening it on every render can be wasteful. useMemo recomputes the flattened list only when pages changes—cheap, predictable renders.

Which one should you use?

TL;DR: The hybrid is great when you want fine-grained control or you're extending an existing useQuery flow. For most infinite feeds, prefer useInfiniteQuery—it's less code and harder to get wrong.


💡 Idea #5

select(shape data once, not per render)

Use select to transform server JSON into the shape your UI wants once per fetch.

// useMe above:
select: (json) => json?.data?.attributes || null
Enter fullscreen mode Exit fullscreen mode
  • Cleaner components (no ?.data?.attributes all over).
  • Stable memoization: React Query caches the selected result too.

Common pitfalls & how we fixed them

  1. 401 storms when logged out Mounting
    "private" widgets on public pages can fire many unauthorized requests.
    Gate them with enabled: !!me.data or wrap the section in an auth guard.

  2. Retrying auth endpoints
    Default retry can make /me or /login feel broken.
    retry: 0 for auth queries/mutations, or a function that disables for 401/403.

  3. Missing params in keys
    ['feed'] won’t distinguish pages. Use ['feed', { before }].

  4. Flashing on page change
    keepPreviousData: true.

  5. Transforming data inside JSX
    Doing map/filter/get every render is noisy and costly. Use select to shape data once per fetch.


Cheatsheet (copy/paste)

// Query
useQuery({
  queryKey,                 // array, include params
  queryFn,                  // () => Promise
  enabled,                  // gate by preconditions
  select,                   // shape once per fetch
  staleTime, gcTime,
  keepPreviousData,
  refetchOnWindowFocus,
  retry: (count, err) => count < 2 && ![401,403].includes(err?.status),
})

// Mutation (Part 2)
useMutation({
  mutationFn,
  onMutate, onError, onSuccess, onSettled,
})

// Cache helpers
queryClient.invalidateQueries({ queryKey: keys.feed({}) })
queryClient.setQueryData(keys.me(), updaterFn)
queryClient.cancelQueries({ queryKey: keys.feed({ before }) })
Enter fullscreen mode Exit fullscreen mode

What's next (Part 2)

We'll wire mutations the Inkline way:

  • create/update/delete with useMutation
  • optimistic updates + rollback on error
  • the right invalidation strategy per action
  • toasts + form errors (Formik) without tears

Appendix — Mock API (MSW), App.jsx

We use MSW so the demo runs anywhere:

// src/mocks/handlers.js (excerpt)
import { http, HttpResponse } from 'msw'

let isAuthed = true
let feed = Array.from({ length: 25 }).map((_, i) => ({
  id: String(1000 - i),
  attributes: { title: `Note #${1000 - i}`, body: `Demo body for note #${1000 - i}` }
}))

export const handlers = [
  http.get('/me', () =>
    isAuthed
      ? HttpResponse.json({ data: { type: 'users', id: '1', attributes: { email: 'demo@inkline.live' } }})
      : HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
  ),

  http.get('/me/summary', () =>
    isAuthed
      ? HttpResponse.json({ notes_count: feed.length, friends_count: 7 })
      : HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
  ),

  http.get('/feed/public', ({ request }) => {
    const url = new URL(request.url)
    const before = url.searchParams.get('before')
    const start = before ? feed.findIndex(n => n.id === before) : 0
    const items = feed.slice(Math.max(start, 0), Math.max(start, 0) + 10)
    const next = feed[Math.max(start, 0) + 10]?.id ?? null
    return HttpResponse.json({ data: items, meta: { next_cursor: next } })
  }),
]

Enter fullscreen mode Exit fullscreen mode

This is the final App.jsx:

  import Summary from './features/me/Summary';
import Feed from './features/feed/Feed';

const App = () => (
  <main className="container">
    <h1>React Query - Inkline-style JS Examples</h1>

    <section className='summary'>
      <h2>Dependent query ( /me → /me/summary )</h2>
      <Summary />
    </section>

    <section className='feed'>
      <h2>Keyset pagination ( /feed/public )</h2>
      <Feed />
    </section>
  </main>
);

export default App;

Enter fullscreen mode Exit fullscreen mode

Top comments (0)