DataTable

Sort, filter, select, paginate.

8 minmedium

The DataTable rendered by defineResource() handles sort, filter, pagination, search, row selection, and per-column cell rendering — all from the column config. This lesson tours the knobs.

The default surface

From the previous lesson's minimal config you got:

  • Sortable column headers (click to sort)
  • Pagination at the bottom (20 rows / page default)
  • Search bar (searches columns with searchable: true)
  • Row action menu (Edit / Delete / View)
  • Bulk selection checkboxes
  • Loading + empty + error states out of the box

Adding filters

filters: [
{
type: 'select',
name: 'status',
label: 'Status',
options: [
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
},
{
type: 'date-range',
name: 'created_between',
label: 'Created',
},
{
type: 'multi-select',
name: 'category_id',
label: 'Category',
optionsFrom: '/api/categories', // populates from your API
},
]

Each filter appears in the toolbar. Selected values become URL query params, so a filtered view is shareable / bookmarkable / back-button-friendly.

Per-column custom render

columns: [
{ key: 'name', label: 'Name' },
{
key: 'status',
label: 'Status',
format: 'badge',
badgeColor: (v) => v === 'active' ? 'green' : 'gray',
},
{
key: 'image_url',
label: 'Image',
format: 'image',
width: 60,
},
{
key: 'price',
label: 'Price',
cell: (row) => <strong className="text-green-600">{formatCurrency(row.price)}</strong>,
},
]

format is the quick-and-clean shortcut. cell is the escape hatch for custom JSX.

Bulk actions

bulkActions: [
{
label: 'Archive',
icon: Archive,
onClick: async (selectedIds, refresh) => {
await fetch('/api/products/bulk-archive', {
method: 'POST',
body: JSON.stringify({ ids: selectedIds }),
})
refresh() // re-fetches the table
},
},
{
label: 'Export as CSV',
onClick: (ids) => window.open(`/api/products/export?ids=${ids.join(',')}`),
},
]

Bulk actions appear in a toolbar that slides up when rows are selected. The handler gets the array of selected IDs + refresh().

Sorting + URL state

Every filter, sort, page number, and search query syncs to the URL:

/resources/products?page=2&sort=price&order=desc&q=widget&status=active

A user can bookmark a filtered view, send the URL to a teammate, or hit the back button to undo a filter. No work on your part.

Search on indexed columns only. Marking a column searchable: true issues a WHERE col ILIKE '%q%' on every keystroke. On a 100K- row table without an index, this hangs the UI. Add a GIN trigram index for search columns.

Per-row actions vs. bulk actions

  • Row actions live in the "…" menu at the end of each row. Default: Edit, Delete, View.
  • Bulk actions appear above the table when rows are selected. Use for "archive 50 things" type workflows.

Quick check

Your admin's Products list page loads slowly with 50,000 products. What's the most likely fix?

Try it

Extend your products admin with two improvements:

  1. Make the name column searchable.
  2. Add a select filter for an is_active: boolean column (you may need to add this field to the Product model + grit migrate + grit sync).
  3. Add a bulk action "Mark as inactive" that flips is_active to false on selected rows.
  4. Test that the URL changes when you filter/search.

Paste the URL of a filtered+searched+sorted state in notes.md.

What's next

Last lesson of this chapter — FormBuilder. The forms behind New / Edit, with all 8 field types in action.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub