Naalya Handbook
Recipes

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:

src/components/data-tables/inquiry/
inquiry/
  datatable.tsx   # the orchestrator — fetch + assemble
  columns.tsx     # column definitions
  filter.tsx      # the search + faceted-filter toolbar
  cells/          # one file per non-trivial cell

The 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:

src/components/data-tables/inquiry/columns.tsx
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:

src/components/data-tables/inquiry/columns.tsx
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:

src/components/data-tables/inquiry/datatable.tsx
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:

src/components/data-tables/inquiry/datatable.tsx
<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:

src/components/data-tables/inquiry/filter.tsx
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:

src/components/data-tables/inquiry/filter.tsx
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.

src/components/data-tables/inquiry/datatable.tsx
const [params, setParams] = useState<ListEnquiriesQueryParams>({ limit: 100 })
// DataTableShell shows its built-in pager automatically

Reach 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:

src/components/data-tables/platform/guest/datatable.tsx
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.

Where to go next

On this page