Skip to main content

Connect rows, search, selection, loading, and paging to one consistent list surface.

4 min read

DataTable renders a table and its surrounding search, filters, create action, and paging controls. You provide the data and state transitions. The component does not fetch rows, authorize actions, or filter a backend query for you.

tsx
import { DataTable } from '@tale/ui/data-table/data-table';
import type { ColumnDef } from '@tanstack/react-table';

Define the visible columns

Live example
Agents in this workspace
AgentModelStatus
Support triageclaude-sonnet
Ready
Invoice readergpt-4.1-mini
Ready
Release notesllama-3.3-70b
Draft

Each TanStack ColumnDef supplies an accessor or cell renderer and a header. Use a stable domain ID through getRowId when rows can be selected, expanded, reordered, or refreshed. Otherwise row-index identity can attach state to the wrong item after a data change.

Pass a descriptive caption, such as Agents in this workspace. It becomes a screen-reader table caption. It is optional in the TypeScript interface, but a data table still needs an accessible name in the page.

The builders exported from @tale/ui/data-table/column-builders include text, date, creation-time, selection, and action columns. Reuse them for those common shapes; use custom cells when the content requires them.

Wire search to the rows

Live example
Automations
AutomationTrigger
Weekly digestSchedule
Invoice intakeWebhook
Escalate stale ticketsSchedule
Publish release notesManual

Activate Search automations, then type Weekly: only Weekly digest remains. Type a value that matches nothing to see the shared No results found state. Clear the query to restore all four rows. The example filters names in memory; it does not search the trigger column.

search={{ value, onChange, placeholder }} renders and controls the search field. Your callback updates the query and the rows. For a backend search, send the new query to the backend, reset the paging cursor, and pass the resulting rows back to the table. Preserve meaningful query/filter state in the URL when the screen needs shareable results.

emptyState describes an initially empty collection. An active query or filter with zero rows uses the table's shared no-results copy instead. If cursor pages remain, the table treats zero visible matches as still loading rather than declaring the entire source empty.

Choose toolbar controls

PropHost responsibility
searchOwn the query and apply it to the data source.
filters, dateRangeSupply available choices, selected values, and handlers.
onClearFiltersReset the relevant filters consistently.
filtersContentPlace an additional filter-side control inside the toolbar.
addActionSupply a label and a click handler, destination, or create-menu items.
actionMenuSupply bespoke primary-side toolbar content; it takes precedence over addAction.

When an initially empty table has no search/filter toolbar, addAction moves into the empty state. With toolbar controls present, it stays in the header. Pass the permission-dependent disabled state from your service; the table does not decide access.

Loading and errors

Set isLoading while fetching the initial data. approxRowCount helps reserve space: an unknown count gives the default skeleton; a positive estimate gives skeleton rows up to the component's cap; zero allows the supplied initial empty state. Do not pass zero merely because a request has not returned yet.

Pass error and onRetry for a failed query. A load failure should explain recovery rather than masquerade as an empty collection. Keep filter state when retrying so the request still matches what the reader sees.

Pick one paging model

ModelConfiguration
All rows already loadedpagination.clientSide: true; the table slices the in-memory data.
Server pagesSupply pagination callbacks/counts and the one-based currentPage; replace rows after each request.
Cursor loadingSupply infiniteScroll.hasMore, onLoadMore, and loading state; append returned rows in the host.

Cursor loading is automatic by default and also provides a load-more control. Supply entityLabel: { one, other } for count-aware footer text. totalCount is the unfiltered total; displayedCount is useful when one visible row represents multiple entities. Do not report an estimate as an exact total.

Selection, sorting, and row actions

enableRowSelection accepts a boolean or a per-row predicate. Pair controlled rowSelection with onRowSelectionChange, a stable getRowId, and a selection column. A disabled UI row is not a server-side permission boundary.

The sorting configuration enables sorting and carries initialSorting with onSortingChange. Verify whether your host is sorting the complete local set or requesting a sorted backend set; sorting only the currently loaded page is not a global ordering.

onRowClick receives a TanStack Row, so domain data is in row.original. isRowClickable can exclude aggregate or restricted rows. Keep a named keyboard-accessible link or action in the row; a pointer click handler alone is not equivalent to a navigation link. Use onRowMouseEnter for optional route preloading.

enableExpanding and renderExpandedRow reveal inline detail. Keep the expansion control distinct from row navigation and test both with the keyboard. For the surrounding screen, use the list-page pattern; for a short static table without this chrome, use Table from @tale/ui/table.