Skip to content

Data Grid

Sticky columns, inline editing, saved views, printing and virtualization — plus the trade-offs each one carries.

Two grids, deliberately

DataTable is paginated and keeps pinning, inline editing, saved views, export and print. The virtualized grid at /data-grid is a separate component, because virtualization replaces pagination — so it has no pager, no rows-per-page and no Print. Reach for it only when there are too many rows to render at all.

Column pinning

<DataTable
  columns={columns}
  data={rows}
  enableColumnPinning
  initialColumnPinning={{ left: ["name"], right: [] }}
/>

Pin from the Columns menu; the control cycles start → end → unpinned. Offsets use logical properties, so a pinned column lands on the correct edge under RTL with no JavaScript.

Enabling it switches the table to border-collapse: separate and table-layout: fixed, which is not cosmetic. Three measured facts forced it:

  • Under border-collapse: collapse a sticky cell's border is not painted— the computed style still reports it. Tailwind's preflight sets that mode on every table.
  • box-shadow on a table cell is also ignored under collapse, so the obvious workaround fails too.
  • In the separate model, borders on <tr> are not rendered at all, so row dividers move onto the cells.

Fixed layout matters because under the default auto a column's declared size is only a suggestion: with w-full on a narrower container the browser compresses every column, the table never overflows, and a pinned column is indistinguishable from an ordinary one. Give pinnable columns an explicit size.

Inline editing

{
  accessorKey: "stock",
  meta: {
    editable: {
      type: "number",                       // "text" | "number" | "select"
      validate: (v) => Number(v) < 0 ? "Cannot be negative" : null,
    },
  },
}

<DataTable
  columns={columns}
  data={rows}
  onCellEdit={async ({ row, columnId, value }) => {
    await update(row.id, { [columnId]: Number(value) });   // your useMutation
  }}
/>

Enter or blur commits, Escape discards, an unchanged value writes nothing, and a rejected write restores the previous value and shows the message. Reject by throwing from onCellEdit.

A column declaring editable without onCellEdit renders no editor at all — a control that silently discards work is worse than none.

Saved views

<DataTable columns={columns} data={rows} viewsKey="products" />

Names the current sort, filters, hidden columns and pinning into localStorage under that key, and encodes the same state into ?view= so a link reproduces the arrangement for someone who has never opened the app.

The decoder treats that parameter as untrusted input — types checked, entry counts and string lengths capped, page size clamped — because the address bar is not a trusted source. A shared view applies just after first paint rather than during it: deriving the first client render from the URL would make React's tree disagree with the prerendered HTML about the actual rows.

Print, and the gotcha

Pagination removes rows from the DOM. Printing a table showing page 1 of 12 would produce ten rows under a footer claiming 120, and no stylesheet can fix that — there is nothing to reveal. The Print action expands the table, waits two frames (one for React to commit, one for the browser to lay the rows out), prints, and restores on afterprint.

It is called Print / Save as PDF, not “PDF export”, and that wording is deliberate. With no server there is no headless browser. jspdf is roughly 350KB gzipped and its built-in fonts are Latin-1 with no bidi shaping, so it would mangle Arabic and Hebrew in a template whose RTL support is a headline feature. The browser's own print-to-PDF shapes every script correctly and costs nothing.

Virtualization

/data-grid scrolls 50,000 rows with roughly thirty in the DOM. Row heights are measured rather than assumed, because --table-row-height changes with the density setting and a user can change it while looking at the table.

@tanstack/react-virtual is a dependency of this template, not of @dashboardpack/core. Core ships untranspiled source that consumers typecheck as first-party, so any import there must resolve in every template — an “optional” peer is optional to npm only.

Export

Covered separately in Export, including why a server-paged table needs getAllRows and what happens if you omit it.