List Page Loading States
A list page has one job before its data arrives: look like itself. The heading, the description, the primary action, the filters, and the column headers depend on nothing the server sends, so they can paint on the first frame. Only the count and the rows wait. This recipe wires a Data Table, Skeleton rows, Empty states, and a Live Region into a page that paints fast, fills in without moving, and tells screen readers what it is doing.
The failure modes it removes are ones you have seen: a spinner where the page should be, a "No results" that flashes before the real rows, columns that slide sideways when text arrives, a pagination bar that pushes the page down once it mounts, and a count that reads 0 for a second.
Pick a scenario in the toolbar, then click Replay load to watch it from a cold start. After the rows land, type in the search box: the rows dim while the next page loads, and the skeleton never comes back.
Why the shell first
- The first frame is the layout. Everything above the rows is static markup. Paint it before the request resolves, and the reader gets the page's shape at once. The browser gets its largest paint without a round trip to the server.
- A skeleton that matches the rows moves nothing. Cumulative Layout Shift (CLS) counts every element that moves after it paints. Skeleton rows with the same cell padding, the same row count, and bars near the real content's width mean the swap to real rows changes pixels, not positions.
- Column widths come from the headers, not the data. The default
table-layout: autosizes every column by its widest cell. With no header widths, the columns slide sideways the moment real text arrives, and a long value on page two widens its column, moves every header after it, and pushes the table into a horizontal scroll. A width on each header holds the columns through the skeleton swap.table-fixedmakes those widths final, so a long value truncates instead of moving the table. - A refetch is not a cold load. The reader already has rows on screen. Replacing them with skeletons throws away a working page for the length of a request. Keep them, dim them, and swap when the next page lands.
- Screen readers can't see a skeleton.
Skeletonrendersaria-hidden, which is right: the bars carry no information. The page still has to say that it is loading, and later how many rows it shows, so aLiveRegionmounted from the first frame announces each state.
What this covers
The demo waits 1.5 seconds per request so every state is visible. The route is also available as plain markdown at /recipes/list-page-loading-states.md, so humans and LLM tools can fetch the same recipe without scraping the rendered page.
Anatomy
Implementation
The rules below keep the code consistent with the intent above.
- Render everything that does not read the query outside the branch. The heading, description, primary action, filters, and
DataTable.Headtake no data. In the demo, onlyDomainCountandDomainsTableBodyreadstate. Give eachSelect.Valueits text as children: Radix fills an empty one from the mounted items, which the server never renders, so the trigger is blank until hydration. Wrap the part of that text that changes in a<span>. A filter that starts on a plain string and resolves to an element flips a child's type beside a sibling that stays put, which crashes a page a browser translation engine has translated — see Browser translation. - Derive one body state, and check pending first.
resolveListBodyStatereturns exactly one ofpending,rows,no-results,no-data, anderror. Pending comes first, so an empty state can't render before the first response. Aswitchon the state renders exactly one region. - Build the skeleton from the real table.
SkeletonRowstakestable.getAllLeafColumns()and renders oneDataTable.Cellper column, so the skeleton can't have more or fewer columns than the loaded rows. A second column list, or a copy of the real one with different flags, drifts. - Match the row count to the page size. Eight skeleton rows for a page of eight. Three skeleton rows for a page of twenty guarantee seventeen rows of shift. If the table registers
rowPaginationFeature, passmanualPagination: trueto the skeleton's table, or the row model caps the placeholder at the default page size. - Size each bar to its column. A
Recordkeyed by column id holds one class per column: a wide bar for the domain name, a pill for the badge, a right-aligned bar for the number. Bars that match the content's shape keep the row height still. - Pin the column widths. Set a width class on every
DataTable.Headerbut one, and[&_table]:table-fixedonDataTable.Root. The header widths hold from skeleton to rows on their own, as long as no cell is wider than its column.table-fixedcovers the case where one is: under auto layout that column grows and every header after it moves, andtruncateonly clips text under fixed layout. The unsized column absorbs the remaining width. - Mount the count line in every state.
DomainCountis a fixed-height<p>aligned to the right edge, so a wider number moves nothing beside it. It shows a skeleton while pending and the count when loaded. It never shows0before the first response, because a count that appears with the data pushes the table down. - Keep the loaded rows through a refetch.
placeholderData: keepPreviousDatakeeps the previous page while a new key loads. The wrapper setsaria-busyand dims, andisRefetching(fetching with data present) drives both. If your list filters on the client instead, run the filter in the query'sselectand pass the filter value throughuseDeferredValue. The query never re-enters pending. - Announce every state once. One
LiveRegionrenders from the first frame, empty, anduseIsHydratedAfterPaintpublishes the message one frame after mount:Loading domains,Updating domains,Showing 8 of 1,284 domains, and the empty and error messages. A live region announces changes, not the text it mounts with, so an empty mount is what makes the first message audible. Skeleton rows arearia-hidden, so the region is the loading signal. - Keep errors inside the frame. An error renders an
EmptyinDataTable.EmptyRowwith a Retry button that callsquery.refetch(). The frame, the header, and the filters stay where they are. Put the retry policy in the hook, as the Overlays + Async Data recipe does. - Cache the page for the return trip. A
staleTimelong enough to cover a detail-and-back navigation renders the list from cache, so the reader sees the skeleton once per session instead of once per visit.
Anti-patterns
The left column is what not to do. The right column is the fix.
Other data layers
The rules don't depend on TanStack Query. With React Router, a server loader blocks the document until the list resolves, so the first paint has rows and no skeleton, at the cost of a slower document. To paint the shell first, keep the list out of the loader and fetch it from the component, or return the promise from the loader and render the skeleton rows as the Suspense fallback around Await. HydrateFallback applies only when a clientLoader runs during hydration; if you have one, make it the same shell with skeleton rows, never a spinner. For later navigations, read useNavigation().state. Never gate an <Outlet /> on a pending request: every child route waits behind it. For a page-level loading indicator, a fixed, zero-height progress bar at the top of the viewport costs no layout at all.