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-devtoolsv5 installed as a dev dependency.- A
QueryClientProvideryou can render a child inside. - A build setup that can code-split a dynamic import.
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.
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.
- 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.
- Reset returns the query to its initial state, which reproduces a first-load skeleton without a page reload.
- Remove deletes the entry, reproducing a hard cache miss on a screen the user has already visited.
- Trigger loading and trigger error force those states so you can check the corresponding UI without touching the network.
- Set to offline flips the online manager, producing
pausedon 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.
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.
Related
- Cache Observability & Debugging — the parent guide on making cache decisions visible.
- Logging Query Lifecycle Events for Debugging — the sibling technique for when the panel shows what happened but not why.
- Measuring Cache Hit Rate in Production — turning what the panel shows into a number you can alert on.
- Detecting Cache Memory Leaks in React Query — where the panel’s own observers matter to the measurement.