I clicked a tab and saw its API request appear before the selected tab changed. It was easy to blame the request, but synchronous React render work was delaying the browser's next paint.
The page still worked: the right panel opened, the URL stayed in sync, and the data loaded. The pause was still long enough to make the click feel uncertain.
The fix was to stop preparing inactive panels, then let the tab confirm the click before React rendered its expensive content.
A reduced test isolates the blocking render
The test below applies the same minimum amount of synchronous work to each panel render. Switch tabs several times in Coupled, then repeat in Responsive. Compare when the selected tab is ready to paint with when its content finishes.
Interactive latency test
One workload, two update orders
Switch tabs several times, then change the render strategy without changing the work.
Current load
84 kWAcross 12 active linesSwitch tabs to record a timing.
In Coupled, the selected state and panel share one render. In Responsive, React can commit the selected state first and prepare the panel afterwards.
The reported times depend on the browser, device, and current workload. The tab timing estimates when the committed update reaches the next animation frame, not the exact paint time. The useful result is the gap between the two update orders on the same device, not one exact number.
The test uses synchronous work on purpose. A timeout would simulate waiting, but waiting for a timer does not stop the browser from painting. The problem only appears when JavaScript keeps the main thread busy.
The workload is a minimum per render. React may restart background work, so the total content time can exceed the selected value.
The request was not blocking the paint
Network requests are asynchronous. Starting one does not, by itself, prevent the browser from updating a tab style.
The tab wrapper also kept its value in the URL. A click changed a search parameter, which caused the page to render again. That page contained charts, tables, exports, and derived data for large tab panels. Some of that work ran in the parent component, including work for panels that were not active.
The sequence looked like this:
click
update the URL
start or refresh a request
render the page
shape chart and table data
commit the selected tab
paintThe request was easy to see in the network panel. The synchronous render work was less obvious. React still had to finish that urgent update before the browser could paint the selected tab.
This distinction matters. Optimizing the endpoint would not fix wasted work in the parent render. A loading spinner would not help either because the browser could not paint it until the same work had finished.
A tab component cannot choose page-level priorities
The tabs came from a small wrapper around an accessible component primitive. It handled tab state, markup, keyboard behavior, styles, and URL synchronization.
That covered the component contract. The page still had to decide which updates were urgent, when the URL should change, and where to compute expensive panel data.
Hidden panels can still create work
The first page structure made every tab part of one large render:
function DevicePage({ data }) {
const summary = buildSummary(data)
const chartPoints = buildChartPoints(data)
const columns = buildTableColumns(data)
return (
<Tabs value={tabFromUrl} onValueChange={writeTabToUrl}>
<TabPanel value="summary">
<Summary data={summary} />
</TabPanel>
<TabPanel value="details">
<Chart points={chartPoints} />
<Table columns={columns} />
</TabPanel>
</Tabs>
)
}The tab primitive could avoid mounting an inactive panel. It could not undo
the work that the parent had already done. Calls such as buildChartPoints
ran before React passed the children to the tab component.
I removed wasted work before adding a concurrency API:
function DevicePage({ data }) {
return (
<Tabs value={tabFromUrl} onValueChange={writeTabToUrl}>
<TabPanel value="summary">
<SummaryPanel data={data} />
</TabPanel>
<TabPanel value="details">
<DetailsPanel data={data} />
</TabPanel>
</Tabs>
)
}
function DetailsPanel({ data }) {
const chartPoints = buildChartPoints(data)
const columns = buildTableColumns(data)
return (
<>
<Chart points={chartPoints} />
<Table columns={columns} />
</>
)
}Now the page does not shape detail data until React renders DetailsPanel.
Extracting the panel also gives the work a clear boundary for profiling and
memoization.
Do this before adding useDeferredValue: deferring inactive-panel work changes
its priority but does not remove its cost.
Give tab selection and panel rendering different priorities
Removing wasted work made the page cheaper to render, but a valid panel could still be expensive. The selected tab and its content needed different priorities.
The selected value became local, immediate state. URL synchronization became a transition:
import { startTransition, useState } from "react"
const [selectedTab, setSelectedTab] = useState(tabFromUrl)
function selectTab(nextTab) {
setSelectedTab(nextTab)
startTransition(() => {
writeTabToUrl(nextTab)
})
}This only helps if writeTabToUrl schedules the router's React update.
startTransition calls its function at once; it does not delay a direct
history.pushState call. Some routers already run navigation as a transition,
so check the router before wrapping it again.
This split also creates two state sources. The wrapper must reconcile local selection after back and forward navigation, preserve other search parameters, and normalize invalid tab values. Use it only when profiling shows that URL-driven rendering delays feedback. Here, visual feedback was urgent; navigation state was not.
There was one more issue. Updating selectedTab could still mount an expensive
panel in the same urgent render. Putting only the URL update in
startTransition did not make that panel cheap.
The panel body followed a deferred value instead:
const deferredTab = useDeferredValue(selectedTab)
<Tabs value={selectedTab} onValueChange={selectTab}>
<TabList>
<Tab value="summary">Summary</Tab>
<Tab value="details">Details</Tab>
</TabList>
<TabPanel value="summary">
{deferredTab === "summary" ? (
<SummaryPanel data={data} />
) : (
<PanelPending />
)}
</TabPanel>
<TabPanel value="details">
{deferredTab === "details" ? (
<DetailsPanel data={data} />
) : (
<PanelPending />
)}
</TabPanel>
</Tabs>On a click, React can now commit the selected tab with a cheap pending panel.
It then renders the expensive body from deferredTab at a lower priority.
This example assumes the panel data remains valid across tabs. If a tab needs
different data, key that read to deferredTab or include the request state in
the pending condition. Otherwise the new panel can render data from the old
URL.
The pending panel is part of the fix. Showing old content under a newly selected tab can be confusing. Mark the panel as busy, name the content it is preparing, and announce that status outside the busy region so assistive technology does not have to infer it.
A transition changes priority, not cost
startTransition and useDeferredValue do not make calculations faster. They
let React put urgent feedback before work that can wait.
They also cannot split one long JavaScript function in half. React can pause between parts of the component tree, but once a component starts one large synchronous calculation, that function runs until it returns. This is another reason to split large panels and keep their work focused.
For a small panel, none of this is needed. Local state and a direct render are clearer. Concurrency tools become useful after profiling shows that valid work still delays an urgent interaction.
Test useDeferredValue in a reduced example
This smaller version contains no router, request, chart library, or tab
package. The blocking loop simulates data shaping and a large panel render.
Change BLOCK_MS, switch modes, or remove useDeferredValue to compare when
the selected state updates.
A review checklist for responsive React tabs
I turned the fix into a short review checklist for future tabbed pages:
- Keep the click path limited to selected state and immediate feedback.
- Do not compute data for inactive panels in the shared parent.
- Extract large panels so React can skip and profile them as units.
- Keep URL synchronization non-urgent when it does not need to block feedback.
- Use
useDeferredValuewhen a valid panel render still delays selection. - Mark pending panels as busy and explain what they are preparing.
- Normalize invalid URL tab values without making that update urgent.
- Profile again when a tab freezes hover, scrolling, or focus feedback.
The list is not a required template for every set of tabs. It records the places where a small composition choice can add work to an urgent update.
Profile the interaction, not just the request
The page met its functional requirements, but its click feedback still arrived late. Diagnosing that gap meant looking past the visible request and following the full path through render, commit, and paint.
When a tab feels tied to a request, profile the click path first. Remove work
that should not run. If valid panel work still delays selection, move it out of
the urgent update and test selection, focus, scrolling, and hover feedback
again. React's useDeferredValue reference
documents the scheduling details and its limits.