Inspecting Cache State With React Query DevTools

The DevTools panel answers most cache questions in seconds — but only if you read its badges the way the library means them. Half the confusing reports about “React Query is refetching for no reason” come from reading fetching as an error state, or inactive as a leak, or fresh as a claim about correctness. This page covers reading the panel accurately and shipping it where you actually need it, under Cache Observability & Debugging. Once the panel has told you what is happening, Logging Query Lifecycle Events for Debugging tells you why, and Measuring Cache Hit Rate in Production tells you whether it happens to real users.

Prerequisites

  • @tanstack/react-query-devtools v5 installed as a dev dependency.
  • A QueryClientProvider you can render a child inside.
  • A build setup that can code-split a dynamic import.
Status and fetchStatus are two independent axes A grid with status pending, success and error across the top, and fetchStatus idle, fetching and paused down the side. Each cell describes what the combination means in the DevTools panel, showing for example that success plus fetching is a background refetch, not a loading state. One badge is about data; the other is about the network status: pending status: success status: error idle created but disabled enabled is false the resting state fresh or stale, not fetching failed, retries exhausted error is on state.error fetching the real loading state no data yet — show a skeleton background refetch data is on screen — do not blank it retrying after failure error still shown meanwhile paused wants to fetch, but the browser reports offline not an error and not a stall — it resumes by itself when connectivity returns Separately, every entry is also either active (has observers) or inactive (has none) — that axis is about mounting, not fetching.
Reading "fetching" as a loading state is the single most common misreading. With data present it means a refetch behind a visible screen.

Step 1 — Mount the Panel Without Shipping It to Everyone

Render the panel as a sibling of your application inside the provider, and let the bundler drop it from production builds.

import { lazy, Suspense } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './query-client';

// Dev-only: the import specifier is statically analysable, so the production
// build never includes the module at all.
const Devtools = import.meta.env.DEV
  ? lazy(() =>
      import('@tanstack/react-query-devtools').then((mod) => ({ default: mod.ReactQueryDevtools })),
    )
  : () => null;

export function AppRoot({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <Suspense fallback={null}>
        <Devtools initialIsOpen={false} buttonPosition="bottom-right" />
      </Suspense>
    </QueryClientProvider>
  );
}

Cache Behavior Analysis: The panel subscribes to the QueryCache event stream to render its list, which means every open panel adds observers — visible in getObserversCount() and enough to keep an otherwise-inactive entry from being garbage collected. That is harmless in development and is exactly why the leak-hunting procedure in Detecting Cache Memory Leaks in React Query tells you to close it first. Rendering the panel inside the provider rather than outside is required: it reads the client from context, and mounted outside it silently renders an empty list.


Step 2 — Read Status and fetchStatus as Two Axes

status describes the data. fetchStatus describes the network. Collapsing them into one mental “loading” flag is what produces most misdiagnoses.

function TodoList() {
  const { data, status, fetchStatus, isPlaceholderData } = useQuery({
    queryKey: ['todos', 'list', {}],
    queryFn: fetchTodos,
    staleTime: 30_000,
  });

  // status === 'pending' means there is NO data yet — the only true skeleton case.
  if (status === 'pending' && fetchStatus === 'fetching') return <Skeleton />;

  // Offline with nothing cached: a distinct state that deserves distinct copy.
  if (status === 'pending' && fetchStatus === 'paused') return <OfflineNotice />;

  if (status === 'error') return <ErrorPanel />;

  return (
    <>
      {/* status success + fetchStatus fetching is a BACKGROUND refetch.
          Show a subtle indicator; never replace the list with a spinner. */}
      {fetchStatus === 'fetching' && <RefreshIndicator />}
      <List items={data} dimmed={isPlaceholderData} />
    </>
  );
}

Cache Behavior Analysis: In v5 the two axes are fully independent, which is what makes stale-while-revalidate expressible: an entry can be success with data on screen and simultaneously fetching in the background, and the correct UI shows the old data with a refresh hint rather than a skeleton. paused is a third fetchStatus value that the online manager sets when the browser reports no connectivity — it is neither an error nor a stall, and the query resumes on its own when connectivity returns. isPlaceholderData is separate again: it marks data carried over from a previous key, which is why a keystroke-driven search can keep the previous term’s results visible while the new ones load.

What the panel is telling you, and whether to act Matrix of five DevTools badge combinations with their meaning and whether they indicate a problem. What the panel is telling you, and whether to act Meaning Action fresh, idle, active Serving from cache inside staleTime None — this is the goal stale, fetching, active Background refetch behind visible data None — keep the data on screen fresh, idle, inactive No observer; waiting out gcTime None unless it outlives gcTime stale, idle, active for minutes Something is stale and nothing is refetching it Check refetch triggers and enabled Dozens of near-identical keys The key is being rebuilt per render Normalize the key in its factory
Only two of these five are ever a bug, and neither of them is the one that looks most alarming.

Step 3 — Reproduce States Deliberately

The panel’s per-query actions let you force each state instead of waiting for one. That turns “I cannot reproduce it” into a ten-second check.

  1. Invalidate marks the entry stale and refetches it if it has observers — use it to confirm an invalidation actually reaches the keys you think it does.
  2. Reset returns the query to its initial state, which reproduces a first-load skeleton without a page reload.
  3. Remove deletes the entry, reproducing a hard cache miss on a screen the user has already visited.
  4. Trigger loading and trigger error force those states so you can check the corresponding UI without touching the network.
  5. Set to offline flips the online manager, producing paused on demand.

Cache Behavior Analysis: The offline toggle drives the same onlineManager the library uses for refetchOnReconnect, so it exercises the real code path rather than a simulation — a mutation dispatched while it is off will genuinely enter the paused mutation queue and replay when you switch it back, which is the fastest way to test the behaviour described in Syncing Offline Mutations on Reconnect. “Remove” differs from “Reset” in a way worth internalising: remove deletes the entry so the next observer starts from nothing, while reset keeps the entry and returns it to pending, which is what a fresh mount would see.


Step 4 — Gate a Production Panel Behind an Explicit Opt-In

Support engineers frequently need the panel on a real user’s session. The production build exists for exactly this, and must never be mounted by default.

import { lazy, Suspense, useEffect, useState } from 'react';

const ProdDevtools = lazy(() =>
  // The production entry point ships without development-only instrumentation.
  import('@tanstack/react-query-devtools/production').then((mod) => ({
    default: mod.ReactQueryDevtools,
  })),
);

export function OptionalDevtools() {
  const [enabled, setEnabled] = useState(() => sessionStorage.getItem('rq-devtools') === '1');

  useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      // Ctrl+Shift+Q: deliberate, undiscoverable by accident, no UI surface.
      if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'q') {
        sessionStorage.setItem('rq-devtools', '1');
        setEnabled(true);
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, []);

  if (!enabled) return null;
  return (
    <Suspense fallback={null}>
      <ProdDevtools initialIsOpen />
    </Suspense>
  );
}

Cache Behavior Analysis: Because the import is dynamic and only reached after the key chord, the panel’s JavaScript is a separate chunk that no ordinary user downloads — the cost to everyone else is the few bytes of this component. Storing the flag in sessionStorage rather than localStorage means it does not persist across sessions, which keeps a support session from permanently changing a user’s bundle. The panel exposes cached data, so treat enabling it as a privileged action: on an application with sensitive data, gate it on an internal-user claim rather than a key chord alone.

Bundle cost of each DevTools mounting strategy Bar chart of gzipped bytes. Statically importing the development panel adds 47 kilobytes. A dynamic import gated on a flag adds under 1. A dev-only conditional import adds 0 in production. Bundle cost of each DevTools mounting strategy Gzipped JavaScript downloaded by a user who never opens the panel static import, dev entry 47 KB dynamic import, opt-in gate 0.6 KB dev-only conditional import 0 KB
Only a static import in the production bundle costs anything, and it is the one that also ships development-only instrumentation.

Edge Cases & Gotchas

Strict mode doubles what you see. React’s development strict mode mounts effects twice, so observer counts and fetch entries can transiently appear doubled. Confirm anything surprising in a production build before investigating.

The panel keeps entries alive. Every query the panel lists has an observer from the panel itself, so gcTime never elapses while it is open. Close it before taking cache-size measurements.

Expanding a huge entry is not free. The data viewer serializes what it renders. Expanding a multi-megabyte infinite-query entry blocks the main thread for as long as that takes — collapse it before profiling anything else.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
The panel renders but lists nothing It is mounted outside QueryClientProvider Move it inside the provider, as a sibling of the app
Entries never get collected while debugging The open panel holds an observer on every query Close the panel before measuring cache size
“Fetching” is treated as a bug fetchStatus was read as a loading state Branch on status === 'pending' for skeletons, fetchStatus for refresh hints
The panel appears in the production bundle A static import was left in a shared module Use a dev-only conditional or a dynamic import
Observer counts look doubled React strict mode double-invokes effects Verify in a production build

Frequently Asked Questions

What does an inactive query mean — is it a leak?

Inactive means the entry currently has no observers: no mounted component is reading it. That is the expected state for every screen the user has navigated away from, and the entry will be removed once gcTime elapses. It becomes a signal worth investigating only when an entry stays inactive for substantially longer than its gcTime, which means the timer was never armed — usually because something else is still subscribed. The DevTools panel itself is a common culprit, which is why the leak-hunting procedure starts by closing it.

Why is a query showing as paused?

Because it wants to fetch and the online manager reports that the browser has no connectivity. paused is a fetchStatus, sitting alongside idle and fetching, and it is neither an error nor a hang: the query is queued and will run automatically when connectivity returns. It is worth handling explicitly in the UI when combined with status === 'pending', because that is the case where the user has no data at all and a generic spinner would be misleading — an offline notice is more honest and stops people reloading the page.

Can I trust the panel's data view for large responses?

The values are accurate — it reads directly from query.state.data — but rendering them is not free. The viewer serializes and renders whatever node you expand, so opening a multi-megabyte infinite-query entry will block the main thread for the duration and skew any measurement you take while it is open. Keep large entries collapsed, and when you need to look at a big payload, read it from the console with queryClient.getQueryData(key) instead, where you control how much of it you materialise.