Prefetching on hover
Switching tabs flickered every time I first loaded the page. The fix was to stop waiting for the click — and start fetching on hover.
Flickering UI
I was working on a feature at work recently where I bumped into a UI issue when switching between two tabs. Each tab displayed a paginated list that fetched its data from an API. The issue that I observed was an occasional UI flicker: our empty state component would flash, give way to our skeleton loaders, and then finally the content would resolve.
Nothing major in terms of impacting the user, but mildly frustrating seeing it appear whilst developing.
After playing around with the issue more, I realised that the problem only really showed up the first time you loaded the page. Once you clicked through the tabs already, subsequent clicks to navigate between tabs had no issues.
This pointed to the issue being the request needing to make that round-trip for the very first time.
After digging around this sort of symptom, I came across a type of solution I found quite interesting — warming the cache.
Warming the cache
I should probably preface this all by saying that the app I was working on utilises TanStack Query. A big benefit of reaching for libraries like this is that you get a client-side cache of your server data for free. Once you've fetched something, any read within that cache window comes back from memory instead of the network, leading to snappier UI feedback when moving back and forth between pages. That's why subsequent clicks had no issues. "Warming the cache", in this context, then means prepping it before the eventual action.
Now, if you think about a UI with tabs, by the time someone clicks a tab, their cursor has been sitting on it for a couple of hundred milliseconds. That action of hovering is crucial here, because we can latch onto this as a trigger point for prefetching and thus warming the cache.
The fix
Because each list was already a TanStack Query infinite query, warming it was mostly a matter of calling the thing I was already calling, earlier:
function useMediaListPrefetch() {
const queryClient = useQueryClient();
return useCallback(
(type: MediaType) => {
void queryClient.prefetchInfiniteQuery(mediaListQueryOptions(type));
},
[queryClient],
);
}
function MediaTabs({
activeType,
onTypeChange,
}: {
activeType: MediaType;
onTypeChange: (type: MediaType) => void;
}) {
const prefetchMediaList = useMediaListPrefetch();
return (
<nav>
{MEDIA_TYPES.map(({ type, label }) => (
<NavItem
key={type}
active={type === activeType}
onClick={() => onTypeChange(type)}
onMouseEnter={() => prefetchMediaList(type)}
onFocus={() => prefetchMediaList(type)}
>
{label}
</NavItem>
))}
</nav>
);
}
That is the whole idea. By the time the click lands, the query is either resolved or already in flight, so the flicker usually doesn't get a chance to appear. It's a race rather than a guarantee — a 150ms hover against a 600ms request still flickers, just for less of it.
The reason for the onFocus alongside onMouseEnter is to support keyboard users tabbing through
the sidebar so that they can get the same head start — the intent signal is not really "hover", it
is "about to". And the prefetch has to reuse the exact query options the component reads, key
included, which is why they live in a shared mediaListQueryOptions factory.
Anyway, this one is a short post. I just thought this was a really interesting solution, and one that had never occurred to me before. Something to note is that this isn't a complete solution: there's no hover on a touchscreen, so it's an enhancement for pointer and keyboard users rather than a fix for everyone.
← Back to writing