Data Tables
Why there is no monolithic DataTable — and how each feature assembles its own table, filters and pagination.
If you go looking for a single <DataTable> component to drop your data into, you won't find one. That's deliberate. Every list in the Education Hub — inquiries, students, staff, applications — is a little different: different columns, different filters, different pagination. So instead of one all-knowing component bent into a hundred shapes by props, each feature assembles its own table from shared parts.
The shared parts do the boring, repetitive work — the chrome, the row models, the loading skeleton. The feature-owned parts describe what's actually unique: which columns, which filters, which cells. Once you've seen one feature's folder, you've seen them all.
The four-part folder
Every table lives in src/components/data-tables/{feature}/ and is built from four files. Using inquiry as our example:
datatable.tsx— the orchestrator. Holds query params, fetches, builds the table, lays out the filter and the shell.columns.tsx— the column definitions: what each column is and how its cell renders.filter.tsx— the toolbar above the table: search, faceted filters, column visibility.cells/— custom cell renderers likeactions.cell.tsxandstatus.cell.tsx.
Two shared helpers tie them together: useDataTable (a thin wrapper over useReactTable) and DataTableShell (the table chrome every feature renders identically). Neither knows anything about inquiries — they're pure plumbing.
The orchestrator
The orchestrator is small on purpose. It owns the query params as local state, hands them to the feature's query hook, feeds the result into useDataTable, and renders the filter on top of the shell.
const [params, setParams] = useState<ListEnquiriesQueryParams>({ limit: 100 })
const { inquiries, loadingInquiries, fetchingInquiries } = useInquiries(params)
const table = useDataTable({
data: inquiries?.data ?? [],
columns: inquiryColumns,
defaultColumnVisibility: { id: false },
})Notice that params is typed with the generated ListEnquiriesQueryParams — the same contract the API speaks, so search, status, and date filters are all type-checked against the real endpoint. The filter receives params and setParams; when a user picks a status, it patches that state and the query refetches.
Memoize the fallback before useDataTable
useDataTable takes a data array. If you pass inquiries?.data ?? [], that ?? [] creates a brand-new array on every render when data is undefined — and a new array reference makes TanStack Table treat the data as changed, which can spin into a render loop. When the fallback is anything more than the simplest case, hoist it into a useMemo so the empty array stays referentially stable.
The shared hook
useDataTable exists so no feature has to repeat the same useReactTable boilerplate. It wires up the four pieces of table state — sorting, column filters, row selection, column visibility — and registers the standard row models.
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// ...state + onChange handlers
})Everything a feature needs from the table — selected rows, filter values, visible columns — comes off this one table instance. You'll see it passed down to both the filter and the shell.
The columns file
Columns are described with createColumnHelper, typed to the feature's DTO so every accessor is checked against a real field. The shape is consistent across the app: a select column always comes first, an actions column always comes last, and the visible columns sit in between.
const columnHelper = createColumnHelper<EnquiryDto>()
const inquiryColumns = [
createSelectColumn<EnquiryDto>(), // first — the checkbox
columnHelper.accessor('name', { header: 'Name', cell: /* ... */ }),
columnHelper.accessor('status', { header: 'Status', filterFn: 'equals', cell: /* ... */ }),
// ...more accessors
columnHelper.display({ id: 'actions', enableHiding: false, cell: /* ... */ }), // last
]A few conventions worth absorbing. Headers are plain strings — header: 'Status', not a component — which keeps the file readable and the right-hand TOC of your mental model tidy. Rich cells delegate to a renderer in cells/; the status column just renders <StatusCell row={info.row.original} />. And when a cell needs shared context — an open-panel callback, the current user — it reads it from table.options.meta rather than threading props down through the shell.
meta beats prop drilling
useDataTable accepts a meta object that lands on table.options.meta. Anything a cell needs that isn't on the row — callbacks, the active campus, permission flags — goes there. The shell flex-renders cells without knowing their props, so meta is the only clean channel into a cell. Reach for it instead of widening DataTableShell's prop list.
The filter
The filter is the toolbar that sits above the table — and it's where most of a feature's personality lives. Inquiry's has a search box, faceted dropdowns for staff, priority and status, a date-range picker, and a column-visibility popover. The faceted filters drive TanStack's column filters via table.getColumn('status')?.setFilterValue(...); the search box drives the server params.
Search is the one piece worth a close look, because typing into it shouldn't fire a request on every keystroke. It uses a debounce from @tanstack/pacer — the input updates instantly for a responsive feel, but the params (and therefore the query) only update once typing settles, roughly 800ms later.
const [searchValue, setSearchValue] = useState(params?.search || '')
const debouncedSearch = useMemo(
() =>
debounce((search: string) => setParams((p) => ({ ...p, search })), { wait: 800 }),
[setParams],
)
// <Input value={searchValue} onChange={(e) => {
// setSearchValue(e.target.value) // instant, local
// debouncedSearch(e.target.value) // settles after 800ms → refetch
// }} />The important takeaway is where filter state lives: in local useState, not the URL. That keeps the filter self-contained and refetches snappy, with the trade-off that a filtered view isn't shareable by link and is lost on refresh. If you ever need shareable filters, that's a conscious change to make — not the default.
The shell
DataTableShell is the chrome every table renders identically, so no feature reinvents the header rows, the loading state, or the empty state. Hand it the table, the columns, and the loading flags and it produces, top to bottom:
- a total-count badge sourced from the paginated response (
totalCountand arecordLabellike"inquiries"), - a loading skeleton while
isLoadingis true, - the table — header and body flex-rendered — dimmed during a background refetch, or an empty state when there are no rows,
- and the pagination footer.
<DataTableShell
table={table}
columns={inquiryColumns}
isLoading={loadingInquiries}
isFetching={fetchingInquiries}
totalCount={inquiries?.totalCount ?? 0}
recordLabel="inquiries"
emptyTitle="No Inquiries Found"
/>That isFetching flag is what fades the table to half-opacity during a background refetch — so a filter change feels alive without yanking the old rows away.
Two pagination systems
Here's the fork in the road, and the one decision you actually have to make when you build a new table. There are two pagination systems, and they don't mix.
The simple path. Ask the API for a big page — limit: 100 — then let TanStack's getPaginationRowModel slice it into pages in the browser. The shell's built-in DataTablePagination footer drives it. Inquiry works this way. It's fine when a tenant will never have thousands of rows.
The scalable path. The table holds one page at a time and asks the server for the next via a cursor. State comes from the shared useCursorParams, and you render CursorPaginationFooter instead of the default footer (passing hidePagination to the shell).
const { params, onSearch, onNext, onPrev, canPrev } = useCursorParams(20)
// onNext pushes the current cursor onto a stack so onPrev can rewindonNext advances afterId and remembers where it was, so Previous can walk back. The platform student, staff, guardian and guest tables all use this.
Known debt — read before copying a table
Three things to know before you clone an existing table. The sorting UI is unwired — getSortedRowModel is registered but no header toggles it, so columns don't actually sort yet. Most staff tables over-fetch with limit: 100 and client-paginate, which is fine for small tenants but won't scale. For any list that can grow large, prefer the cursor system from the start — retrofitting it later means reworking the orchestrator and the filter together.