Skip to main content

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.

important

Don't copy this UI verbatim. The recipe teaches the state structure: the shell renders on the first frame, exactly one region of the table body branches, the skeleton derives from the real columns, and a refetch keeps the loaded rows. The domains table, its copy, and the toolbar controls are stand-ins. Swap them for your product's content and keep the structure.

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: auto sizes 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-fixed makes 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. Skeleton renders aria-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 a LiveRegion mounted from the first frame announces each state.

What this covers

StateWhat renders
Cold loadThe header, the filters, the column headers, eight skeleton rows, and a skeleton count.
LoadedEight rows and Showing 8 of 1,284 domains.
Filter changeThe loaded rows stay on screen, dimmed and aria-busy, until the filtered page lands.
No resultsAn Empty inside the table frame with a Clear filters action.
No dataAn Empty inside the table frame with the create action.
Request failsAn Empty inside the table frame with a Retry action. The header and filters stay in place.

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

DomainsListPage                              renders on the first frame├── LiveRegion                               mounted once; its text changes per state├── Heading, description, primary action     static├── Filters: Select, Select, Input           static; fixed widths└── DomainsTable    ├── DomainCount                          fixed height; skeleton | text    └── DataTable.Root                       [&_table]:table-fixed        ├── DataTable.Head                   built from the column list; never waits        └── DataTable.Body                   pending | rows | no-results | no-data | error   ← the only region that branches

Implementation

The rules below keep the code consistent with the intent above.

  1. Render everything that does not read the query outside the branch. The heading, description, primary action, filters, and DataTable.Head take no data. In the demo, only DomainCount and DomainsTableBody read state. Give each Select.Value its 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.
  2. Derive one body state, and check pending first. resolveListBodyState returns exactly one of pending, rows, no-results, no-data, and error. Pending comes first, so an empty state can't render before the first response. A switch on the state renders exactly one region.
  3. Build the skeleton from the real table. SkeletonRows takes table.getAllLeafColumns() and renders one DataTable.Cell per 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.
  4. 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, pass manualPagination: true to the skeleton's table, or the row model caps the placeholder at the default page size.
  5. Size each bar to its column. A Record keyed 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.
  6. Pin the column widths. Set a width class on every DataTable.Header but one, and [&_table]:table-fixed on DataTable.Root. The header widths hold from skeleton to rows on their own, as long as no cell is wider than its column. table-fixed covers the case where one is: under auto layout that column grows and every header after it moves, and truncate only clips text under fixed layout. The unsized column absorbs the remaining width.
  7. Mount the count line in every state. DomainCount is 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 shows 0 before the first response, because a count that appears with the data pushes the table down.
  8. Keep the loaded rows through a refetch. placeholderData: keepPreviousData keeps the previous page while a new key loads. The wrapper sets aria-busy and dims, and isRefetching (fetching with data present) drives both. If your list filters on the client instead, run the filter in the query's select and pass the filter value through useDeferredValue. The query never re-enters pending.
  9. Announce every state once. One LiveRegion renders from the first frame, empty, and useIsHydratedAfterPaint publishes 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 are aria-hidden, so the region is the loading signal.
  10. Keep errors inside the frame. An error renders an Empty in DataTable.EmptyRow with a Retry button that calls query.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.
  11. Cache the page for the return trip. A staleTime long 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.

Don'tDo
Return a full-page spinner while an existence check resolves. The heading and toolbar wait on a request they don't need.Render the heading, description, and toolbar, and gate only the region below them. If a zero state replaces the whole page, gate below the heading they share.
Render three skeleton rows for a twenty-row page.Render one skeleton row per row of the page size.
Mount pagination only with the loaded table.Render the pagination controls in every state and disable them while pending.
Show 0 results while the first page loads.Show a skeleton, or nothing, until the first response.
Build the skeleton from a second column list with hard-coded flags.Pass the real table's leaf columns to the skeleton.
Leave the default table-auto layout with no header widths, so the columns move when text arrives or a long value lands.Set a width on every header but one, and table-fixed so a long value truncates instead of widening its column.
Replace loaded rows with skeletons on a filter change.Keep the rows with keepPreviousData, or filter on the client in select.
Say nothing to screen readers while the rows load.Mount a LiveRegion on the first frame and change its text per state.

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.