Add a Data Table
Stand up a feature table with the per-feature folder — orchestrator, columns, filter and pagination.
A list view in the Education Hub is never one big file. It's a small folder of single-purpose files that snap together: an orchestrator that fetches, a column definition, a filter toolbar, and a few cell components. Once you've built one, every other table is the same shape — so this recipe walks you through standing one up, using the inquiry table as the worked example.
The pieces and the wiring are explained in full on Data Tables — read that first if you want the mental model. This page is the checklist.
You need a data feature first
A table renders whatever your query hook returns, so the data layer has to exist before the table can. If your feature doesn't yet have a use* query hook and generated DTOs, build that first with Add a Data Feature, then come back here.
Step 1: Make the folder
Create src/components/data-tables/<feature>/ and give it the four standard members. The inquiry table looks like this:
inquiry/
datatable.tsx # the orchestrator — fetch + assemble
columns.tsx # column definitions
filter.tsx # the search + faceted-filter toolbar
cells/ # one file per non-trivial cellThe shared machinery — useDataTable, DataTableShell, createSelectColumn — lives one level up in data-tables/. Your folder only holds what's specific to this feature.
Step 2: Define the columns
columns.tsx exports a plain array built with TanStack's column helper, typed to your generated DTO. Order matters, and it's always the same three bands:
const columnHelper = createColumnHelper<EnquiryDto>()
const inquiryColumns = [
createSelectColumn<EnquiryDto>(), // 1. checkbox column FIRST
columnHelper.accessor('name', { header: 'Name', /* ... */ }), // 2. accessors
columnHelper.accessor('status', { filterFn: 'equals', /* ... */ }),
columnHelper.display({ id: 'actions', /* ... */ }), // 3. actions LAST
]The select column comes first so every table gets the same row-checkbox, and a display column with id: 'actions' comes last for the row menu. Everything in between is an accessor keyed on a real DTO field.
A cell can be as simple as formatting a date, or it can open a side panel. The name cell does the latter — clicking a row reaches into the row's original data and opens the inquiry detail panel:
cell: (info) => {
const { openPanel } = useSidePanel()
return (
<p onClick={() => openPanel(Panel.InquiryDetail, { size: 'wide' },
{ inquiryId: info.row.original.id, defaultTab: 'message' })}>
{info.getValue() || <span className="text-muted-foreground">~</span>}
</p>
)
}Passing context into cells
Cells can pull from a hook (like useSidePanel above) or read whatever the orchestrator stashed in table.options.meta. The platform tables use the meta route — they pass meta: { onImpersonate } into useDataTable, and the action cell reads table.options.meta.onImpersonate. Use meta when a cell needs a callback that only the orchestrator can build.
Keep anything more than a line of markup in its own file under cells/ — the inquiry table has status.cell.tsx, priority.cell.tsx, assigned-to.cell.tsx, and actions.cell.tsx. The columns file stays scannable.
Step 3: Wire the orchestrator
datatable.tsx is the only stateful piece. It holds the query params, runs the query, and hands the rows to useDataTable. Four moves, in order:
const [params, setParams] = useState<ListEnquiriesQueryParams>({ limit: 100 })
const { inquiries, loadingInquiries, fetchingInquiries } = useInquiries(params)
const table = useDataTable({
data: inquiries?.data ?? [], // never pass undefined
columns: inquiryColumns,
defaultColumnVisibility: { id: false }, // hide the raw id column
})Then render the filter and the shell, passing params/setParams down to the filter and the loading flags to the shell:
<InquiryDataTableFilter table={table} params={params} setParams={setParams} />
<DataTableShell
table={table}
columns={inquiryColumns}
isLoading={loadingInquiries}
isFetching={fetchingInquiries}
emptyTitle="No Inquiries Found"
totalCount={inquiries?.totalCount ?? 0}
recordLabel="inquiries"
/>Always coalesce to an empty array
The ?? [] on data isn't optional. While the query is loading, inquiries is undefined, and useDataTable will crash if it ever receives undefined for its rows. Coalesce every time.
Step 4: Build the filter toolbar
filter.tsx receives table, params, and setParams. It owns the user-facing controls and keeps its own UI state in local useState. Three things you'll wire up almost every time:
A debounced search. Type into a controlled input, but only push the value into params after the user pauses — so you don't fire a request per keystroke. Use @tanstack/pacer's debounce at ~800ms:
const [searchValue, setSearchValue] = useState(params?.search || '')
const debouncedSearch = useMemo(
() => debounce((search: string) =>
setParams((prev) => ({ ...prev, search })), { wait: 800 }),
[setParams],
)Faceted filters. Status and priority are fixed lists, so they live in a dropdown that both sets the TanStack column filter and patches params for the server:
onClick={() => {
table.getColumn('status')?.setFilterValue(status)
setParams((prev) => ({ ...prev, status }))
}}A column-visibility popover. Walk table.getAllColumns(), keep the hideable ones, and toggle each with column.toggleVisibility(...). This is boilerplate — copy it from the inquiry filter rather than rewriting it.
Step 5: Pick a pagination strategy
The last decision is how the table pages. There are two paths, and you choose by how big the list gets.
Fetch a generous page (limit: 100) and let the table page in memory. This is the inquiry table's choice — and the default: DataTableShell renders DataTablePagination for you, so you do nothing extra.
const [params, setParams] = useState<ListEnquiriesQueryParams>({ limit: 100 })
// DataTableShell shows its built-in pager automaticallyReach for this when the full result set comfortably fits in one request.
For lists that can run into the thousands, page on the server. Drive params with the shared useCursorParams hook, tell the shell to skip its own pager with hidePagination, and render CursorPaginationFooter instead:
const { params, onSearch, onNext, onPrev, canPrev } = useCursorParams()
// ...
<DataTableShell table={table} columns={columns} hidePagination /* ... */ />
<CursorPaginationFooter
page={guests}
canPrev={canPrev}
onNext={() => onNext(guests?.nextCursor)}
onPrev={onPrev}
/>useCursorParams tracks a cursor stack so Previous can rewind, and resets to the first page whenever search or a filter changes.
That's the whole recipe
Folder, columns, orchestrator, filter, pagination. Import your <Feature>DataTable into the page that needs it and you're done — the shell handles loading skeletons, empty states, and row selection for you.