Stale Data Flash on Tab Switch
Users switched tabs and saw the wrong list for a split second. The data was right eventually—so why did it feel like a bug?
The Problem
On a tabbed list page (e.g. “Active” vs “Archived”), users reported a jarring UX: when they clicked the other tab, the old tab’s data would briefly appear under the new tab’s header before the correct data loaded. For a split second, the wrong list was visible. It looked like a bug even though the right data eventually showed up.
Investigation
I traced the behavior through the component lifecycle. The tab change triggered a route push (e.g. router.push() with a new query or path), and that route change triggered a re-render and a data fetch for the new tab. The problem was the gap between:
- User clicks the other tab.
- Route updates and the component re-renders.
- The fetch for the new tab’s data is called.
- The fetch sets
loadingtotrueand later returns with new data.
During that gap, the component was still showing loading: false and the previous tab’s data. The loading flag was only set to true inside the fetch function, so it turned on only after the request started. Until then, the UI had no signal that a switch was in progress, so it kept rendering the old list under the new tab label.
Root Cause
Two things were missing:
- A loading signal at click time. The data-fetching hook’s
loadingstate reflected “request in flight,” not “user just asked for different data.” So there was a window where the user had already switched tabs but the hook hadn’t yet started the new request. - Stale data still in state. Even if we had shown a spinner, we were still holding the old tab’s list in state. Any momentary glitch or delayed loading state would have allowed that stale list to render again.
So the root cause was a state timing gap: navigation and data-fetching were decoupled (as in Next.js page-based or query-based routing), and we had no UI-level loading signal that fired at the moment of user action.
The Fix
We did two things:
- Page-level “tab just switched” state. As soon as the user clicks a different tab, we set a flag (e.g.
isTabSwitched) totruebefore the route push. The loading state passed to the list container becameisTabSwitched || loading. So the loading skeleton appeared immediately on click. When the new data arrived, we setisTabSwitchedback tofalse. - Flush stale data at the start of the fetch. At the beginning of the fetch inside the data-fetching hook, we cleared the list (e.g.
setAssessmentData([])). That way, even if there was a brief moment where the loading state was still false, there was no old data to show.
So: one signal for “user asked for a change” (immediate), one for “request in flight” (when the fetch runs), and no stale list left in state during the transition.
Lessons Learned
- The
loadingboolean from a data-fetching hook is often not enough for perceived performance. When navigation and fetching are decoupled, you need a loading signal that fires at the moment of user interaction, not when the API call starts. - Clearing or resetting list data at the start of a new fetch is a simple way to avoid showing the wrong data during tab or filter changes.
- Combining “user intent” (tab switched) with “request state” (loading) gives a more predictable and pleasant UX than relying on request state alone.
Have thoughts on this story or questions? Get in touch.