Local Data Table
This example shows how common features work together in one local-mode table. You you can copy, run, and then adapt to your own product. It builds a local table with typed rows, stable row IDs, search, filters, display options, selection, bulk actions, keyboard navigation, and preview using static in-file data with no custom cells.
Before you start, install the datatable package and set it up. See Installation.
1. Row type and data
Define one Customer type and keep the data array in the same file so the example is fully copyable.
// client
type Customer = {
id: string
name: string
company: string
status: "Active" | "Trial" | "Paused"
plan: "Starter" | "Team" | "Enterprise"
seats: number
revenue: number
owner: string
region: "NA" | "EU" | "APAC"
renewal: string
}
const rows: Customer[] = [
{ id: "cus_0001", name: "Ava Patel", company: "Northstar Labs", status: "Active", plan: "Enterprise", seats: 185, revenue: 42100, owner: "Jordan Lee", region: "NA", renewal: "2026-09-14" },
{ id: "cus_0002", name: "Noah Kim", company: "Blue Harbor Health", status: "Trial", plan: "Team", seats: 42, revenue: 7600, owner: "Marta Rossi", region: "EU", renewal: "2026-06-02" },
{ id: "cus_0003", name: "Emma Silva", company: "Orbit Retail Group", status: "Active", plan: "Team", seats: 96, revenue: 18300, owner: "Jordan Lee", region: "NA", renewal: "2026-11-08" },
// ...16 additional rows with the same shape
]This is a good local-mode shape because the browser owns the full dataset and the table can evaluate search, filters, sorting, grouping, and selection without a server query layer.
2. Define columns
The example columns do more than render labels. They decide filtering, sorting, grouping, and widths with built-in behaviors only.
// client
const columns: DatatableColumn<Customer>[] = [
{
id: "company",
header: "Company",
accessorKey: "company",
width: 250,
enableSorting: true,
enableFiltering: true,
filterType: "text",
},
{
id: "name",
header: "Contact",
accessorKey: "name",
width: 190,
enableSorting: true,
enableFiltering: true,
filterType: "text",
},
{
id: "status",
header: "Status",
accessorKey: "status",
width: 140,
enableSorting: true,
enableFiltering: true,
enableGrouping: true,
filterType: "text-list",
filterOptions: {
options: statuses.map((status) => ({ label: status, value: status })),
},
},
{
id: "revenue",
header: "Revenue",
accessorKey: "revenue",
width: 130,
enableSorting: true,
enableFiltering: true,
filterType: "number",
cell: ({ getValue }) => `$${Number(getValue()).toLocaleString()}`,
},
]The full example also includes plan, seats, owner, region, and renewal to demonstrate text, list, number, and date filters in one place.
3. Mount with stable row IDs
The core table stays simple:
// client
<Datatable
tableKey="customers"
data={rows}
columns={columns}
getRowId={(row) => row.id}
initialState={{
sorting: [{ id: "name", desc: false }],
showHorizontalLines: false,
showVerticalLines: false,
}}
/>That gives the example a stable contract before any extra interaction layers are added.
4. Toolbar
The runnable example turns on quick search, filters, and display options from the toolbar.
// client
toolbar={{
quickSearch: {
placeholder: "Search 1,200 customers...",
},
filterButton: true,
displayOptions: true,
copyLink: false,
views: false,
}}This is a good local example mix because it focuses on browsing the current dataset, not URL sharing or saved views.
5. Selection and bulk actions
Selection is enabled together with a small bulk-actions registry.
// client
selection={{
enabled: true,
mode: "multi",
showCheckboxOnHover: false,
allowSelectAllMatching: true,
}}
bulkActions={{
triggerLabel: "Actions",
actions: bulkActions,
}}The example bulk actions intentionally do lightweight local work:
- export selected IDs
- mark selected customers as reviewed
Each action also clears selection and closes the dialog so the workflow feels complete after the operation finishes.
6. Keyboard navigation and preview
The example supports both keyboard-first navigation and a floating preview panel.
// client
keyboardNavigation={{
enabled: true,
}}
rowActions={{
onOpenRow: ({ row }) => {
setLastAction(`Opened ${row.company}.`)
},
onTogglePreviewRow: ({ row, nextOpen }) => {
setLastAction(`${nextOpen ? "Previewing" : "Closed preview for"} ${row.company}.`)
},
}}
preview={{
floating: {
draggable: true,
storageKey: "react-datatable-local-basic",
},
renderPreview: ({ row }) => (
<CustomerPreview customer={row} />
),
}}This is a good example of the local table acting like a real product surface instead of a bare data grid.
Full example (single file)
Expand to copy the full local customer table example
// client
import { useMemo, useState } from "react"
import { Datatable, type DataTableBulkAction, type DatatableColumn } from "./react-datatable"
type CustomerStatus = "Active" | "Trial" | "Paused"
type CustomerPlan = "Starter" | "Team" | "Enterprise"
type CustomerRegion = "NA" | "EU" | "APAC"
type Customer = {
id: string
name: string
company: string
status: CustomerStatus
plan: CustomerPlan
seats: number
revenue: number
owner: string
region: CustomerRegion
renewal: string
}
const rows: Customer[] = [
{ id: "cus_0001", name: "Ava Patel", company: "Northstar Labs", status: "Active", plan: "Enterprise", seats: 185, revenue: 42100, owner: "Jordan Lee", region: "NA", renewal: "2026-09-14" },
{ id: "cus_0002", name: "Noah Kim", company: "Blue Harbor Health", status: "Trial", plan: "Team", seats: 42, revenue: 7600, owner: "Marta Rossi", region: "EU", renewal: "2026-06-02" },
{ id: "cus_0003", name: "Emma Silva", company: "Orbit Retail Group", status: "Active", plan: "Team", seats: 96, revenue: 18300, owner: "Jordan Lee", region: "NA", renewal: "2026-11-08" },
{ id: "cus_0004", name: "Liam Turner", company: "Cinder Logistics", status: "Paused", plan: "Starter", seats: 18, revenue: 2400, owner: "Priya Nair", region: "APAC", renewal: "2026-05-29" },
{ id: "cus_0005", name: "Mia Dubois", company: "Kepler Finance", status: "Active", plan: "Enterprise", seats: 220, revenue: 50900, owner: "Marta Rossi", region: "EU", renewal: "2026-12-01" },
{ id: "cus_0006", name: "Ethan Brooks", company: "Summit Bio", status: "Trial", plan: "Starter", seats: 24, revenue: 3200, owner: "Diego Alvarez", region: "NA", renewal: "2026-07-10" },
{ id: "cus_0007", name: "Sophia Chen", company: "Copperline Energy", status: "Active", plan: "Team", seats: 88, revenue: 15400, owner: "Jordan Lee", region: "EU", renewal: "2026-10-21" },
{ id: "cus_0008", name: "Lucas Meyer", company: "Atlas Devices", status: "Paused", plan: "Team", seats: 57, revenue: 9100, owner: "Priya Nair", region: "NA", renewal: "2026-08-03" },
{ id: "cus_0009", name: "Isabella Reed", company: "Helio Security", status: "Active", plan: "Enterprise", seats: 164, revenue: 33700, owner: "Diego Alvarez", region: "APAC", renewal: "2026-09-30" },
{ id: "cus_0010", name: "James Park", company: "Maple Education", status: "Trial", plan: "Team", seats: 39, revenue: 6900, owner: "Marta Rossi", region: "EU", renewal: "2026-06-27" },
{ id: "cus_0011", name: "Charlotte Gray", company: "Riverbend Media", status: "Active", plan: "Starter", seats: 31, revenue: 5100, owner: "Priya Nair", region: "NA", renewal: "2026-10-05" },
{ id: "cus_0012", name: "Benjamin Cole", company: "Stonegate Manufacturing", status: "Paused", plan: "Enterprise", seats: 142, revenue: 27100, owner: "Jordan Lee", region: "EU", renewal: "2026-07-19" },
{ id: "cus_0013", name: "Amelia Ward", company: "Vista Hospitality", status: "Active", plan: "Team", seats: 73, revenue: 12900, owner: "Diego Alvarez", region: "APAC", renewal: "2026-11-15" },
{ id: "cus_0014", name: "Henry Price", company: "Signal Works", status: "Trial", plan: "Starter", seats: 16, revenue: 2100, owner: "Marta Rossi", region: "NA", renewal: "2026-05-18" },
{ id: "cus_0015", name: "Harper Quinn", company: "Lumen Foods", status: "Active", plan: "Enterprise", seats: 198, revenue: 44800, owner: "Jordan Lee", region: "EU", renewal: "2026-12-12" },
{ id: "cus_0016", name: "Alexander Diaz", company: "Nimbus Telecom", status: "Paused", plan: "Team", seats: 64, revenue: 11200, owner: "Priya Nair", region: "APAC", renewal: "2026-08-24" },
{ id: "cus_0017", name: "Evelyn Scott", company: "Forge Mobility", status: "Active", plan: "Team", seats: 104, revenue: 20100, owner: "Diego Alvarez", region: "NA", renewal: "2026-09-07" },
{ id: "cus_0018", name: "Michael Evans", company: "Prism Insurance", status: "Trial", plan: "Starter", seats: 22, revenue: 2800, owner: "Marta Rossi", region: "EU", renewal: "2026-06-14" },
{ id: "cus_0019", name: "Abigail Hill", company: "Tidal Commerce", status: "Active", plan: "Enterprise", seats: 176, revenue: 39200, owner: "Jordan Lee", region: "NA", renewal: "2026-11-28" },
{ id: "cus_0020", name: "Daniel Young", company: "Pioneer Clinics", status: "Paused", plan: "Starter", seats: 28, revenue: 3600, owner: "Priya Nair", region: "APAC", renewal: "2026-07-01" },
]
const statuses: CustomerStatus[] = ["Active", "Trial", "Paused"]
const plans: CustomerPlan[] = ["Starter", "Team", "Enterprise"]
const regions: CustomerRegion[] = ["NA", "EU", "APAC"]
function CustomerPreview({ customer }: { customer: Customer }) {
return (
<article className="mx-auto max-w-lg text-sm text-foreground">
<h3 className="m-0 text-xl font-semibold">{customer.company}</h3>
<p className="m-0 mt-1 text-muted-foreground">{customer.name}</p>
<dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-2">
<dt className="text-muted-foreground">Status</dt>
<dd className="m-0 text-right">{customer.status}</dd>
<dt className="text-muted-foreground">Plan</dt>
<dd className="m-0 text-right">{customer.plan}</dd>
<dt className="text-muted-foreground">Owner</dt>
<dd className="m-0 text-right">{customer.owner}</dd>
<dt className="text-muted-foreground">Region</dt>
<dd className="m-0 text-right">{customer.region}</dd>
<dt className="text-muted-foreground">Seats</dt>
<dd className="m-0 text-right">{customer.seats}</dd>
<dt className="text-muted-foreground">Revenue</dt>
<dd className="m-0 text-right">${customer.revenue.toLocaleString()}</dd>
<dt className="text-muted-foreground">Renewal</dt>
<dd className="m-0 text-right">{customer.renewal}</dd>
</dl>
</article>
)
}
export function LocalCustomerTableExample() {
const [lastAction, setLastAction] = useState("Ready: local rows loaded.")
const columns = useMemo<DatatableColumn<Customer>[]>(() => [
{
id: "company",
header: "Company",
accessorKey: "company",
width: 250,
enableSorting: true,
enableFiltering: true,
filterType: "text",
},
{
id: "name",
header: "Contact",
accessorKey: "name",
width: 190,
enableSorting: true,
enableFiltering: true,
filterType: "text",
},
{
id: "status",
header: "Status",
accessorKey: "status",
width: 130,
enableSorting: true,
enableFiltering: true,
enableGrouping: true,
filterType: "text-list",
filterOptions: {
options: statuses.map((status) => ({ label: status, value: status })),
},
},
{
id: "plan",
header: "Plan",
accessorKey: "plan",
width: 130,
enableSorting: true,
enableFiltering: true,
enableGrouping: true,
filterType: "text-list",
filterOptions: {
options: plans.map((plan) => ({ label: plan, value: plan })),
},
},
{
id: "region",
header: "Region",
accessorKey: "region",
width: 120,
enableSorting: true,
enableFiltering: true,
enableGrouping: true,
filterType: "text-list",
filterOptions: {
options: regions.map((region) => ({ label: region, value: region })),
},
},
{
id: "seats",
header: "Seats",
accessorKey: "seats",
width: 110,
enableSorting: true,
enableFiltering: true,
filterType: "number",
},
{
id: "revenue",
header: "Revenue",
accessorKey: "revenue",
width: 130,
enableSorting: true,
enableFiltering: true,
filterType: "number",
cell: ({ getValue }) => `$${Number(getValue()).toLocaleString()}`,
},
{
id: "owner",
header: "Owner",
accessorKey: "owner",
width: 170,
enableSorting: true,
enableFiltering: true,
enableGrouping: true,
filterType: "text-list",
filterOptions: {
options: Array.from(new Set(rows.map((row) => row.owner))).map((owner) => ({ label: owner, value: owner })),
},
},
{
id: "renewal",
header: "Renewal",
accessorKey: "renewal",
width: 140,
enableSorting: true,
enableFiltering: true,
filterType: "date",
},
], [])
const bulkActions = useMemo<DataTableBulkAction<Customer>[]>(() => [
{
id: "mark-reviewed",
title: "Mark as reviewed",
keywords: ["review", "done"],
onSelect: (context) => {
setLastAction(`Marked ${context.selectedCount} customer${context.selectedCount === 1 ? "" : "s"} as reviewed.`)
context.clearSelection()
context.closeDialog()
},
},
{
id: "export-selection",
title: "Export selected IDs",
keywords: ["export", "ids"],
onSelect: (context) => {
const ids = context.selectedRows.map((row) => row.id).join(", ")
setLastAction(ids.length > 0 ? `Selected IDs: ${ids}` : "No rows selected.")
context.clearSelection()
context.closeDialog()
},
},
], [])
return (
<section className="rounded-xl border border-border bg-card p-4">
<p className="mb-3 text-sm text-muted-foreground">{lastAction}</p>
<div className="h-[560px] overflow-hidden rounded-lg border border-border">
<Datatable
tableKey="customers"
data={rows}
columns={columns}
getRowId={(row) => row.id}
toolbar={{
quickSearch: {
placeholder: "Search 20 customers...",
},
filterButton: true,
displayOptions: true,
copyLink: false,
views: false,
}}
initialState={{
sorting: [{ id: "company", desc: false }],
showHorizontalLines: false,
showVerticalLines: false,
}}
selection={{
enabled: true,
mode: "multi",
showCheckboxOnHover: false,
allowSelectAllMatching: true,
}}
bulkActions={{
triggerLabel: "Actions",
actions: bulkActions,
}}
keyboardNavigation={{
enabled: true,
}}
rowActions={{
onOpenRow: ({ row }) => {
setLastAction(`Opened ${row.company}.`)
},
onTogglePreviewRow: ({ row, nextOpen }) => {
setLastAction(`${nextOpen ? "Previewing" : "Closed preview for"} ${row.company}.`)
},
}}
preview={{
floating: {
draggable: true,
storageKey: "docs-local-customer-example",
},
renderPreview: ({ row }) => <CustomerPreview customer={row} />,
}}
/>
</div>
</section>
)
}Where to go next
- For the setup boundary behind this example, read Getting started.
- For the top-level contract decisions, read Define your table and Define columns.
- For a server-owned variant, continue to Paginated Table.