react-datatable
Guides

Define columns

Use this guide right after Quickstart, before you lock the top-level table contract.

The goal is to define columns that stay stable over time and make the right behavior obvious to the table.

Start from the row shape

Write or confirm the row type before you define columns:

export type Customer = {
  id: string
  company: string
  owner: string
  status: "active" | "trial" | "paused"
  seats: number
  renewalDate: string
}

This keeps columns grounded in real product data instead of ad-hoc UI formatting.

Give every column a stable ID

Use explicit IDs for every column:

import type { DatatableColumn } from "./react-datatable"

export const customerColumns: DatatableColumn<Customer>[] = [
  {
    id: "company",
    accessorKey: "company",
    header: "Company",
    filterType: "text",
  },
  {
    id: "status",
    accessorKey: "status",
    header: "Status",
    filterType: "text-list",
    filterOptions: {
      options: ["active", "trial", "paused"],
    },
  },
]

Do not derive IDs from translated labels or visible text.

Column IDs feed filtering, sorting, grouping, URL state, saved views, persisted state, and online query inputs. If an ID changes, stored user state may stop mapping correctly.

[!NOTE] Screenshot placeholder: realistic table with columns whose visible labels differ from stable internal IDs, emphasizing why stable column contracts matter.

Prefer accessorKey for real fields

Use accessorKey when the displayed value maps directly to a row property:

{
  id: "company",
  accessorKey: "company",
  header: "Company",
  filterType: "text",
}

This is the easiest shape to keep consistent across local mode, online mode, and API mapping.

Use accessorFn when the field is nested or intentionally derived

Use accessorFn when the displayed value comes from nested data or from a computed value.

For example, if the row contains a richer owner object, keep the row shape honest and pick the exact value the table should use:

export type Customer = {
  id: string
  company: string
  owner: {
    id: string
    name: string
    company: {
      id: string
      name: string
    }
  }
  status: "active" | "trial" | "paused"
}

{
  id: "ownerName",
  header: "Owner",
  accessorFn: (row) => row.owner.name,
  filterType: "text",
}

That is usually better than flattening product data just to satisfy the table.

Use accessorFn when the column value is computed too:

{
  id: "accountHealth",
  header: "Health",
  accessorFn: (row) => row.status + ":" + row.seats,
  enableSorting: false,
}

When you do this, keep the ID explicit and confirm whether your online backend also needs a matching derived field or sort/filter concept. If the backend sorts or filters on owner.name, make sure the server-side contract uses the same semantic field even if the raw object is richer.

[!NOTE] Screenshot placeholder: nested owner/company data rendered as a clean Owner column, with the richer underlying object shape called out in accompanying art direction.

Choose the filter type based on the data, not the styling

A good column definition decides how users should query the field.

{
  id: "seats",
  accessorKey: "seats",
  header: "Seats",
  filterType: "number",
  enableSorting: true,
}

A few common mappings:

  • names and labels → text
  • finite statuses or plans → text-list
  • numeric measures → number
  • machine-readable dates → date

If a field is a finite set, use list-style filtering even if the cell is rendered as a badge or pill.

Decide sorting and grouping intentionally

Columns define more than rendering. For each one, decide:

  • should users sort this field?
  • should users group by it?
  • does grouped output need a grouping spec?
{
  id: "renewalDate",
  accessorKey: "renewalDate",
  header: "Renewal",
  filterType: "date",
  enableGrouping: true,
  groupingSpec: {
    defaultVariant: "month",
    variants: {
      month: { kind: "date_trunc", granularity: "month" },
    },
  },
}

If a column is mostly a control surface, like an actions column, sorting and filtering should usually be disabled.

Pick a column pattern that matches the field

Once the behavior is clear, choose the presentation pattern that matches it:

  • plain text for names and identifiers
  • number formatting for metrics while keeping the raw value numeric
  • date formatting for machine-readable dates
  • badges or pills for finite status-like fields
  • links for primary entities that open a detail page
  • compact action buttons for per-row controls

The key idea is that the column pattern includes both behavior and UI, not just the cell renderer.

Use metadata when on-screen labels should differ from the contract

If the table needs a different label in the interface than it uses in the column contract, use column metadata rather than changing the column ID.

meta.displayName, meta.filterName, and related metadata let the interface stay readable while the underlying column contract stays stable.

Check your columns before moving on

Before you add more features, verify that:

  • each column has a stable ID
  • accessor choice matches the real data shape
  • filter type matches the field semantics
  • sorting and grouping are enabled only where they make sense
  • custom cells do not hide the underlying data behavior

On this page