Skip to content

Empty, Loading, and Error States

The table has three “no rows” states: it has zero rows to display (empty), it’s loading rows that haven’t arrived yet (loading), or a load failed (error). Each state has its own slot component, and the shadcn wrapper ships sensible defaults for all of them — override a slot when the default copy doesn’t fit your domain.

EmptyPlaceholder renders when the model has zero rows. The component receives the table context prop.

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { localModel } from '@virtuoso.dev/data-table'

const model = localModel<{ name: string }>({ data: [] })

function EmptyState({ context }: { context: { query: string } }) {
  return <div className="p-6 text-sm text-muted-foreground">No products match "{context.query}".</div>
}

export default function App() {
  return (
    <DataTable EmptyPlaceholder={EmptyState} className="rounded-xl" context={{ query: 'archived' }} model={model} style={{ height: 240 }}>
      <DataTableColumn field="name">
        <DataTableColumnHeader>Product</DataTableColumnHeader>
        <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
      </DataTableColumn>
    </DataTable>
  )
}

remoteModel() drives three slot components:

  • LoadingPlaceholderinitial phase (first unresolved dataset)
  • LoadingOverlayrefresh phase (request params changed, old rows still visible)
  • LoadingFooterend phase (append mode is fetching more rows below)

Offset viewport fetches show up as placeholder rows, not a table-level loading slot. DataTableLoadingState exposes a start segment for custom integrations, but the built-in remoteModel() never sets it.

Replace any slot through components:

function LoadingOverlay({ loadingState }: LoadingComponentProps) {
  if (loadingState.refresh.status === 'error') {
    return <div role="status">Refresh failed: {loadingState.refresh.errorMessage}</div>
  }

  return <div role="status">Refreshing rows...</div>
}

;<DataTable components={{ LoadingOverlay }} model={model} />

The shadcn wrapper ships defaults for all three slots. Override them for different copy, skeletons, or error recovery controls.

External UI reads the same state through useVirtuosoLoadingState() (inside the table) or loadingState$ with remote engine hooks (outside).

Errors attach to the phase that failed, so a first-load failure can be handled differently from a refresh failure:

function LoadingPlaceholder({ loadingState }: LoadingComponentProps) {
  if (loadingState.initial.status === 'error') {
    return (
      <div className="p-6 text-sm" role="status">
        Could not load products. Check your connection and try again.
      </div>
    )
  }

  return <div className="p-6 text-sm text-muted-foreground">Loading products...</div>
}

function LoadingFooter({ loadingState }: LoadingComponentProps) {
  if (loadingState.end.status === 'error') {
    return <div role="status">Could not load more rows.</div>
  }

  return loadingState.end.status === 'loading' ? <div>Loading more...</div> : null
}

;<DataTable components={{ LoadingFooter, LoadingPlaceholder }} model={model} />

Retry buttons belong with the slot that’s showing the error: initial failure → retry from the placeholder, refresh failure → retry from the overlay, append failure → retry from the footer.