DataGrid
A feature-rich, headless table component built on TanStack Table with support for sorting, filtering, pagination, row selection, real-time WebSocket updates, and master-detail expansion.
Installation
npx shadcn@latest add https://befame-registry.example.com/r/data-grid.jsonBasic Usage
import { DataGrid } from '@/components/data-grid/data-grid'
import { createColumnHelper } from '@tanstack/react-table'
import type { ColumnDef } from '@/components/data-grid/types'
interface User {
id: number
name: string
email: string
}
const col = createColumnHelper<User>()
const columns: ColumnDef<User>[] = [
col.accessor('id', { header: 'ID' }),
col.accessor('name', { header: 'Name' }),
col.accessor('email', { header: 'Email' }),
]
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]
export function BasicExample() {
return <DataGrid columns={columns} data={users} />
}Feature Flags
All features are opt-in via the features prop:
<DataGrid
columns={columns}
data={users}
features={{
sorting: true,
filtering: true,
pagination: true,
rowSelection: true,
expanding: true,
}}
/>Column Headers with Sorting
Use DataGridColumnHeader to get a sortable column header with the standard sort indicator:
import { DataGridColumnHeader } from '@/components/data-grid/data-grid-column-header'
const columns: ColumnDef<User>[] = [
col.accessor('name', {
header: ({ column }) => <DataGridColumnHeader title="Name" column={column} />,
meta: { label: 'Full Name' }, // used in the column-visibility menu
}),
col.accessor('email', {
header: ({ column }) => <DataGridColumnHeader title="Email" column={column} />,
}),
]Server-Side Pagination with a Store
createDataGridStore wires up a paginated server endpoint. The grid handles paging, sorting and filtering automatically.
import { createDataGridStore } from '@/components/data-grid/lib/create-store'
// REST endpoint — the grid appends ?skip=0&take=10&sort=...&filter=...
const usersStore = createDataGridStore<User>({
key: ['users'],
endpoint: '/api/users',
searchFields: ['name', 'email'], // fields searched when the user types in the toolbar
})
export function ServerSideExample() {
return (
<DataGrid
columns={columns}
store={usersStore}
features={{ sorting: true, filtering: true, pagination: true }}
pagination={{ pageSize: 10, pageSizeOptions: [5, 10, 25, 50] }}
/>
)
}Custom Loader
Use a custom load function instead of an endpoint for non-REST sources, auth headers, GraphQL, etc.
const usersStore = createDataGridStore<User>({
key: ['users'],
load: async (req) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
return res.json() // must return { data: TData[], total: number }
},
})Invalidating / Refetching from Outside the Grid
The store exposes invalidate and refetch so you can trigger a reload after mutations:
import { useQueryClient } from '@tanstack/react-query'
function DeleteUserButton({ userId }: { userId: number }) {
const queryClient = useQueryClient()
const handleDelete = async () => {
await fetch(`/api/users/${userId}`, { method: 'DELETE' })
await usersStore.invalidate(queryClient) // grid reloads automatically
}
return <Button onClick={handleDelete}>Delete</Button>
}TanStack Query (queryResult)
If you already use useQuery in your component, pass the result directly:
import { useQuery } from '@tanstack/react-query'
function UsersPage() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const query = useQuery({
queryKey: ['users', pagination],
queryFn: () =>
fetch(`/api/users?skip=${pagination.pageIndex * pagination.pageSize}&take=${pagination.pageSize}`)
.then(r => r.json()),
})
return (
<DataGrid
columns={columns}
queryResult={query}
features={{ pagination: true }}
pagination={{
manual: true,
rowCount: query.data?.total,
pageSize: 10,
onPaginationChange: setPagination,
}}
/>
)
}Row Selection
<DataGrid
columns={columns}
data={users}
features={{ rowSelection: true }}
onSelectionChange={(rows) => console.log('Selected rows:', rows)}
onRowSelectedChange={(row, isSelected) => console.log(row.name, isSelected)}
// Pre-select rows on load
getRowInitiallySelected={(row) => row.status === 'active'}
/>Row Actions
Add a dedicated actions column with custom buttons per row:
<DataGrid
columns={columns}
data={users}
rowActions={{
size: 120, // column width in px
render: (user) => (
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => viewUser(user)}>View</Button>
<Button size="sm" variant="outline" onClick={() => editUser(user)}>Edit</Button>
</div>
),
}}
/>Row Styling with onRowPreparing
Customize per-row <tr> props (class, style, data attributes, event handlers) based on row data:
<DataGrid
columns={columns}
data={users}
onRowPreparing={({ data, isSelected, rowIndex }) => {
if (data.status === 'inactive') {
return { className: 'opacity-60 italic' }
}
if (isSelected) {
return { className: 'bg-blue-50' }
}
return rowIndex % 2 === 1 ? { className: 'bg-muted/30' } : undefined
}}
/>Master-Detail (Row Expansion)
Render an expandable detail panel below each row:
<DataGrid
columns={columns}
data={users}
features={{ expanding: true }}
renderSubComponent={(user) => <UserDetailPanel user={user} />}
// Optionally restrict which rows can expand
getRowCanExpand={(user) => user.role === 'admin'}
onExpandedChange={(expandedRows) => console.log('Expanded:', expandedRows)}
/>Custom Toolbar Content
Inject buttons or other controls into the toolbar alongside the built-in search:
<DataGrid
columns={columns}
data={users}
features={{ filtering: true }}
toolbar={{
columnVisibility: true, // show/hide column toggle
children: (
<Button variant="outline" onClick={handleExport}>
Export CSV
</Button>
),
}}
/>Loading State
// Default skeleton rows
<DataGrid columns={columns} data={[]} loadingConfig={{ isLoading: true, rows: 8 }} />
// Custom loading component
<DataGrid
columns={columns}
data={[]}
loadingConfig={{
isLoading: true,
component: <MySpinner />,
}}
/>Zod Validation
Drop invalid rows before rendering and surface errors to the consumer:
import { z } from 'zod'
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
<DataGrid
columns={columns}
data={untrustedData}
schema={userSchema}
onValidationError={(row, error) => {
console.warn('Invalid row dropped:', row, error.issues)
}}
/>Real-Time WebSocket Updates
<DataGrid
columns={columns}
data={initialData}
websocket={{
url: 'wss://your-server.com/live/users',
onMessage: (event, currentData) => {
const update = JSON.parse(event.data)
return currentData.map(row => (row.id === update.id ? update : row))
},
reconnectInterval: 2000,
maxReconnectAttempts: 10,
}}
/>API Reference
DataGrid Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | — | Static data array. Use this or queryResult/store. |
queryResult | UseQueryResult<DataGridResponse<TData>> | — | TanStack Query result. The grid reads data, isFetching. |
store | DataGridStore<TData> | — | Self-managed paginated store. Created via createDataGridStore. Enables server-side pagination automatically. |
columns | ColumnDef<TData>[] | required | TanStack Table column definitions. |
features | DataGridFeatures | {} | Feature flags (sorting, filtering, pagination, rowSelection, expanding). |
pagination | DataGridPaginationConfig | {} | Pagination settings. |
toolbar | DataGridToolbarProps | — | Toolbar config; pass children to add custom controls. |
schema | z.ZodType<TData> | — | Zod schema for row validation. Invalid rows are dropped. |
onValidationError | (row, error: ZodError) => void | — | Called when a row fails schema validation. |
websocket | DataGridWebSocketConfig | — | Real-time update config. |
onRowClick | (row: Row<TData>) => void | — | Called when a row is clicked. Adds cursor-pointer to rows. |
onSelectionChange | (rows: TData[]) => void | — | Called when the selected row set changes. |
onRowSelectedChange | (row: TData, isSelected: boolean) => void | — | Called when a single row's selection state toggles. |
getRowInitiallySelected | (data: TData) => boolean | — | Seed the initial selection state from row data on load. |
onRowPreparing | (info: RowPreparingInfo<TData>) => RowPreparingResult | void | — | Customize <tr> props per row (className, style, event handlers). |
renderSubComponent | (row: TData) => ReactNode | — | Render the expanded detail panel (requires features.expanding). |
getRowCanExpand | (row: TData) => boolean | () => true | Control which rows show the expand toggle. |
onExpandedChange | (expandedRows: TData[]) => void | — | Called when rows are expanded or collapsed. |
rowActions | RowActionsConfig<TData> | — | Add an actions column with custom buttons. |
loadingConfig | DataGridLoadingConfig | {} | Loading state display (skeleton rows or custom component). |
emptyMessage | string | 'No results.' | Message displayed when there are no rows. |
className | string | — | Extra class applied to the outer wrapper. |
DataGridFeatures
| Prop | Type | Default | Description |
|---|---|---|---|
sorting | boolean | true | Enable column sorting. |
filtering | boolean | true | Enable global text search in the toolbar. |
pagination | boolean | true | Enable pagination controls. |
rowSelection | boolean | false | Enable row selection checkboxes. |
expanding | boolean | false | Enable master-detail row expansion (requires renderSubComponent). |
DataGridPaginationConfig
| Prop | Type | Default | Description |
|---|---|---|---|
pageSize | number | 10 | Initial number of rows per page. |
pageSizeOptions | number[] | — | Available page-size choices shown in the pagination footer. |
manual | boolean | false | Enable server-side pagination. The grid won't slice data itself. |
rowCount | number | — | Total row count from the server. Required in manual mode. |
onPaginationChange | (pagination: PaginationState) => void | — | Called when the page or page size changes (use to refetch the server page). |
DataGridStoreConfig
| Prop | Type | Default | Description |
|---|---|---|---|
key | QueryKey | required | Base TanStack Query key. Invalidating this key refreshes all pages. |
endpoint | string | — | REST endpoint. The grid appends ?skip=&take=&sort=&filter=. Provide this or load. |
load | (req: DataGridRequest) => Promise<DataGridResponse<TData>> | — | Custom async loader (for auth, GraphQL, non-REST, etc.). |
params | Record<string, string | number | boolean> | — | Extra static query params appended to every request (endpoint mode). |
fetcher | typeof fetch | fetch | Custom fetch implementation (e.g. to add auth headers). |
searchFields | string[] | — | Field names used to build a contains filter from the toolbar search input. |
DataGridWebSocketConfig
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | required | WebSocket URL (ws://, wss://, or PartyKit project URL). |
onMessage | (event: MessageEvent, currentData: TData[]) => TData[] | — | Transform an incoming message into updated row data. |
reconnectInterval | number | 1000 | Reconnect interval in ms. |
maxReconnectAttempts | number | 5 | Max reconnect attempts before giving up. |
RowActionsConfig
| Prop | Type | Default | Description |
|---|---|---|---|
render | (row: TData) => ReactNode | ReactNode[] | required | Render action buttons for a row. |
size | number | 100 | Width of the actions column in pixels. |
DataGridLoadingConfig
| Prop | Type | Default | Description |
|---|---|---|---|
isLoading | boolean | — | Whether the grid is in a loading state. |
rows | number | 5 | Number of skeleton rows to show while loading. |
component | ReactNode | — | Custom component rendered instead of the default skeleton. |
RowPreparingInfo
Passed to onRowPreparing for each rendered row.
| Field | Type | Description |
|---|---|---|
row | Row<TData> | TanStack Table row object. |
data | TData | The row's underlying data object. |
rowIndex | number | Zero-based index within the current page. |
isSelected | boolean | Whether the row is currently selected. |
isExpanded | boolean | Whether the row is currently expanded. |
DataGridRequest
Shape of the paging request sent to your server (DevExtreme LoadOptions pattern).
| Field | Type | Description |
|---|---|---|
skip | number | Rows to skip (zero-based offset). |
take | number | Rows to return (page size). |
filter | string | null | DevExtreme filter JSON, e.g. '["name","contains","john"]'. |
sort | string | null | DevExtreme sort JSON, e.g. '[["name","asc"]]'. |
DataGridResponse
Shape of the paginated response your server must return.
| Field | Type | Description |
|---|---|---|
data | TData[] | Rows for the current page. |
total | number | Total row count across all pages. |
DataGridColumnMeta
Optional metadata you can attach to any column definition via meta:
| Field | Type | Description |
|---|---|---|
label | string | Human-readable display name used in the column-visibility menu. Falls back to the string header, then a humanized column id. |
hideable | boolean | Whether this column can be hidden via the column-visibility toggle. |
filterable | boolean | Whether this column participates in column-level filtering. |