Prefetching Queries on Hover and Route Change
The two moments where a client can most reliably predict its next fetch are a pointer hovering a link and a router beginning to resolve a route. Wiring prefetchQuery into the first and ensureQueryData into the second turns those signals into a warmed cache, so the destination component renders without a spinner. This recipe is a concrete application of Prefetching & Cache Warming, focused narrowly on the intent handlers and route loaders that trigger the warm-up. If your concern is keeping already-warmed data fresh rather than fetching it early, the timing knobs in Optimizing SWR Revalidation Intervals govern the other half of the lifecycle.
The failure mode this page eliminates is subtle: a prefetch that runs but does not count. Hover a link with a staleTime: 0 prefetch and the destination useQuery will discard the warmed entry and refetch on mount — you paid for the round-trip and still see the spinner. Get the staleTime right and repeat hovers dedupe to zero requests while the click lands on fresh data.
Diagnostic Checklist
Confirm your symptoms match this failure mode before wiring handlers:
- The Network tab shows a request firing on hover and a second identical request when the page opens.
- Sweeping the pointer down a long list fires a burst of prefetch requests that delay the one the user actually clicks.
- A route feels instant on desktop but spins on mobile because the loader
returns without awaiting anything. - Navigating away mid-load leaves a request in flight that writes data into a cache nobody will read.
- React Query DevTools shows the hovered entry flip straight to
stale, so the consumer refetches on mount every time.
Step-by-Step Implementation
Step 1 — Attach intent handlers to the link
Share a single query-options factory between the link and the destination so their queryKey and staleTime cannot drift. Fire the prefetch on both onMouseEnter (pointer users) and onFocus (keyboard and assistive-tech users).
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
interface Product { id: string; name: string; price: number }
export const productQuery = (id: string) => ({
queryKey: ['product', id] as const,
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<Product> => {
const res = await fetch(`/api/products/${id}`, { signal });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<Product>;
},
staleTime: 60_000,
});
export function ProductLink({ id, name }: { id: string; name: string }) {
const queryClient = useQueryClient();
const warm = () => void queryClient.prefetchQuery(productQuery(id));
return (
<Link to={`/products/${id}`} onMouseEnter={warm} onFocus={warm}>
{name}
</Link>
);
}
Cache Behavior Analysis. prefetchQuery reads the cache before it fetches: if ['product', id] is already fresh, it returns without a request, so the handler is safe to call on every pointer event. Passing the same staleTime: 60_000 the destination uses is what makes the warmed entry survive the mount-time freshness check instead of being discarded.
Step 2 — Dedupe repeat hovers with staleTime
The single most common hover-prefetch bug is a request storm from sweeping the pointer across a list. The fix is not a debounce — it is staleTime. Because prefetchQuery short-circuits on a fresh entry, a correctly-set staleTime collapses N hovers into one request automatically. A small debounce is a belt-and-suspenders addition for very dense lists.
import { useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react';
import { productQuery } from './ProductLink';
export function useDebouncedPrefetch(delay = 80) {
const queryClient = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout>>();
return (id: string) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
// prefetchQuery still short-circuits if the entry is fresh.
void queryClient.prefetchQuery(productQuery(id));
}, delay);
};
}
Cache Behavior Analysis. With staleTime: 60_000, the first hover writes a fresh entry and the next fifty-nine seconds of hovers over the same link find that entry and issue no request — the dedup is a property of the freshness window, not of the debounce. The debounce only trims the transient hovers that occur while the pointer is travelling across intermediate rows toward its target, before any of them become the intended destination.
Step 3 — Prefetch on route change with ensureQueryData
A route loader knows exactly what the next screen needs. Use ensureQueryData — not prefetchQuery — because a loader must reject on failure so the router’s error boundary can catch it, and because ensureQueryData returns the value. Awaiting it makes navigation land on a spinner-free page; this is also the client-side counterpart to server prefetch during SSR hydration boundaries, where the same entry is warmed on the server and rehydrated on the client.
import { QueryClient, useQuery } from '@tanstack/react-query';
import { LoaderFunctionArgs, useParams } from 'react-router-dom';
import { productQuery } from './ProductLink';
export const productLoader =
(queryClient: QueryClient) =>
async ({ params, request }: LoaderFunctionArgs) => {
const id = params.id as string;
// Forward the navigation's signal so a superseded route aborts the fetch.
await queryClient.ensureQueryData({
...productQuery(id),
queryFn: ({ signal }) =>
productQuery(id).queryFn({ signal: request.signal ?? signal }),
});
return null;
};
export function ProductPage() {
const { id } = useParams();
const { data } = useQuery(productQuery(id!));
return <h2>{data?.name}</h2>;
}
Cache Behavior Analysis. ensureQueryData resolves instantly with the cached value when the entry is fresh — so a route entered right after a Step 1 hover costs zero requests — and otherwise awaits fetchQuery and writes the result. Because the loader awaits it, the router holds the transition until the entry exists, and the mounted useQuery reads it synchronously under the shared staleTime with no second request.
Step 4 — Cancel wasted prefetches on navigation
A loader prefetch that is still in flight when the user navigates elsewhere is wasted bandwidth. React Router passes an AbortSignal on request.signal that fires when a navigation is superseded; forward it into fetch (as in Step 3) so the stale request aborts. For imperative cancellation of a specific key, use queryClient.cancelQueries.
import { useQueryClient } from '@tanstack/react-query';
export function useCancelProductPrefetch() {
const queryClient = useQueryClient();
return (id: string) =>
// Aborts an in-flight fetch for this key without evicting cached data.
queryClient.cancelQueries({ queryKey: ['product', id] });
}
Cache Behavior Analysis. cancelQueries aborts the in-flight promise for the matched queryKey but leaves any previously cached data intact — it does not evict or mark the entry stale. Forwarding request.signal achieves the same for loader-driven prefetches automatically, so a rapid A → B → C navigation aborts A’s and B’s fetches and only C’s warmed data is written.
Edge Cases and Gotchas
The queryKey must match exactly
A prefetch under ['product', id] warms a consumer that reads ['product', id] — and nothing else. If the destination uses ['products', 'detail', id], you get two independent cache entries and a guaranteed second fetch on mount. Sharing one productQuery(id) factory across the link, the loader, and the page is the only reliable defense.
Do not cancel hover prefetches on mouse-leave
It is tempting to abort a hover prefetch when the pointer leaves, but the request is usually already in flight and its response populates the cache for near-zero cost. Aborting it throws away a round-trip you have already paid for; if the user re-hovers, you refetch from scratch. Reserve cancellation for superseded navigations, not abandoned hovers.
staleTime of zero silently defeats everything
If either the prefetch or the consumer omits staleTime, the warmed entry is stale on arrival and useQuery refetches on mount. The prefetch still shows in the Network tab, which makes this bug look like it is “working” — the tell is the duplicate request when the page opens.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A request fires on hover and again on mount | staleTime missing or mismatched between the prefetch and the destination useQuery |
Share one query-options factory so both sides pass identical queryKey and a non-zero staleTime; verify the entry stays fresh in DevTools after hover |
| Sweeping a list fires a burst of prefetch requests | Every onMouseEnter fetches with staleTime: 0, so none dedupe |
Set a staleTime so repeat hovers hit the fresh-entry short-circuit; add an ~80ms debounce for very dense lists |
| Route spins on slow connections despite a loader | The loader returns without awaiting ensureQueryData, so nothing is warmed before mount |
await the ensureQueryData call so the router commits only once data is cached, or intentionally skip the await and render a skeleton |
| Superseded navigation keeps loading in the background | The loader’s fetch ignores request.signal, so an abandoned route’s request runs to completion |
Forward request.signal into fetch; for imperative cases call queryClient.cancelQueries({ queryKey }) |
Frequently Asked Questions
Will prefetchQuery refetch if I hover the same link ten times in a row?
No, as long as the prefetch specifies a staleTime. prefetchQuery inspects the cache first and short-circuits when it finds an entry still inside its staleTime window, so the second through tenth hovers issue no request. Without a staleTime the entry is stale immediately and every hover fires a new fetch, which is the usual cause of hover-induced request storms. A shared query-options factory keeps that staleTime consistent across the link and the destination.
Should I cancel a prefetch when the user moves the pointer away?
Usually not for hover — the request is already in flight and the response will populate the cache cheaply, so aborting it just throws away a round-trip you have paid for. Cancellation matters when you navigate away from a route whose loader is still prefetching: forward the loader’s request.signal into the fetch so a superseded navigation aborts the in-flight request instead of wasting bandwidth and writing data no one will read. For imperative control, queryClient.cancelQueries({ queryKey }) aborts a specific key without evicting its cached data.
Does ensureQueryData block navigation until the fetch resolves?
Only if you await it in the loader. Awaited, ensureQueryData resolves with cached data instantly when the entry is fresh, or waits for the fetch when it is not, so the route commits with data ready and the destination renders without a spinner. If you do not await it, navigation proceeds immediately and the component renders a skeleton while the warmed fetch completes in the background — a useful trade when the endpoint is slow and you would rather not stall the transition.
Related
- Prefetching & Cache Warming — the parent reference covering all three warming primitives (
prefetchQuery,ensureQueryData,setQueryData) and howstaleTime,initialData, andplaceholderDatagovern whether a warm-up pays off. - Optimizing SWR Revalidation Intervals — the complementary half of the lifecycle: tuning how often a warmed entry revalidates once it has been read.
- Cache Invalidation & Server Synchronization — the top-level guide covering the full freshness and synchronization model that prefetching plugs into.
- Stale-While-Revalidate Implementation — how a warmed entry is served instantly and refreshed in the background on subsequent views, sharing the same
staleTimeyou set on the prefetch.