react-gridix 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,2080 @@
1
+ # react-gridix Docs
2
+
3
+ A React `DataGrid` with row selection, server-side mode, typed per-column filters, date columns, in-cell editing, resizing and pinning, virtualization, CSV export and 8 themes, plus the `Checkbox` it uses.
4
+
5
+ **Contents**
6
+
7
+ 1. [Overview](#1-overview)
8
+ 2. [Installation](#2-installation)
9
+ 3. [Quick start](#3-quick-start)
10
+ 4. [Theming](#4-theming)
11
+ 5. [Checkbox](#5-checkbox)
12
+ 6. [DataGrid](#6-datagrid)
13
+ 7. [Demos](#7-demos)
14
+ 8. [Troubleshooting](#9-troubleshooting)
15
+ 9. [Migrating from the earlier version](#10-migrating-from-the-earlier-version)
16
+
17
+ ---
18
+
19
+ ## 1. Overview
20
+
21
+ | Component | Purpose |
22
+ | --- | --- |
23
+ | `DataGrid` | Data table with per-column sorting, search, typed per-column filters, pagination, in-cell editing, date columns, row selection, server-side mode, loading skeletons, resize / pin / hide, keyboard navigation, CSV export, virtualization, nested fields, striping, themes and custom column components |
24
+ | `Checkbox` | Checkbox in three looks (button with a span, tick, native), toggles with one click. The grid uses it for boolean columns and row selection, and you can use it on its own |
25
+
26
+ **Everything extra is opt-in.** A bare `<DataGrid columns={...} rows={...} />` is a plain read-only table. Sorting, the search box, page info, selection, resizing and the rest only appear when you switch them on:
27
+
28
+ | Feature | How you switch it on | Default |
29
+ | --- | --- | --- |
30
+ | Sorting | `sortable: true` **on a column** | off |
31
+ | Multi-column sort | `multiSort` on the grid | off |
32
+ | Global search box | `searchable` | off |
33
+ | Pagination bar (row count, page controls) | `pagination` | off |
34
+ | Column filter | `filterable: true` on a column | off |
35
+ | Editing | `editable` | off |
36
+ | Row selection | `rowSelection` | off |
37
+ | Server-side mode | `serverSide` | off |
38
+ | Resize / pin | `resizable: true` / `pinned` on a column | off |
39
+ | Show / hide columns button | `columnMenu` | off |
40
+ | Arrow-key navigation | `keyboardNavigation` | off |
41
+ | CSV export button | `exportable` | off |
42
+ | Virtualization | `virtualize` | off |
43
+ | Theme | `theme` | none (plain look) |
44
+
45
+ Everything is written in TypeScript, ships its own type definitions, and uses plain CSS (no CSS-in-JS runtime). The grid is 100% of its container's width by default. There is deliberately **no column reordering**.
46
+
47
+ ---
48
+
49
+ ## 2. Installation
50
+
51
+ **Requirements:** React 18 or newer and `react-dom`.
52
+
53
+ ```bash
54
+ npm install react-gridix
55
+ ```
56
+
57
+ The stylesheet is included in the package's `style.css`. Import it once, near the root of your app:
58
+
59
+ ```tsx
60
+ import "react-gridix/style.css";
61
+ ```
62
+
63
+ The library does not inject CSS by itself. If the grid looks unstyled, this import is missing.
64
+
65
+ ---
66
+
67
+ ## 3. Quick start
68
+
69
+ A read-only table, the smallest possible grid:
70
+
71
+ ```tsx
72
+ import { DataGrid, type ColumnDef } from "react-gridix";
73
+ import "react-gridix/style.css";
74
+
75
+ interface Person { id: number; name: string; city: string }
76
+
77
+ const columns: ColumnDef<Person>[] = [
78
+ { field: "id", headerName: "ID" },
79
+ { field: "name", headerName: "Name" },
80
+ { field: "city", headerName: "City" },
81
+ ];
82
+
83
+ const rows: Person[] = [
84
+ { id: 1, name: "Amina", city: "Kampala" },
85
+ { id: 2, name: "Brian", city: "Nairobi" },
86
+ ];
87
+
88
+ export default function App() {
89
+ return <DataGrid columns={columns} rows={rows} />;
90
+ }
91
+ ```
92
+
93
+ The same grid with the common features switched on:
94
+
95
+ ```tsx
96
+ import { useState } from "react";
97
+ import { DataGrid, type ColumnDef } from "react-gridix";
98
+ import "react-gridix/style.css";
99
+
100
+ interface Person {
101
+ id: number;
102
+ name: string;
103
+ joined: string; // "2026-03-14"
104
+ contact: { email: string };
105
+ }
106
+
107
+ const columns: ColumnDef<Person>[] = [
108
+ { field: "id", headerName: "ID", type: "number", editable: false, sortable: true },
109
+ { field: "name", headerName: "Name", sortable: true, filterable: true },
110
+ { field: "joined", headerName: "Joined", type: "date", sortable: true, filterable: true },
111
+ { field: "contact.email", headerName: "Email" },
112
+ ];
113
+
114
+ export default function App() {
115
+ const [rows, setRows] = useState<Person[]>([
116
+ { id: 1, name: "Amina", joined: "2026-03-14", contact: { email: "amina@example.com" } },
117
+ { id: 2, name: "Brian", joined: "2025-11-02", contact: { email: "brian@example.com" } },
118
+ ]);
119
+
120
+ return (
121
+ <DataGrid
122
+ columns={columns}
123
+ rows={rows}
124
+ getRowId={(r) => r.id}
125
+ editable
126
+ onRowsChange={setRows}
127
+ searchable // search box
128
+ pagination // row count and page controls
129
+ rowSelection // checkbox column
130
+ theme="ocean"
131
+ />
132
+ );
133
+ }
134
+ ```
135
+
136
+ ---
137
+
138
+ ## 4. Theming
139
+
140
+ ### 4.1 Built-in themes
141
+
142
+ Pass a theme name to the grid. **Without a `theme` prop nothing changes**: the grid keeps its plain default look.
143
+
144
+ ```tsx
145
+ <DataGrid theme="midnight" ... />
146
+ ```
147
+
148
+ | Theme | Look |
149
+ | --- | --- |
150
+ | `"ocean"` | Light, cool blues, blue gradient header, soft shadow |
151
+ | `"midnight"` | Dark slate, indigo accent, dark popups and inputs |
152
+ | `"sunset"` | Warm cream, orange-to-pink gradient header, serif font, rounded corners |
153
+ | `"forest"` | Calm greens, solid dark-green header, sharper corners |
154
+ | `"lavender"` | Light purple, soft violet gradient header, rounded corners |
155
+ | `"coffee"` | Warm browns and cream, serif font, rich brown header |
156
+ | `"graphite"` | Dark neutral grey with a teal accent, flat and sharp |
157
+ | `"aurora"` | Deep night blue with a green-to-blue-to-violet gradient header and a soft glow |
158
+
159
+ A theme is only a set of CSS variables on `[data-rk-theme="name"]`, written with `:where()` (zero specificity). So you can still override any variable or any class on top of a theme. Popups (the filter popup, the column menu) are drawn in `document.body`, and the grid copies the theme attribute onto them, so they follow the theme too.
160
+
161
+ **Switching themes at run time** is just a state change:
162
+
163
+ ```tsx
164
+ const [theme, setTheme] = useState<GridTheme>("ocean");
165
+
166
+ <select value={theme} onChange={(e) => setTheme(e.target.value as GridTheme)}>
167
+ {["ocean", "midnight", "sunset", "forest", "lavender", "coffee", "graphite", "aurora"].map((t) => (
168
+ <option key={t}>{t}</option>
169
+ ))}
170
+ </select>
171
+ <DataGrid theme={theme} ... />
172
+ ```
173
+
174
+ **Following the system dark mode.** Choose the theme from a media query:
175
+
176
+ ```tsx
177
+ const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
178
+ <DataGrid theme={dark ? "graphite" : "lavender"} ... />
179
+ ```
180
+
181
+ **Tweaking a built-in theme.** Because themes have zero specificity, any variable or rule you write wins over them:
182
+
183
+ ```css
184
+ /* make the aurora header a single colour */
185
+ [data-rk-theme="aurora"] { --rk-header-bg: #0f766e; }
186
+ ```
187
+
188
+ **Making your own theme.** Any other string works as a theme name. Define the variables you want:
189
+
190
+ ```css
191
+ [data-rk-theme="brand"] {
192
+ --rk-header-bg: #7c3aed;
193
+ --rk-header-text: #fff;
194
+ --rk-row-hover: #f5f3ff;
195
+ --rk-selected-bg: #ede9fe; /* selected rows */
196
+ --rk-primary: #7c3aed;
197
+ }
198
+ ```
199
+
200
+ ```tsx
201
+ <DataGrid theme="brand" ... />
202
+ ```
203
+
204
+ A dark custom theme should also set the colours that the light defaults assume, for example `--rk-surface`, `--rk-text`, `--rk-border`, `--rk-pinned-bg`, `--rk-skeleton-a` and `--rk-skeleton-b`. Copy the `graphite` block in `themes.css` as a starting point.
205
+
206
+ ### 4.2 CSS variables
207
+
208
+ Every variable is optional and is read with a fallback, so nothing is declared on the grid itself. Set them on `:root`, on any parent element, in your own theme, or in the grid's `style`.
209
+
210
+ | Variable | Default | Used for |
211
+ | --- | --- | --- |
212
+ | `--rk-font` | system UI stack | Font family |
213
+ | `--rk-text` | `#111827` | Main text |
214
+ | `--rk-muted` | `#6b7280` | Secondary text, empty state |
215
+ | `--rk-border` | `#d1d5db` | Borders |
216
+ | `--rk-surface` | `#ffffff` | Background of the grid, inputs, buttons |
217
+ | `--rk-bg-subtle` | `#f3f4f6` | Header and footer background |
218
+ | `--rk-primary` | `#4f46e5` | Focus rings, active editor border |
219
+ | `--rk-danger` | `#dc2626` | Invalid editor border |
220
+ | `--rk-invalid-bg` | `#fef2f2` | Invalid editor background |
221
+ | `--rk-grid-bg` | `--rk-surface` | Grid background |
222
+ | `--rk-grid-radius` | `10px` | Grid corner radius |
223
+ | `--rk-grid-shadow` | `none` | Grid shadow |
224
+ | `--rk-toolbar-bg` | `transparent` | Toolbar background |
225
+ | `--rk-input-bg`, `--rk-input-border`, `--rk-input-text` | surface, border, inherit | Search box, filter inputs, editor, page controls |
226
+ | `--rk-header-bg` | `--rk-bg-subtle` | Header background. Any CSS background, so gradients work |
227
+ | `--rk-header-text` | inherit | Header text colour |
228
+ | `--rk-header-hover-bg` | `rgba(0,0,0,.06)` | Header hover |
229
+ | `--rk-header-border` | `--rk-border` | Line under the header |
230
+ | `--rk-cell-border` | `#eef0f3` | Lines between rows |
231
+ | `--rk-row-hover` | `#f3f4f6` | Row hover |
232
+ | `--rk-selected-bg` | `#eef2ff` | Background of selected rows |
233
+ | `--rk-pinned-bg` | `--rk-grid-bg` | Opaque background behind pinned body cells (so scrolled cells do not show through) |
234
+ | `--rk-pinned-header-bg` | `--rk-bg-subtle` | Opaque background behind pinned header cells |
235
+ | `--rk-skeleton-a`, `--rk-skeleton-b` | `#e5e7eb`, `#f3f4f6` | The two colours of the shimmering loading bars |
236
+ | `--rk-stripe-a`, `--rk-stripe-b` | `transparent`, `#fafafa` | Alternating row colours (see [stripes](#striped-rows)) |
237
+ | `--rk-footer-bg`, `--rk-footer-text` | subtle, muted | Pagination bar |
238
+ | `--rk-btn-hover` | `#e5e7eb` | Page button, toolbar button and Clear button hover |
239
+ | `--rk-filter-active` | `--rk-primary` | Filter dot colour while a filter is set |
240
+ | `--rk-filter-glow` | `rgba(79,70,229,.25)` | Ring around an active filter dot |
241
+ | `--rk-popup-bg`, `--rk-popup-text`, `--rk-popup-border` | white, dark, grey | Filter popup and column menu |
242
+ | `--rk-popup-radius`, `--rk-popup-shadow` | `10px`, soft shadow | Popup shape |
243
+ | `--rk-row-duration` | `0.38s` | Row entrance animation length |
244
+ | `--rk-row-stagger` | `28ms` | Extra delay per row in the entrance animation |
245
+ | `--rk-checkbox-size` | `20px` | Checkbox width and height |
246
+ | `--rk-checkbox-border-width` | `1px` | Checkbox border width |
247
+ | `--rk-checkbox-radius` | `0` | Checkbox corner radius (no rounding by default) |
248
+ | `--rk-checkbox-color` | `#4f46e5` | Checked colour (also the focus ring and the native accent) |
249
+ | `--rk-checkbox-border` | `#9ca3af` | Border colour of an unchecked `"button"` / `"tick"` checkbox |
250
+ | `--rk-checkbox-bg` | `#ffffff` | Background of an unchecked `"button"` / `"tick"` checkbox |
251
+ | `--rk-checkbox-tick` | `#ffffff` | Colour of the tick, and of a custom character in the `"tick"` variant |
252
+
253
+ ```css
254
+ :root {
255
+ --rk-primary: #0d9488;
256
+ --rk-row-hover: #f0fdfa;
257
+ --rk-selected-bg: #ccfbf1;
258
+ }
259
+ ```
260
+
261
+ Scoped to one grid through its `style` prop:
262
+
263
+ ```tsx
264
+ <DataGrid style={{ "--rk-header-bg": "#0f172a", "--rk-header-text": "#fff" } as React.CSSProperties} ... />
265
+ ```
266
+
267
+ ---
268
+
269
+ ## 5. Checkbox
270
+
271
+ A checkbox in three looks. Every variant toggles with **one click** (or the Space key when it has focus), and clicking the label text toggles it too.
272
+
273
+ ```tsx
274
+ <Checkbox variant="button" checked={a} onChange={setA} label="Button + span" />
275
+ <Checkbox variant="tick" checked={b} onChange={setB} label="Tick" />
276
+ <Checkbox variant="native" checked={c} onChange={setC} label="Native" />
277
+ ```
278
+
279
+ | Variant | What it is |
280
+ | --- | --- |
281
+ | `"button"` | A `<button role="checkbox">` drawn as a box, with a `<span>` inside that is hidden when unchecked and shown when checked |
282
+ | `"tick"` | The same button, but the box fills with the checked colour and the span holds a tick icon |
283
+ | `"native"` | The browser's own `<input type="checkbox">` |
284
+
285
+ | Prop | Type | Default | Description |
286
+ | --- | --- | --- | --- |
287
+ | `checked` | `boolean` | required | Current value (controlled) |
288
+ | `indeterminate` | `boolean` | `false` | Shows the mixed ("some selected") state: a dash instead of a tick. Use it with `checked={false}`. A click then calls `onChange(true)` |
289
+ | `onChange` | `(checked: boolean) => void` | required | Called once per toggle with the new value |
290
+ | `variant` | `"button" \| "tick" \| "native"` | `"native"` | Which look to use |
291
+ | `disabled` | `boolean` | `false` | Disables the checkbox and dims it |
292
+ | `label` | `ReactNode` | | Text next to the box. Clicking it toggles the checkbox. An empty string, `null` or `false` renders no label |
293
+ | `aria-label` | `string` | | Accessible name when there is no visible `label` |
294
+ | `id` | `string` | | `id` of the control |
295
+ | `name` | `string` | | `name` of the input (`"native"` variant only) |
296
+ | `indicator` | `ReactNode` | | A custom character or icon drawn inside the `"button"` and `"tick"` variants when checked, for example `"★"` |
297
+ | `className` | `string` | | Classes for the root element (same as `classNames.root`) |
298
+ | `style` | `CSSProperties` | | Inline styles for the root element (same as `styles.root`) |
299
+ | `classNames` | `Partial<Record<CheckboxSlot, string>>` | | Classes per part |
300
+ | `styles` | `Partial<Record<CheckboxSlot, CSSProperties>>` | | Inline styles per part |
301
+
302
+ **Parts (`CheckboxSlot`):** `root` (the wrapping `<label>`), `control` (the `<button>` or `<input>`), `indicator` (the span inside, button variants only) and `label` (the text).
303
+
304
+ **Customising the look from outside**
305
+
306
+ 1. **Classes and inline styles per part**, including Tailwind:
307
+
308
+ ```tsx
309
+ <Checkbox
310
+ variant="tick"
311
+ checked={on}
312
+ onChange={setOn}
313
+ classNames={{ control: "rounded-full border-emerald-500 aria-checked:bg-emerald-500" }}
314
+ styles={{ root: { gap: 12 } }}
315
+ />
316
+ ```
317
+
318
+ (`aria-checked:` works for the `"button"` and `"tick"` variants. The native input has no `aria-checked`, so use the `--rk-checkbox-color` variable for it.)
319
+ 2. **CSS variables**: `--rk-checkbox-size`, `--rk-checkbox-border-width`, `--rk-checkbox-radius`, `--rk-checkbox-color`, `--rk-checkbox-border`, `--rk-checkbox-bg` and `--rk-checkbox-tick` (see [Theming](#42-css-variables)). Set them anywhere above the checkbox:
320
+
321
+ ```tsx
322
+ <Checkbox styles={{ root: { "--rk-checkbox-color": "#dc2626" } as CSSProperties }} ... />
323
+ ```
324
+ 3. **A custom character**, for example `indicator="★"` (any text, emoji or React node). It shows when the box is checked, in the `"button"` and `"tick"` variants. The default fill is dropped; the character is sized to the box (70% of `--rk-checkbox-size`) and coloured with `--rk-checkbox-color` in the button variant and `--rk-checkbox-tick` in the tick variant. Restyle it through `classNames.indicator` / `styles.indicator`.
325
+ 4. **Plain CSS** with these stable classes: `.rk-checkbox`, `.rk-checkbox--button`, `.rk-checkbox--tick`, `.rk-checkbox--native`, `.rk-checkbox--checked`, `.rk-checkbox--indeterminate`, `.rk-checkbox--disabled`, `.rk-checkbox__control`, `.rk-checkbox__indicator`, `.rk-checkbox__indicator--custom` and `.rk-checkbox__label`. State is also exposed as `data-state="checked"`, `"unchecked"` or `"indeterminate"` and, on the button variants, `aria-checked` (`"true"`, `"false"` or `"mixed"`).
326
+
327
+ **Defaults:** a 1px border and square corners (`--rk-checkbox-border-width: 1px`, `--rk-checkbox-radius: 0`). Round them with `--rk-checkbox-radius: 999px` or a Tailwind `rounded-*` class on `classNames.control`.
328
+
329
+ The checkbox CSS is written with `:where()` (zero specificity), so any class you add wins without `!important`.
330
+
331
+ **Accessibility:** the button variants have `role="checkbox"` and `aria-checked`, and the native variant is a real checkbox. When you don't pass a `label`, pass an `aria-label`.
332
+
333
+ ---
334
+
335
+ ## 6. DataGrid
336
+
337
+ ### 6.1 Concepts
338
+
339
+ ```tsx
340
+ <DataGrid columns={columns} rows={rows} />
341
+ ```
342
+
343
+ - **`rows`** is an array of plain objects.
344
+ - **`columns`** is an array of column definitions. Each one names a `field` (a key of the row object) and describes how to show, sort, filter and edit it.
345
+ - **Row ids.** Pass `getRowId` when your rows have a stable id. Without it the grid uses each row's index in `rows`, which is fine for static data. Selection, server mode and virtualization all work best with real ids.
346
+ - **`rowIndex`** in callbacks and params is always the row's index in your original `rows` array, not its position on screen.
347
+ - **The grid keeps its own copy of the rows** so edits appear immediately. It re-syncs whenever the `rows` prop changes identity. See [section 6.9](#69-editing).
348
+ - **Controlled or uncontrolled.** Search, filters, sort, page and selection each work two ways. Leave the prop out and the grid keeps the state itself (optionally starting from a `default…` value). Pass the prop together with its `on…Change` callback and you own the state. See [6.14](#614-controlled-state).
349
+ - **Opt-in everywhere.** Features are off until you switch them on (see the table in the [overview](#1-overview)).
350
+
351
+ The grid is generic: `DataGrid<T>` infers `T` from `rows`, and `ColumnDef<T>` checks that each `field` is a real key of `T` or a dot path into a nested object (see [nested fields](#612-nested-fields)).
352
+
353
+ ### 6.2 DataGrid props
354
+
355
+ **Data and editing**
356
+
357
+ | Prop | Type | Default | Description |
358
+ | --- | --- | --- | --- |
359
+ | `columns` | `ColumnDef<T>[]` | required | Column definitions |
360
+ | `rows` | `T[]` | required | Data. In `serverSide` mode: the current page |
361
+ | `getRowId` | `(row, index) => string \| number` | row index | Stable id for each row |
362
+ | `editable` | `boolean` | `false` | Allow editing cells. Columns can override this |
363
+ | `enterMovesDown` | `boolean` | `true` | After Enter saves a cell, edit the cell below |
364
+ | `onCellEdit` | `(event: CellEditEvent<T>) => void` | | Called after a cell edit is saved |
365
+ | `onRowsChange` | `(rows: T[]) => void` | | Called with the full updated array after an edit |
366
+ | `onRowClick` | `(row, rowIndex) => void` | | Called when a row is clicked |
367
+
368
+ **Row selection** (see [6.13](#613-row-selection))
369
+
370
+ | Prop | Type | Default | Description |
371
+ | --- | --- | --- | --- |
372
+ | `rowSelection` | `boolean \| "single" \| "multiple"` | `false` | Adds a checkbox column. `true` means `"multiple"` |
373
+ | `selectedRowIds` | `(string \| number)[]` | | Controlled selection |
374
+ | `defaultSelectedRowIds` | `(string \| number)[]` | `[]` | Initial selection when not controlled |
375
+ | `onSelectionChange` | `(selection: RowSelection<T>) => void` | | Called with `{ ids, rows }` |
376
+ | `isRowSelectable` | `(row) => boolean` | all rows | Return `false` to disable a row's checkbox |
377
+
378
+ **Sorting** (see [6.4](#64-sorting))
379
+
380
+ | Prop | Type | Default | Description |
381
+ | --- | --- | --- | --- |
382
+ | `multiSort` | `boolean` | `false` | Shift+click adds more sort columns |
383
+ | `sort` | `SortModel[]` | | Controlled sort |
384
+ | `defaultSort` | `SortModel \| SortModel[] \| null` | `null` | Initial sort when not controlled |
385
+ | `onSortChange` | `(sort: SortModel[]) => void` | | Called when the sort changes |
386
+
387
+ **Search, filters and paging** (see [6.5](#65-searching-and-filtering), [6.7](#67-pagination), [6.14](#614-controlled-state))
388
+
389
+ | Prop | Type | Default | Description |
390
+ | --- | --- | --- | --- |
391
+ | `searchable` | `boolean` | `false` | Show the global search box |
392
+ | `searchPlaceholder` | `string` | `"Search…"` | Placeholder of the search box |
393
+ | `search` / `onSearchChange` | `string` / `(s) => void` | | Controlled search text |
394
+ | `filters` / `onFiltersChange` | `Record<string, FilterValue>` / `(f) => void` | | Controlled column filters |
395
+ | `pagination` | `boolean` | `false` | Split rows into pages and show the bar with the row count and page controls |
396
+ | `paginationPosition` | `"bottom" \| "top" \| "both"` | `"bottom"` | Where the bar goes |
397
+ | `pageSize` | `number` | `10` | Rows per page (initial value) |
398
+ | `pageSizeOptions` | `number[]` | `[5, 10, 25, 50]` | Choices in the page size menu. The current size is added if missing |
399
+ | `page` / `onPageChange` | `number` / `(page) => void` | | Controlled page (zero-based) |
400
+ | `onPageSizeChange` | `(pageSize) => void` | | Called when the page size menu changes |
401
+
402
+ **Server-side mode, loading and errors** (see [6.15](#615-server-side-mode), [6.16](#616-loading-skeleton))
403
+
404
+ | Prop | Type | Default | Description |
405
+ | --- | --- | --- | --- |
406
+ | `serverSide` | `boolean` | `false` | Your server sorts, filters, searches and pages |
407
+ | `rowCount` | `number` | | Total rows on the server (for the page count) |
408
+ | `onQueryChange` | `(query: GridQuery) => void \| Promise<unknown>` | | Called on mount and whenever the query changes. A returned promise drives the loading state |
409
+ | `queryDebounce` | `number` | `250` | Milliseconds to wait after the last keystroke before querying |
410
+ | `loading` | `boolean` | follows the promise | Force the loading state on or off |
411
+ | `loadingRows` | `number` | page size (max 20), or 5 | How many skeleton rows to draw |
412
+ | `skeletonRow` | `ComponentType<SkeletonRowProps<T>>` | shimmer bars | Your own component for each placeholder row |
413
+ | `errorMessage` | `ReactNode` | `"Could not load the data."` | Shown with a Retry button when the promise rejects |
414
+
415
+ **Columns, keyboard, big data and export**
416
+
417
+ | Prop | Type | Default | Description |
418
+ | --- | --- | --- | --- |
419
+ | `columnMenu` | `boolean` | `false` | A "Columns" button to show and hide columns |
420
+ | `onColumnVisibilityChange` | `(hiddenFields: string[]) => void` | | Called with the fields that are hidden |
421
+ | `keyboardNavigation` | `boolean` | `false` | Arrow keys, Home, End, Page Up and Page Down move between cells |
422
+ | `virtualize` | `boolean` | `false` | Render only the visible rows |
423
+ | `rowHeight` | `number` | `40` | Row height in px when virtualizing |
424
+ | `overscan` | `number` | `6` | Extra rows drawn above and below the window |
425
+ | `exportable` | `boolean \| ExportOptions` | `false` | Show an "Export CSV" button |
426
+ | `apiRef` | `MutableRefObject<DataGridApi<T> \| null>` | | Call `exportCsv()`, `getSelectedRows()` and more from outside |
427
+
428
+ **Look and feel**
429
+
430
+ | Prop | Type | Default | Description |
431
+ | --- | --- | --- | --- |
432
+ | `theme` | `GridTheme \| string` | | Applies a theme. Nothing changes without it. See [Theming](#4-theming) |
433
+ | `toolbar` | `ReactNode` | | Extra content on the right of the toolbar |
434
+ | `emptyMessage` | `ReactNode` | `"No rows to show"` | Shown when no rows match |
435
+ | `striped` | `boolean` | `false` | Alternate row shading |
436
+ | `stripeColors` | `[string, string]` | | Two colours for the alternating rows. Implies `striped`. See [striped rows](#striped-rows) |
437
+ | `animateRows` | `boolean` | `true` | Rows slide up and fade in when they render. See [row animation](#row-animation) |
438
+ | `checkboxVariant` | `"button" \| "tick" \| "native"` | `"native"` | Checkbox look for boolean columns and row selection. Columns can override it |
439
+ | `maxHeight` | `number \| string` | | Makes the body scroll, with a sticky header. Example: `400` or `"60vh"` |
440
+ | `className` | `string` | | Classes for the root element (Tailwind works). The grid is `width: 100%` of its container by default |
441
+ | `style` | `CSSProperties` | | Inline styles for the root element |
442
+ | `classNames` | `Partial<Record<GridSlot, string>>` | | Classes for individual parts. See [6.11](#611-styling) |
443
+ | `styles` | `Partial<Record<GridSlot, CSSProperties>>` | | Inline styles for individual parts |
444
+ | `rowClassName` | `string \| (row, rowIndex) => string` | | Classes for rows |
445
+ | `rowStyle` | `CSSProperties \| (row, rowIndex) => CSSProperties` | | Inline styles for rows |
446
+
447
+ ### 6.3 Column definitions
448
+
449
+ ```ts
450
+ const columns: ColumnDef<Employee>[] = [
451
+ { field: "name", headerName: "Name", sortable: true, filterable: true },
452
+ { field: "salary", headerName: "Salary", type: "number", align: "right", sortable: true },
453
+ { field: "hired", headerName: "Hired", type: "date", sortable: true, filterable: true },
454
+ ];
455
+ ```
456
+
457
+ | Option | Type | Description |
458
+ | --- | --- | --- |
459
+ | `field` | `FieldPath<T>` | **Required.** Key of the row object this column reads and writes. Use dot notation for nested objects: `"contact.phone"` |
460
+ | `headerName` | `string` | Header text. Defaults to the field name, except in `type: "boolean"` columns, where it defaults to an empty string |
461
+ | `type` | `"text" \| "number" \| "boolean" \| "date"` | `"number"` columns edit and parse as numbers, `"boolean"` columns show a checkbox, `"date"` columns have a date picker (see [6.6](#66-date-columns)). Default `"text"` |
462
+ | `width` | `number \| string` | Header cell width |
463
+ | `minWidth` / `maxWidth` | `number \| string` / `number` | Smallest and largest width (`maxWidth` limits dragging when resizing) |
464
+ | `align` | `"left" \| "center" \| "right"` | Text alignment for the header, cells and editor |
465
+ | `sortable` | `boolean` | **Opt in to sorting this column** (default `false`) |
466
+ | `resizable` | `boolean` | Show a drag handle on the header's right edge (default `false`). See [6.17](#617-resize-pin-and-hide-columns) |
467
+ | `pinned` | `"left" \| "right"` | Stick the column to an edge while scrolling sideways |
468
+ | `hidden` | `boolean` | Start hidden (shown again from the Columns menu) |
469
+ | `hideable` | `boolean` | Set `false` to keep a column out of the Columns menu (default `true`) |
470
+ | `filterable` | `boolean` | Show a filter indicator in this column's header (default `false`). Clicking it opens the filter popup. See [6.5](#65-searching-and-filtering) |
471
+ | `filterType` | `"text" \| "exact" \| "number" \| "date" \| "select"` | Which filter controls the popup shows. Default follows `type` |
472
+ | `filterOptions` | `(string \| FilterOption)[]` | Choices for a `"select"` filter. Without it they are built from the data |
473
+ | `filterPlaceholder` | `string` | Placeholder of the text filter box |
474
+ | `filterIndicator` | `ComponentType<FilterIndicatorProps<T>>` | Your own component drawn instead of the default dot |
475
+ | `filterIndicatorClassName` / `filterIndicatorStyle` | `string` / `CSSProperties` | Classes and inline styles for this column's indicator button |
476
+ | `filterPopupClassName` / `filterPopupStyle` | `string` / `CSSProperties` | Classes and inline styles for this column's filter popup |
477
+ | `filterRenderer` | `ComponentType<FilterRendererProps<T>>` | Component that replaces the filter controls inside the popup |
478
+ | `editable` | `boolean \| (params: CellEditableParams<T>) => boolean` | Overrides the grid-level `editable`. A function decides per cell |
479
+ | `valueFormatter` | `(value, row) => string` | Text used for display, search and text filters. Does not change the stored value |
480
+ | `valueParser` | `(text, row) => unknown` | Turns typed text into the value to store |
481
+ | `validate` | `(value, row) => boolean` | Return `false` to reject an edit |
482
+ | `comparator` | `(a, b, rowA, rowB) => number` | Custom sort order |
483
+ | `dateFormat` / `dateLocale` | `Intl.DateTimeFormatOptions` / `string` | How a `"date"` column is displayed |
484
+ | `dateMin` / `dateMax` | `string` (`"YYYY-MM-DD"`) | Earliest and latest day the date picker accepts |
485
+ | `exportValue` | `(value, row) => string \| number \| boolean \| null` | What goes in the CSV file for this column |
486
+ | `cellRenderer` | `ComponentType<CellParams<T>>` | Component that renders the cell |
487
+ | `cellEditor` | `ComponentType<CellEditorProps<T>>` | Component used instead of the built-in editor |
488
+ | `headerRenderer` | `ComponentType<HeaderRendererProps<T>>` | Component that renders the header label |
489
+ | `className` / `style` | `string` / `CSSProperties` | Classes and inline styles for the whole column: header cell and every body cell (Tailwind works) |
490
+ | `headerClassName` / `headerStyle` | `string` / `CSSProperties` | Header cell only |
491
+ | `cellClassName` / `cellStyle` | `string \| (params) => string` / `CSSProperties \| (params) => CSSProperties` | Body cells only |
492
+ | `checkbox` | `ColumnCheckboxOptions` | Look of the checkbox in a `type: "boolean"` column: `variant`, `indicator`, `className`, `style`, `classNames`, `styles` |
493
+ | `headerCheckbox` | `boolean` | On a `type: "boolean"` column, draws a select-all checkbox in the header. See [Select-all header checkbox](#select-all-header-checkbox) |
494
+
495
+ ### 6.4 Sorting
496
+
497
+ **Sorting is chosen per column.** A column is sortable only when its definition says `sortable: true`. There is no grid-level switch.
498
+
499
+ ```tsx
500
+ const columns: ColumnDef<Employee>[] = [
501
+ { field: "id", type: "number" }, // not sortable
502
+ { field: "name", sortable: true }, // click to sort
503
+ { field: "salary", type: "number", sortable: true },
504
+ ];
505
+ ```
506
+
507
+ - Click a header to sort ascending, click again for descending, click a third time to clear the sort.
508
+ - Numbers sort numerically, dates by day, booleans false before true. Everything else sorts as text with natural number ordering (`"item 2"` before `"item 10"`), ignoring case.
509
+ - Empty values (`null`, `undefined`, `""`) always sort last, in both directions.
510
+ - Headers are keyboard accessible (Tab to a header, Enter or Space to sort) and expose `aria-sort`. A column that is not sortable gets no sort arrow, no pointer cursor and no focus stop.
511
+
512
+ **Custom order** with a `comparator`:
513
+
514
+ ```tsx
515
+ const order = ["low", "medium", "high", "urgent"];
516
+ { field: "priority", sortable: true, comparator: (a, b) => order.indexOf(a) - order.indexOf(b) }
517
+ ```
518
+
519
+ **Starting sorted** with `defaultSort`:
520
+
521
+ ```tsx
522
+ <DataGrid defaultSort={{ field: "salary", direction: "desc" }} ... />
523
+ ```
524
+
525
+ **Multi-column sort.** Switch on `multiSort`. A plain click still replaces the sort with that one column. **Shift+click** adds the column as the next tie-breaker (or cycles its direction if it is already in the sort). Small numbers in the headers show the priority.
526
+
527
+ ```tsx
528
+ <DataGrid
529
+ multiSort
530
+ defaultSort={[
531
+ { field: "department", direction: "asc" },
532
+ { field: "salary", direction: "desc" }, // inside each department, highest salary first
533
+ ]}
534
+ columns={columns}
535
+ rows={rows}
536
+ />
537
+ ```
538
+
539
+ **Reacting to the sort** (always an array, empty when unsorted):
540
+
541
+ ```tsx
542
+ <DataGrid
543
+ onSortChange={(sort) => console.log(sort)}
544
+ // [{ field: "department", direction: "asc" }, { field: "salary", direction: "desc" }]
545
+ ...
546
+ />
547
+ ```
548
+
549
+ **Controlling the sort yourself** (also see [6.14](#614-controlled-state)):
550
+
551
+ ```tsx
552
+ const [sort, setSort] = useState<SortModel[]>([{ field: "name", direction: "asc" }]);
553
+
554
+ <button onClick={() => setSort([])}>Reset sort</button>
555
+ <DataGrid sort={sort} onSortChange={setSort} ... />
556
+ ```
557
+
558
+ ### 6.5 Searching and filtering
559
+
560
+ **Global search.** Off by default. Switch it on with `searchable` and a box appears above the grid. It matches rows where any column's displayed text contains the typed text (case-insensitive). "Displayed text" means the result of `valueFormatter` if there is one.
561
+
562
+ ```tsx
563
+ <DataGrid searchable searchPlaceholder="Search people…" ... />
564
+ ```
565
+
566
+ **Column filters.** Filters are set per column. Only columns with `filterable: true` get one:
567
+
568
+ ```tsx
569
+ const columns: ColumnDef<Person>[] = [
570
+ { field: "name", filterable: true },
571
+ { field: "department", filterable: true, filterPlaceholder: "Which team?" },
572
+ { field: "salary" }, // no filter
573
+ ];
574
+ ```
575
+
576
+ - A filterable column shows a small **indicator** in its header, next to the label. By default it is a dot. It is dim while no filter is set, and lit (with a glow) while one is active.
577
+ - **Clicking the indicator** opens a popup right below it. The popup is not there until you click. It closes on Escape, on a click outside, or on Enter in a text box. Clicking the indicator does not sort the column.
578
+ - The popup shows a **Clear** button while a filter is set.
579
+ - All filters and the global search combine (a row must pass every one).
580
+ - The popup is drawn in `document.body` and positioned next to the indicator, so a scrolling grid never clips it. It follows scrolling and resizing, and stays inside the window.
581
+ - There is no grid-level filter prop to turn filters on: a column without `filterable: true` has no filter, and a filter value is ignored if its column stops being filterable.
582
+
583
+ #### Filter types
584
+
585
+ Each column's filter has a kind. By default it follows the column `type`; set `filterType` to choose another.
586
+
587
+ | `filterType` | Default for | Popup shows | Rows pass when |
588
+ | --- | --- | --- | --- |
589
+ | `"text"` | `type: "text"` | One text box | The displayed text **contains** the typed text (case-insensitive) |
590
+ | `"exact"` | | One text box | The displayed text **equals** the typed text (case-insensitive) |
591
+ | `"number"` | `type: "number"` | Min and Max boxes | The number is between Min and Max (either may be left empty). Rows without a number never match |
592
+ | `"date"` | `type: "date"` | From and To date pickers | The day is between From and To (either may be left empty) |
593
+ | `"select"` | `type: "boolean"` | A checkbox per value | The raw value is one of the ticked values |
594
+
595
+ ```tsx
596
+ const columns: ColumnDef<Task>[] = [
597
+ // "contains": typing "log" finds "Fix login" and "Catalogue"
598
+ { field: "title", filterable: true },
599
+
600
+ // "exact": typing "active" finds "active" but NOT "inactive"
601
+ { field: "status", filterable: true, filterType: "exact" },
602
+
603
+ // number range: type: "number" gives a Min / Max popup by default
604
+ { field: "budget", type: "number", filterable: true },
605
+
606
+ // date range: type: "date" gives From / To date pickers by default
607
+ { field: "due", type: "date", filterable: true },
608
+
609
+ // choose from a list: values are collected from the data...
610
+ { field: "owner", filterable: true, filterType: "select" },
611
+
612
+ // ...or you decide the list and the labels
613
+ {
614
+ field: "priority",
615
+ filterable: true,
616
+ filterType: "select",
617
+ filterOptions: [
618
+ { value: "p1", label: "Urgent" },
619
+ { value: "p2", label: "Normal" },
620
+ "p3", // a plain string is its own label
621
+ ],
622
+ },
623
+ ];
624
+ ```
625
+
626
+ Notes on `select`: when `filterOptions` is left out, the choices are the distinct values in the data, sorted. A boolean column gets "Yes" and "No". Ticking several values combines them with OR.
627
+
628
+ #### Reading and setting filters
629
+
630
+ The shape of a filter value depends on its kind:
631
+
632
+ ```ts
633
+ // text / exact: "sales"
634
+ // number: { min: 1000, max: 5000 } (either key may be missing)
635
+ // date: { from: "2026-01-01", to: "2026-03-31" }
636
+ // select: ["active", "pending"]
637
+ ```
638
+
639
+ Listen with `onFiltersChange`, or control the filters completely:
640
+
641
+ ```tsx
642
+ const [filters, setFilters] = useState<Record<string, FilterValue>>({
643
+ status: ["open", "blocked"], // select
644
+ budget: { min: 1000 }, // number
645
+ due: { from: "2026-06-01" }, // date
646
+ });
647
+
648
+ <button onClick={() => setFilters({})}>Clear all filters</button>
649
+ <DataGrid filters={filters} onFiltersChange={setFilters} ... />
650
+ ```
651
+
652
+ #### Styling each indicator
653
+
654
+ Every column has its own options:
655
+
656
+ ```tsx
657
+ {
658
+ field: "status",
659
+ filterable: true,
660
+ filterIndicatorClassName: "text-amber-300", // Tailwind on this indicator only
661
+ filterIndicatorStyle: { color: "#f59e0b" }, // or inline styles
662
+ filterPopupStyle: { borderColor: "#f59e0b" }, // style this column's popup
663
+ }
664
+ ```
665
+
666
+ The dot takes its colour from the button's text colour, so `color` (or `text-*`) restyles it. The colour while a filter is active comes from `--rk-filter-active`. To style all indicators or popups at once, use `classNames.filterIndicator`, `classNames.filterPopup`, `classNames.filterInput` (or the matching `styles`).
667
+
668
+ #### A custom indicator component
669
+
670
+ Replace the dot with any component. It receives `{ column, active, open }`:
671
+
672
+ ```tsx
673
+ function FunnelIndicator({ active }: FilterIndicatorProps<Employee>) {
674
+ return (
675
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
676
+ <path d="M1.5 2h13l-5 6v5l-3 1.5V8z" fill={active ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5" />
677
+ </svg>
678
+ );
679
+ }
680
+
681
+ { field: "department", filterable: true, filterIndicator: FunnelIndicator }
682
+ ```
683
+
684
+ The component is drawn inside the indicator `<button>`, so do not put another button in it.
685
+
686
+ #### A custom filter control
687
+
688
+ `filterRenderer` replaces **all** the built-in controls inside the popup, whatever the filter type (see [filterRenderer](#filterrenderer-custom-filter-control)). A custom control produces a text filter, so it works with the "contains" matching.
689
+
690
+ #### Notes
691
+
692
+ - Searching and text filters use the displayed text, so a salary shown as `$52,000` is found by `52,000` and not by `52000` unless your formatter says so. Number, date and select filters look at the raw value instead, so formatting never gets in their way.
693
+ - Changing the search, a filter, the sort or the page size sends the grid back to page 1.
694
+ - In [server-side mode](#615-server-side-mode) the filters are not applied by the grid: they are handed to your server in the query.
695
+
696
+ ### 6.6 Date columns
697
+
698
+ `type: "date"` is a built-in column type with formatting, a date picker editor, chronological sorting and a From / To filter.
699
+
700
+ ```tsx
701
+ interface Task { id: number; title: string; due: string; created: Date }
702
+
703
+ const columns: ColumnDef<Task>[] = [
704
+ { field: "title", editable: true },
705
+ {
706
+ field: "due",
707
+ type: "date",
708
+ sortable: true,
709
+ filterable: true,
710
+ editable: true,
711
+ dateMin: "2026-01-01", // the picker will not accept earlier days
712
+ dateMax: "2027-12-31",
713
+ },
714
+ ];
715
+
716
+ <DataGrid columns={columns} rows={tasks} editable onRowsChange={setTasks} />
717
+ ```
718
+
719
+ **What a row may hold.** The cell understands `"2026-11-05"`, a full ISO string (`"2026-11-05T10:30:00Z"`, whose date part is used as written), a `Date` object, a timestamp number, or any string `Date.parse` understands. Empty values show blank and sort last. Text that is not a date is shown as it is.
720
+
721
+ **Editing.** Double-click a date cell (or press Enter / F2) to open the browser's date picker. Typing characters does not start an editor on a date cell, because the picker is its own control. After picking or typing a valid date, Enter or a click elsewhere saves it. An empty picker saves `null`. Escape cancels. A half-typed date is ignored when you click away.
722
+
723
+ **The saved value keeps the shape the row already had.**
724
+
725
+ | Row held | Saved value |
726
+ | --- | --- |
727
+ | `"2026-03-14"` (text) | `"2026-03-14"` |
728
+ | a `Date` object | a `Date` at local midnight |
729
+ | a timestamp number | a timestamp at local midnight |
730
+ | empty | the same shape as the other rows in the column (the ISO text when there are none) |
731
+
732
+ Dates are handled in the **user's local time zone**, so typing 5 November never turns into 4 November because of UTC.
733
+
734
+ **Display.** The default is the browser's medium date format (`Nov 5, 2026`). Change it with `dateLocale` and `dateFormat` (any `Intl.DateTimeFormat` options):
735
+
736
+ ```tsx
737
+ { field: "created", type: "date", dateLocale: "en-GB", dateFormat: { dateStyle: "long" } } // 5 November 2026
738
+ { field: "created", type: "date", dateLocale: "de-DE", dateFormat: { day: "2-digit", month: "2-digit", year: "numeric" } } // 05.11.2026
739
+ ```
740
+
741
+ A `valueFormatter` still wins over these two if you set one.
742
+
743
+ **Sorting and filtering.** Dates sort by day (earliest first, empties last). The filter popup has From and To pickers; either may be empty for an open-ended range:
744
+
745
+ ```tsx
746
+ <DataGrid filters={{ due: { from: "2026-03-01", to: "2026-06-30" } }} onFiltersChange={setFilters} ... />
747
+ ```
748
+
749
+ **CSV export** writes dates as `"YYYY-MM-DD"` unless you give `exportValue`.
750
+
751
+ ### 6.7 Pagination
752
+
753
+ Pagination is off by default. Switch it on to split the rows into pages and show the bar with the row range ("11–20 of 200"), a rows-per-page menu, first / previous / next / last buttons and "Page x of y". **The bar (and its page information) only appears when `pagination` is passed.**
754
+
755
+ ```tsx
756
+ <DataGrid pagination pageSize={20} pageSizeOptions={[10, 20, 50, 100]} paginationPosition="both" ... />
757
+ ```
758
+
759
+ | `paginationPosition` | Result |
760
+ | --- | --- |
761
+ | `"bottom"` | Bar under the table (default) |
762
+ | `"top"` | Bar above the table, below the toolbar |
763
+ | `"both"` | Both bars, kept in sync |
764
+
765
+ Without `pagination` every row is rendered and there is no footer at all. Combine it with `maxHeight` for a scrolling grid with a sticky header:
766
+
767
+ ```tsx
768
+ <DataGrid maxHeight={400} ... />
769
+ ```
770
+
771
+ `pageSize` is only read when the grid first mounts. The user's choice in the menu is then kept inside the grid. To own the page, see [6.14](#614-controlled-state).
772
+
773
+ **Reacting to paging:**
774
+
775
+ ```tsx
776
+ <DataGrid
777
+ pagination
778
+ onPageChange={(page) => console.log("page", page)} // zero-based
779
+ onPageSizeChange={(size) => localStorage.setItem("size", String(size))}
780
+ pageSize={Number(localStorage.getItem("size")) || 10}
781
+ ...
782
+ />
783
+ ```
784
+
785
+ ### 6.8 Row click, toolbar and empty state
786
+
787
+ ```tsx
788
+ <DataGrid
789
+ onRowClick={(row, rowIndex) => navigate(`/people/${row.id}`)}
790
+ toolbar={<button onClick={openNewPersonDialog}>Add person</button>}
791
+ emptyMessage="Nothing matches your search"
792
+ striped
793
+ />
794
+ ```
795
+
796
+ The toolbar row is drawn when at least one of `searchable`, `columnMenu`, `exportable` or `toolbar` is used. Your `toolbar` content sits on the right, next to the built-in buttons. To style a toolbar button the same way as the built-in ones, give it the class `rk-grid__tool-btn`:
797
+
798
+ ```tsx
799
+ toolbar={<button className="rk-grid__tool-btn" onClick={refetch}>Reload</button>}
800
+ ```
801
+
802
+ ### 6.9 Editing
803
+
804
+ Turn editing on with `editable` on the grid, then lock or unlock columns:
805
+
806
+ ```tsx
807
+ <DataGrid editable columns={[
808
+ { field: "id", editable: false }, // locked column
809
+ { field: "name" }, // editable (grid default)
810
+ { field: "status", editable: ({ row }) => row.department !== "HR" } // decided per cell
811
+ ]} />
812
+ ```
813
+
814
+ A column can also be the only editable one: leave the grid's `editable` off and set `editable: true` on that column.
815
+
816
+ **Starting an edit**
817
+
818
+ | Action | Result |
819
+ | --- | --- |
820
+ | Double-click a cell | Opens the editor, cursor at the end |
821
+ | Select a cell (click or Tab) and press **Enter** or **F2** | Opens the editor, cursor at the end |
822
+ | Select a cell and type a character | Opens the editor with that character replacing the old value |
823
+
824
+ Typing to start does not apply to columns with a custom `cellEditor`. Those open with double-click, Enter or F2.
825
+
826
+ **While editing**
827
+
828
+ | Key | Result |
829
+ | --- | --- |
830
+ | **Enter** | Save, then edit the cell below (the text is pre-selected so typing replaces it) |
831
+ | **Shift+Enter** | Save, then edit the cell above |
832
+ | **Tab** / **Shift+Tab** | Save, then edit the next / previous editable cell, wrapping across rows |
833
+ | **Esc** | Cancel without saving |
834
+ | Click elsewhere | Save |
835
+
836
+ Details:
837
+
838
+ - When there is no editable cell in that direction (for example Enter on the last row of the page), the value is saved and the editor closes. Focus returns to the cell.
839
+ - Set `enterMovesDown={false}` to make Enter save and stay put.
840
+ - Cells locked by `editable` are skipped when moving.
841
+ - If sorting, filtering or paging changes the position of rows while you move between cells, editing follows the row, not the screen position.
842
+
843
+ **How the typed text becomes a value**
844
+
845
+ 1. If the column has a `valueParser`, its return value is stored.
846
+ 2. Otherwise, in a `type: "number"` column, an empty text stores `null`, a valid number stores that number, and anything else is rejected.
847
+ 3. Otherwise the text is stored as a string.
848
+
849
+ **Validation.** After parsing, `validate(value, row)` is called. If it returns `false` (or a number could not be parsed), the value is not saved, the editor stays open, and it gets the `rk-grid__editor--invalid` class and `aria-invalid="true"`. Pressing Esc or clicking away discards the invalid text.
850
+
851
+ ```tsx
852
+ { field: "email", validate: (v) => /^\S+@\S+\.\S+$/.test(String(v)) }
853
+ ```
854
+
855
+ A value that equals the old value is treated as "no change" and fires no events.
856
+
857
+ **Events.** When a value is saved the grid calls, in this order:
858
+
859
+ ```ts
860
+ onCellEdit({ rowId, rowIndex, field, oldValue, newValue, row }) // row is the updated row
861
+ onRowsChange(nextRows) // the whole updated array
862
+ ```
863
+
864
+ **Keeping your data in sync.** The simplest pattern is state plus `onRowsChange`:
865
+
866
+ ```tsx
867
+ const [rows, setRows] = useState(initialRows);
868
+ <DataGrid rows={rows} onRowsChange={setRows} editable />
869
+ ```
870
+
871
+ Important: the grid copies `rows` into its own state and re-syncs whenever the `rows` prop *changes identity*. If your parent re-creates the array on every render (for example `rows={data.map(...)}` written inline), the grid resets to it each time and edits are lost. Keep `rows` in state or memoize it with `useMemo`.
872
+
873
+ **Saving to a server:**
874
+
875
+ ```tsx
876
+ <DataGrid
877
+ rows={rows}
878
+ onRowsChange={setRows}
879
+ onCellEdit={async (e) => {
880
+ await fetch(`/api/people/${e.rowId}`, {
881
+ method: "PATCH",
882
+ headers: { "Content-Type": "application/json" },
883
+ body: JSON.stringify({ [e.field]: e.newValue }),
884
+ });
885
+ }}
886
+ />
887
+ ```
888
+
889
+ ### 6.10 Custom components per column
890
+
891
+ Four hooks let you replace parts of a column with your own React components. They are rendered as real components, so they can use hooks and state.
892
+
893
+ > **Define these components at module level**, not inline inside your render function. An inline component gets a new identity on every render, so React unmounts and remounts it each time (inputs lose focus, state resets).
894
+
895
+ #### `cellRenderer`: custom cell content
896
+
897
+ Receives `CellParams<T>`:
898
+
899
+ | Prop | Description |
900
+ | --- | --- |
901
+ | `value` | The raw value from the row |
902
+ | `formattedValue` | The value as text, after `valueFormatter` |
903
+ | `row` | The whole row |
904
+ | `rowIndex` | Index of the row in `rows` |
905
+ | `field` | The column's field |
906
+ | `column` | The column definition |
907
+ | `editable` | `true` if the cell may be changed (the column's `editable`, or the grid's) |
908
+ | `updateValue(value)` | Saves a new value for this cell right away, without opening the editor. It runs `validate` and fires `onCellEdit` / `onRowsChange`. Returns `false` if the value was rejected or the cell is not editable |
909
+
910
+ ```tsx
911
+ function StatusCell({ value }: CellParams<Employee>) {
912
+ return <span className={value === "active" ? "text-green-700" : "text-gray-500"}>{String(value)}</span>;
913
+ }
914
+
915
+ { field: "status", cellRenderer: StatusCell }
916
+ ```
917
+
918
+ Searching, filtering and sorting still use the underlying value and `valueFormatter`, not what your component draws.
919
+
920
+ #### `cellEditor`: custom editor
921
+
922
+ Replaces the text box when the cell is being edited. Receives `CellEditorProps<T>`:
923
+
924
+ | Prop | Description |
925
+ | --- | --- |
926
+ | `value` | The current draft value. Starts as the cell's raw value |
927
+ | `row`, `rowIndex`, `field`, `column` | Same as in `CellParams` |
928
+ | `onChange(value)` | Update the draft without saving |
929
+ | `onCommit(value?, move?)` | Save and close. Pass a value to save it, or nothing to save the latest draft. `move` can be `"down" \| "up" \| "left" \| "right" \| "none"` (default `"none"`) and also starts editing the neighbouring cell |
930
+ | `onCancel()` | Close without saving |
931
+
932
+ Rules your editor should follow:
933
+
934
+ - Focus something when it mounts (`autoFocus` or a ref) so the keyboard works straight away.
935
+ - Esc is handled by the grid. Clicking or tabbing out of the editor saves the current draft.
936
+ - A value passed to `onCommit` is stored as is. `valueParser` and the `type: "number"` conversion are not applied, but `validate` still is.
937
+
938
+ ```tsx
939
+ function StatusEditor({ value, onCommit }: CellEditorProps<Employee>) {
940
+ return (
941
+ <select autoFocus value={String(value)} onChange={(e) => onCommit(e.target.value)}>
942
+ <option>active</option>
943
+ <option>pending</option>
944
+ <option>inactive</option>
945
+ </select>
946
+ );
947
+ }
948
+
949
+ { field: "status", cellEditor: StatusEditor }
950
+ ```
951
+
952
+ An editor with its own keyboard handling, saving and moving down on Enter:
953
+
954
+ ```tsx
955
+ function SliderEditor({ value, onChange, onCommit }: CellEditorProps<Employee>) {
956
+ return (
957
+ <input
958
+ autoFocus
959
+ type="range"
960
+ value={Number(value) || 0}
961
+ onChange={(e) => onChange(Number(e.target.value))}
962
+ onKeyDown={(e) => e.key === "Enter" && onCommit(undefined, "down")}
963
+ />
964
+ );
965
+ }
966
+ ```
967
+
968
+ #### `headerRenderer`: custom header label
969
+
970
+ Receives `{ column, sortDirection }`. The filter indicator (if `filterable`) and the sort arrow are still added next to it. The whole header cell is clickable for sorting, so call `event.stopPropagation()` on any interactive element you put inside.
971
+
972
+ ```tsx
973
+ function SalaryHeader({ column }: HeaderRendererProps<Employee>) {
974
+ return <span>{column.headerName} <small>USD</small></span>;
975
+ }
976
+ ```
977
+
978
+ #### `filterRenderer`: custom filter control
979
+
980
+ Shown inside the filter popup instead of the text box (the column still needs `filterable: true`). Receives `{ column, value, onChange }`, where `value` is the filter text. Whatever string you pass to `onChange` is matched with "contains" against the displayed text.
981
+
982
+ ```tsx
983
+ function DepartmentFilter({ value, onChange }: FilterRendererProps<Employee>) {
984
+ return (
985
+ <select value={value} onChange={(e) => onChange(e.target.value)}>
986
+ <option value="">All</option>
987
+ <option>Engineering</option>
988
+ <option>Design</option>
989
+ </select>
990
+ );
991
+ }
992
+ ```
993
+
994
+ #### Checkbox columns (`type: "boolean"`)
995
+
996
+ Set `type: "boolean"` on a column and each cell shows a [Checkbox](#5-checkbox). One click saves the new `true` / `false` value.
997
+
998
+ ```tsx
999
+ const columns: ColumnDef<Task>[] = [
1000
+ { field: "title", headerName: "Task" },
1001
+ { field: "done", type: "boolean", align: "center", checkbox: { variant: "button" } }, // no headerName: the header is empty
1002
+ { field: "starred", headerName: "Starred", type: "boolean", checkbox: { variant: "button", indicator: "★" } }, // custom character
1003
+ { field: "urgent", headerName: "Urgent", type: "boolean", align: "center", checkbox: { variant: "tick" } },
1004
+ { field: "archived", headerName: "Archived", type: "boolean", align: "center" }, // grid default: native
1005
+ { field: "approved", headerName: "Approved", type: "boolean", editable: false }, // shown disabled
1006
+ ];
1007
+
1008
+ <DataGrid columns={columns} rows={rows} editable onRowsChange={setRows} checkboxVariant="native" />
1009
+ ```
1010
+
1011
+ How it behaves:
1012
+
1013
+ - **One click saves.** The change goes through the same path as a typed edit: `validate` runs, then `onCellEdit` and `onRowsChange` fire. Space toggles a focused checkbox, and Tab reaches them.
1014
+ - **Who can click.** A checkbox is active only where the cell is editable: grid `editable`, overridden per column by `editable: true | false` or a function. Otherwise it is shown disabled.
1015
+ - **No text editor.** Boolean cells never open the text editor, and Enter and Tab navigation skip them. A click on the checkbox does not trigger `onRowClick`.
1016
+ - **Sorting, search and filters.** Unchecked sorts before checked. Search and filters match the text `Yes` / `No`, or whatever your `valueFormatter` returns. A missing value (`null`, `undefined`) shows unchecked.
1017
+ - **Header label.** The header of a boolean column is empty unless you set `headerName`. Screen readers still get the field name.
1018
+ - **Custom character.** `checkbox: { indicator: "★" }` draws that character instead of the default fill or tick when checked. Colour it with `--rk-checkbox-color` (button variant) or `--rk-checkbox-tick` (tick variant), for example through `checkbox.styles.root`.
1019
+ - **Defaults.** 1px border, no rounded corners. See [Checkbox](#5-checkbox).
1020
+ - **A column with its own `cellEditor`** is treated as a normal editable column instead.
1021
+
1022
+ **Choosing and styling the checkbox**
1023
+
1024
+ | Where | How |
1025
+ | --- | --- |
1026
+ | Whole grid | `checkboxVariant="tick"` |
1027
+ | One column | `checkbox: { variant: "tick" }` |
1028
+ | Whole grid, classes or inline styles | `classNames` / `styles` slots `checkbox`, `checkboxControl`, `checkboxIndicator` |
1029
+ | One column, classes or inline styles | `checkbox: { className, style, classNames, styles, indicator }` (same parts as the standalone `Checkbox`) |
1030
+ | Colours and size | CSS variables such as `--rk-checkbox-color`, set on the grid's `className` / `style`, or per column in `checkbox.styles.root` |
1031
+
1032
+ ```tsx
1033
+ <DataGrid
1034
+ checkboxVariant="tick"
1035
+ classNames={{
1036
+ checkboxControl: "rounded-full border-emerald-500 aria-checked:bg-emerald-500",
1037
+ checkboxIndicator: "text-white",
1038
+ }}
1039
+ ...
1040
+ />
1041
+ ```
1042
+
1043
+ When both are given, the column's options are merged after the grid-level ones, so the column wins.
1044
+
1045
+ **Fully custom checkbox in a cell.** Any `cellRenderer` can toggle the value itself through `updateValue`. This example is the "button with a span inside" written by hand:
1046
+
1047
+ ```tsx
1048
+ function PinCell({ value, editable, updateValue }: CellParams<Task>) {
1049
+ return (
1050
+ <button
1051
+ type="button"
1052
+ aria-pressed={!!value}
1053
+ disabled={!editable}
1054
+ onClick={(e) => {
1055
+ e.stopPropagation(); // keep onRowClick from also firing
1056
+ updateValue(!value);
1057
+ }}
1058
+ >
1059
+ <span style={{ visibility: value ? "visible" : "hidden" }}>✓</span>
1060
+ </button>
1061
+ );
1062
+ }
1063
+
1064
+ { field: "pinned", cellRenderer: PinCell }
1065
+ ```
1066
+
1067
+ #### Select-all header checkbox
1068
+
1069
+ Add `headerCheckbox: true` to a boolean column. The header label becomes a checkbox (the header name is its label, or no label if the name is empty) that checks or clears the whole column.
1070
+
1071
+ ```tsx
1072
+ { field: "done", headerName: "Done", type: "boolean", align: "center", headerCheckbox: true, checkbox: { variant: "tick" } }
1073
+ { field: "done", type: "boolean", headerCheckbox: true } // just the checkbox, no text
1074
+ ```
1075
+
1076
+ - **Click:** if every target row is checked, it unchecks them all. Otherwise (none or only some are checked) it checks them all.
1077
+ - **Which rows:** every row that matches the current search and column filters, on all pages, and whose cell is editable. Rows the filters hide are left alone.
1078
+ - **State:** checked when all target rows are checked, a dash (mixed) when only some are, unchecked when none are. It updates as you toggle individual rows.
1079
+ - **Events:** `onCellEdit` fires once for each row that changed, and `onRowsChange` fires once with the whole updated array. Rows already at the target value, and rows where `validate` rejects the value, are skipped.
1080
+ - **Locked columns:** if no cell in the column is editable, the header checkbox is disabled but still shows the real state.
1081
+ - **Look:** it uses the same variant and styling as the column's cell checkboxes (`checkbox` options, `checkboxVariant`, and the `checkbox`, `checkboxControl` and `checkboxIndicator` slots).
1082
+ - **Sorting:** clicking the checkbox or its label does not sort. Click the sort arrow beside it (or the empty part of the header cell) to sort.
1083
+ - **Custom header:** if the column also has a `headerRenderer`, that is used and `headerCheckbox` is ignored. `headerCheckbox` does nothing on columns that are not `type: "boolean"`.
1084
+
1085
+ ### 6.11 Styling
1086
+
1087
+ #### Whole grid
1088
+
1089
+ ```tsx
1090
+ <DataGrid className="rounded-xl shadow-lg" style={{ maxWidth: 900 }} ... />
1091
+ ```
1092
+
1093
+ #### Parts of the grid
1094
+
1095
+ `classNames` and `styles` take the same keys. Each key is a part ("slot") of the grid:
1096
+
1097
+ | Slot | Element |
1098
+ | --- | --- |
1099
+ | `root` | Outer container |
1100
+ | `toolbar` | Bar holding the search box and your `toolbar` content |
1101
+ | `search` | Global search input |
1102
+ | `scroll` | Scrolling wrapper around the table |
1103
+ | `table` | The `<table>` |
1104
+ | `thead` | The `<thead>` |
1105
+ | `headerRow` | The row of column headers |
1106
+ | `headerCell` | Each column header (`<th>`) |
1107
+ | `filterIndicator` | Each column's filter indicator button |
1108
+ | `filterPopup` | The filter popup (drawn in `document.body`) |
1109
+ | `filterInput` | The default filter text box in the popup |
1110
+ | `tbody` | The `<tbody>` |
1111
+ | `row` | Each body row |
1112
+ | `cell` | Each body cell (`<td>`) |
1113
+ | `editor` | The default text editor input |
1114
+ | `empty` | The "no rows" cell |
1115
+ | `footer` | The pagination bar (both top and bottom) |
1116
+ | `rowCount` | The "1–10 of 200" text |
1117
+ | `pageInfo` | The "Page 1 of 20" text |
1118
+ | `pageSize` | The rows-per-page `<select>` |
1119
+ | `pagination` | Wrapper around the page-size menu and buttons |
1120
+ | `toolButton` | Toolbar buttons (Columns, Export CSV, Retry) |
1121
+ | `skeleton` | The shimmering bars of the loading skeleton |
1122
+ | `pageButton` | Each first/previous/next/last button |
1123
+ | `checkbox` | Root of each checkbox in a `type: "boolean"` column |
1124
+ | `checkboxControl` | The checkbox's button or input |
1125
+ | `checkboxIndicator` | The span inside the `"button"` / `"tick"` checkboxes |
1126
+
1127
+ #### Tailwind
1128
+
1129
+ ```tsx
1130
+ <DataGrid
1131
+ className="rounded-2xl border-0 shadow-xl ring-1 ring-slate-200"
1132
+ classNames={{
1133
+ toolbar: "bg-slate-50 px-4 py-3",
1134
+ search: "rounded-full border-slate-300 px-4",
1135
+ headerCell: "bg-slate-900 text-xs uppercase tracking-wider text-slate-100",
1136
+ row: "hover:bg-indigo-50",
1137
+ footer: "bg-slate-50 text-slate-500",
1138
+ pageButton: "rounded-full hover:bg-indigo-100",
1139
+ }}
1140
+ rowClassName={(row) => (row.status === "inactive" ? "opacity-50" : "")}
1141
+ />
1142
+ ```
1143
+
1144
+ You do not need to add the library to Tailwind's `content` setting, because the class names live in your own code.
1145
+
1146
+ The grid's built-in CSS is written with `:where()`, which gives it zero specificity. That means any class you add, Tailwind utilities included, overrides it without `!important`.
1147
+
1148
+ #### Inline styles
1149
+
1150
+ ```tsx
1151
+ <DataGrid
1152
+ style={{ border: "2px solid #111", borderRadius: 0 }}
1153
+ styles={{
1154
+ headerCell: { background: "#111", color: "#ffe66d" },
1155
+ footer: { background: "#ffe66d" },
1156
+ }}
1157
+ rowStyle={(row) => (row.status === "pending" ? { background: "#fff7cc" } : {})}
1158
+ />
1159
+ ```
1160
+
1161
+ When both are given, `style` wins over `styles.root`.
1162
+
1163
+ #### Per column and per cell
1164
+
1165
+ `className` and `style` style a whole column (its header and every cell). The `header*` and `cell*` options target only one part:
1166
+
1167
+ ```tsx
1168
+ {
1169
+ field: "salary",
1170
+ className: "font-mono text-slate-700", // header and every cell (Tailwind)
1171
+ style: { letterSpacing: 0.3 }, // header and every cell (inline)
1172
+ headerClassName: "text-right",
1173
+ headerStyle: { minWidth: 120 },
1174
+ cellClassName: ({ value }) => (Number(value) > 100000 ? "font-bold text-green-600" : ""),
1175
+ cellStyle: ({ value }) => (value === null ? { color: "#9ca3af" } : {}),
1176
+ }
1177
+ ```
1178
+
1179
+ #### Striped rows
1180
+
1181
+ ```tsx
1182
+ <DataGrid striped /> // default shading
1183
+ <DataGrid stripeColors={["#fef3c7", "#e0f2fe"]} /> // your two colours (implies striped)
1184
+ ```
1185
+
1186
+ Rows alternate between the two colours, starting with the first. `stripeColors` sets `--rk-stripe-a` and `--rk-stripe-b` on the grid. You can also set those variables in your own CSS or theme, and turn on `striped`. Hovering a row still highlights it (`--rk-row-hover`).
1187
+
1188
+ #### Row animation
1189
+
1190
+ When rows render (the first load, a new page, a sort, a search or a filter), they slide up and fade in, one after another. It is on by default.
1191
+
1192
+ ```tsx
1193
+ <DataGrid animateRows={false} /> // turn it off
1194
+ ```
1195
+
1196
+ Tune it with `--rk-row-duration` (default `0.38s`) and `--rk-row-stagger` (default `28ms` per row, up to 24 rows). Changing page or sort also scrolls the grid's own scroll area smoothly back to the top. Visitors who ask their system for reduced motion get no animation (`prefers-reduced-motion`).
1197
+
1198
+ #### Width
1199
+
1200
+ The grid is `width: 100%` of its container by default (it never overflows it: `max-width: 100%; min-width: 0`). To make it narrower, give it a width through `className` (`max-w-3xl`) or `style` (`{ maxWidth: 900 }`).
1201
+
1202
+ #### Plain CSS class reference
1203
+
1204
+ If you prefer writing CSS, these class names are stable.
1205
+
1206
+ | Class | Element |
1207
+ | --- | --- |
1208
+ | `.rk-grid` | Root |
1209
+ | `.rk-grid__toolbar`, `.rk-grid__search` | Toolbar and search box |
1210
+ | `.rk-grid__scroll`, `.rk-grid__table`, `.rk-grid__thead`, `.rk-grid__tbody` | Table structure |
1211
+ | `.rk-grid__tr` | Every row |
1212
+ | `.rk-grid__th`, `.rk-grid__th--sortable`, `.rk-grid__th-content`, `.rk-grid__sort` | Header cells and sort arrow |
1213
+ | `.rk-grid__filter-btn`, `.rk-grid__filter-btn--active`, `.rk-grid__filter-btn--open`, `.rk-grid__filter-dot` | Filter indicator button and its default dot |
1214
+ | `.rk-grid-popup`, `.rk-grid__filter-input`, `.rk-grid__filter-clear` | Filter popup (in `document.body`), its text box and Clear button |
1215
+ | `.rk-grid__row`, `.rk-grid__row--stripe-a`, `.rk-grid__row--stripe-b`, `.rk-grid__row--enter`, `.rk-grid__row--clickable` | Body rows |
1216
+ | `.rk-grid__td`, `.rk-grid__td--editable`, `.rk-grid__td--editing` | Body cells |
1217
+ | `.rk-grid__editor`, `.rk-grid__editor--invalid`, `.rk-grid__custom-editor` | Editors |
1218
+ | `.rk-grid__empty` | Empty state |
1219
+ | `.rk-grid__checkbox-cell` | Wrapper around a checkbox in a boolean column (the checkbox's own classes start with `.rk-checkbox`, see [Checkbox](#5-checkbox)) |
1220
+ | `.rk-grid__footer`, `.rk-grid__footer--top` | Pagination bar (`data-position="top"` or `"bottom"`) |
1221
+ | `.rk-grid__count`, `.rk-grid__pagination`, `.rk-grid__page-size-label`, `.rk-grid__page-size`, `.rk-grid__page-btn`, `.rk-grid__page-info` | Pagination parts |
1222
+ | `.rk-grid__toolbar-left`, `.rk-grid__toolbar-actions` | The two halves of the toolbar (search and selected count / buttons) |
1223
+ | `.rk-grid__tool-btn`, `.rk-grid__selected-count` | Toolbar buttons and the "N selected" label |
1224
+ | `.rk-grid__select-cell`, `.rk-grid__row--selected` | Row-selection checkbox cell and a selected row |
1225
+ | `.rk-grid__pinned`, `.rk-grid__pinned--left`, `.rk-grid__pinned--right`, `.rk-grid__pinned--edge` | Pinned cells (the edge class marks the last left / first right one) |
1226
+ | `.rk-grid__resizer` | The column resize handle |
1227
+ | `.rk-grid__sort-order` | The priority number next to a sort arrow in multi-column sort |
1228
+ | `.rk-grid--virtual`, `.rk-grid__spacer` | Virtualized grid and its spacer rows |
1229
+ | `.rk-grid__skeleton`, `.rk-grid__skeleton-row`, `.rk-grid__skeleton-cell` | Loading placeholder bar, row and (custom) cell |
1230
+ | `.rk-grid__error` | The error message cell (with the Retry button) |
1231
+ | `.rk-grid__filter-fields`, `.rk-grid__filter-field`, `.rk-grid__filter-options`, `.rk-grid__filter-empty` | Controls inside the filter popup (number, date, select) |
1232
+
1233
+
1234
+ ### 6.12 Nested fields
1235
+
1236
+ A column's `field` can point into nested objects with dot notation:
1237
+
1238
+ ```tsx
1239
+ interface Person {
1240
+ id: number;
1241
+ name: string;
1242
+ contact: { email: string; phone: string };
1243
+ address: { city: string };
1244
+ }
1245
+
1246
+ const columns: ColumnDef<Person>[] = [
1247
+ { field: "name" },
1248
+ { field: "contact.email", headerName: "Email", filterable: true },
1249
+ { field: "contact.phone", headerName: "Phone" },
1250
+ { field: "address.city", headerName: "City" },
1251
+ ];
1252
+ ```
1253
+
1254
+ - **TypeScript checks the path.** `field` is typed `FieldPath<Person>`: `"contact.phone"` is accepted, `"contact.fax"` is a type error. It follows up to 5 levels deep, and arrays and dates are treated as plain values.
1255
+ - **Everything uses the path:** display, global search, filters, sorting, editing, `valueFormatter`, `cellRenderer` (`value`, `field`), and boolean columns and select-all headers (`"prefs.newsletter"`).
1256
+ - **Editing never mutates your data.** Saving `contact.phone` copies `row` and `row.contact` and replaces the value, so the original objects are left alone. A missing level on the way is created. `onCellEdit` reports `field: "contact.phone"` and the updated row.
1257
+ - **A missing level reads as empty.** If `row.contact` is `undefined`, the cell is blank and sorts last. No error is thrown.
1258
+ - A key that really contains a dot (`{ "a.b": 1 }`) is read as that literal key first.
1259
+ - `__proto__`, `prototype` and `constructor` are never read or written through a path.
1260
+ - `getRowId`, `onRowClick` and other callbacks work as before.
1261
+
1262
+ ### 6.13 Row selection
1263
+
1264
+ Off by default. `rowSelection` adds a checkbox column on the left.
1265
+
1266
+ ```tsx
1267
+ <DataGrid columns={columns} rows={rows} getRowId={(r) => r.id} rowSelection />
1268
+ ```
1269
+
1270
+ | Value | Behaviour |
1271
+ | --- | --- |
1272
+ | `true` or `"multiple"` | Many rows. The header has a select-all checkbox. Shift+click selects a range |
1273
+ | `"single"` | One row at a time (selecting another replaces it). No select-all checkbox |
1274
+
1275
+ - Selected rows get the class `rk-grid__row--selected`, the colour `--rk-selected-bg` and `aria-selected="true"`.
1276
+ - Selection is kept **by row id**, so it survives paging, sorting, searching and filtering. Pass `getRowId` so ids are stable.
1277
+ - The **select-all checkbox** in the header acts on every selectable row that matches the current search and filters, **across all pages**. It shows a dash while only some of them are selected.
1278
+ - **Shift+click** on a row's checkbox selects (or clears) everything between it and the last row you clicked, as shown on screen. Disabled rows in between are skipped.
1279
+ - A "N selected" count appears in the toolbar (when a toolbar is shown) and in the pagination bar.
1280
+
1281
+ **Reading the selection** (`onSelectionChange` receives the ids and the row objects):
1282
+
1283
+ ```tsx
1284
+ function Page() {
1285
+ const [selection, setSelection] = useState<RowSelection<Person>>({ ids: [], rows: [] });
1286
+
1287
+ return (
1288
+ <>
1289
+ <button disabled={!selection.ids.length} onClick={() => deletePeople(selection.ids)}>
1290
+ Delete {selection.ids.length} people
1291
+ </button>
1292
+ <DataGrid
1293
+ columns={columns}
1294
+ rows={rows}
1295
+ getRowId={(r) => r.id}
1296
+ rowSelection
1297
+ onSelectionChange={setSelection}
1298
+ />
1299
+ </>
1300
+ );
1301
+ }
1302
+ ```
1303
+
1304
+ **Controlled selection.** Pass `selectedRowIds` and keep it up to date from `onSelectionChange`:
1305
+
1306
+ ```tsx
1307
+ const [ids, setIds] = useState<(string | number)[]>([2, 4]);
1308
+
1309
+ <button onClick={() => setIds([])}>Clear</button>
1310
+ <DataGrid rowSelection selectedRowIds={ids} onSelectionChange={(s) => setIds(s.ids)} ... />
1311
+ ```
1312
+
1313
+ Or start with some rows selected and let the grid manage it: `defaultSelectedRowIds={[2, 4]}`.
1314
+
1315
+ **Rows that cannot be selected.** Return `false` from `isRowSelectable`. The row's checkbox is disabled, select-all skips it and Shift ranges pass over it:
1316
+
1317
+ ```tsx
1318
+ <DataGrid rowSelection isRowSelectable={(row) => row.status !== "archived"} ... />
1319
+ ```
1320
+
1321
+ **Single selection**, for a master / detail layout:
1322
+
1323
+ ```tsx
1324
+ const [current, setCurrent] = useState<Person | null>(null);
1325
+
1326
+ <DataGrid
1327
+ rowSelection="single"
1328
+ getRowId={(r) => r.id}
1329
+ onSelectionChange={(s) => setCurrent(s.rows[0] ?? null)}
1330
+ ...
1331
+ />
1332
+ {current && <PersonDetails person={current} />}
1333
+ ```
1334
+
1335
+ **From outside the grid**, with `apiRef` (see [6.19](#619-csv-export-and-the-api)):
1336
+
1337
+ ```tsx
1338
+ const api = useRef<DataGridApi<Person> | null>(null);
1339
+
1340
+ <button onClick={() => api.current?.selectAll()}>Select all</button>
1341
+ <button onClick={() => api.current?.clearSelection()}>Clear</button>
1342
+ <button onClick={() => console.log(api.current?.getSelectedRows())}>Log selection</button>
1343
+ <DataGrid rowSelection apiRef={api} ... />
1344
+ ```
1345
+
1346
+ **Combined with other features.** Row selection works together with pinning (the checkbox column is pinned automatically when any column is pinned left), virtualization, keyboard navigation, and CSV export (`scope: "selected"`).
1347
+
1348
+ **In server-side mode** the grid only holds the current page, so it remembers every row it has seen. `onSelectionChange` and `getSelectedRows()` return those remembered rows, including ones selected on pages you have left. Select-all selects the rows of the page that is loaded.
1349
+
1350
+ ### 6.14 Controlled state
1351
+
1352
+ Search, filters, sort, page and selection can each be **uncontrolled** (the grid keeps the state; the default) or **controlled** (you pass the value and the change callback).
1353
+
1354
+ | State | Value prop | Change callback | Initial value (uncontrolled) |
1355
+ | --- | --- | --- | --- |
1356
+ | Search text | `search` | `onSearchChange(text)` | |
1357
+ | Column filters | `filters` | `onFiltersChange(filters)` | |
1358
+ | Sort | `sort` | `onSortChange(sort)` | `defaultSort` |
1359
+ | Page (zero-based) | `page` | `onPageChange(page)` | page 0 |
1360
+ | Page size | | `onPageSizeChange(size)` | `pageSize` |
1361
+ | Selection | `selectedRowIds` | `onSelectionChange({ ids, rows })` | `defaultSelectedRowIds` |
1362
+
1363
+ **Rules.**
1364
+
1365
+ - Pass the value **and** its callback. A value without a callback makes that piece of state read-only.
1366
+ - You may control some pieces and leave the rest to the grid.
1367
+ - The page goes back to 0 automatically whenever the search, a filter, the sort or the page size changes. In a controlled grid you receive that through `onPageChange(0)`.
1368
+
1369
+ **Example: keep the grid in sync with the URL** so a page can be bookmarked and the Back button works:
1370
+
1371
+ ```tsx
1372
+ import { useSearchParams } from "react-router-dom";
1373
+
1374
+ function PeopleGrid() {
1375
+ const [params, setParams] = useSearchParams();
1376
+ const page = Number(params.get("page") ?? 0);
1377
+ const search = params.get("q") ?? "";
1378
+
1379
+ const update = (changes: Record<string, string | null>) =>
1380
+ setParams((p) => {
1381
+ for (const [k, v] of Object.entries(changes)) v === null ? p.delete(k) : p.set(k, v);
1382
+ return p;
1383
+ });
1384
+
1385
+ return (
1386
+ <DataGrid
1387
+ columns={columns}
1388
+ rows={rows}
1389
+ searchable
1390
+ pagination
1391
+ page={page}
1392
+ onPageChange={(p) => update({ page: String(p) })}
1393
+ search={search}
1394
+ onSearchChange={(q) => update({ q: q || null })}
1395
+ />
1396
+ );
1397
+ }
1398
+ ```
1399
+
1400
+ **Example: buttons outside the grid**
1401
+
1402
+ ```tsx
1403
+ const [page, setPage] = useState(0);
1404
+ const [search, setSearch] = useState("");
1405
+
1406
+ <button onClick={() => setPage(2)}>Go to page 3</button>
1407
+ <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search outside the grid" />
1408
+ <DataGrid searchable pagination pageSize={5} page={page} onPageChange={setPage} search={search} onSearchChange={setSearch} ... />
1409
+ ```
1410
+
1411
+ **Example: saved views.** Remember a user's filters and sort between visits:
1412
+
1413
+ ```tsx
1414
+ const [filters, setFilters] = useState<Record<string, FilterValue>>(() => JSON.parse(localStorage.getItem("filters") ?? "{}"));
1415
+ const [sort, setSort] = useState<SortModel[]>(() => JSON.parse(localStorage.getItem("sort") ?? "[]"));
1416
+
1417
+ useEffect(() => localStorage.setItem("filters", JSON.stringify(filters)), [filters]);
1418
+ useEffect(() => localStorage.setItem("sort", JSON.stringify(sort)), [sort]);
1419
+
1420
+ <DataGrid filters={filters} onFiltersChange={setFilters} sort={sort} onSortChange={setSort} ... />
1421
+ ```
1422
+
1423
+ ### 6.15 Server-side mode
1424
+
1425
+ When your data lives on a server, switch on `serverSide`. The grid then **does not sort, filter, search or page the rows itself**. It shows the `rows` you give it (the current page) and tells you what to fetch.
1426
+
1427
+ ```tsx
1428
+ <DataGrid
1429
+ serverSide
1430
+ columns={columns}
1431
+ rows={rows} // only the current page
1432
+ rowCount={total} // total rows on the server, for "1–10 of 237" and the page count
1433
+ pagination
1434
+ searchable
1435
+ onQueryChange={load}
1436
+ />
1437
+ ```
1438
+
1439
+ `onQueryChange(query)` is called **on mount and whenever the query changes**: the sort, the search text (after `queryDebounce` ms), a filter, the page or the page size. The query is:
1440
+
1441
+ ```ts
1442
+ interface GridQuery {
1443
+ sort: SortModel[]; // [{ field: "salary", direction: "desc" }]
1444
+ filters: Record<string, FilterValue>; // only active ones, by field
1445
+ search: string; // trimmed
1446
+ page: number; // zero-based
1447
+ pageSize: number;
1448
+ }
1449
+ ```
1450
+
1451
+ Filter values have the shapes from [6.5](#65-searching-and-filtering): a string for text / exact, `{ min, max }` for number, `{ from, to }` for date, an array for select. Only columns marked `sortable` appear in `sort`.
1452
+
1453
+ **Loading is caught from a function.** If `onQueryChange` returns a promise, the grid shows the [loading skeleton](#616-loading-skeleton) until the promise settles. If it rejects, the grid shows `errorMessage` with a **Retry** button that runs the same query again. You do not manage any `loading` state.
1454
+
1455
+ ```tsx
1456
+ function PeopleGrid() {
1457
+ const [rows, setRows] = useState<Person[]>([]);
1458
+ const [total, setTotal] = useState(0);
1459
+
1460
+ const load = useCallback(async (q: GridQuery) => {
1461
+ const res = await fetch("/api/people?" + new URLSearchParams({
1462
+ page: String(q.page),
1463
+ pageSize: String(q.pageSize),
1464
+ search: q.search,
1465
+ sort: JSON.stringify(q.sort),
1466
+ filters: JSON.stringify(q.filters),
1467
+ }));
1468
+ if (!res.ok) throw new Error("Request failed"); // -> error row with Retry
1469
+ const body = await res.json();
1470
+ setRows(body.rows);
1471
+ setTotal(body.total);
1472
+ }, []);
1473
+
1474
+ return (
1475
+ <DataGrid
1476
+ serverSide
1477
+ columns={columns}
1478
+ rows={rows}
1479
+ rowCount={total}
1480
+ getRowId={(r) => r.id}
1481
+ pagination
1482
+ searchable
1483
+ onQueryChange={load}
1484
+ errorMessage="Could not reach the server."
1485
+ />
1486
+ );
1487
+ }
1488
+ ```
1489
+
1490
+ **Ignoring stale answers.** The grid ignores the *loading state* of an older query when a newer one has started, but it cannot know which response **you** store. Cancel old requests with `AbortController`, or compare a counter:
1491
+
1492
+ ```tsx
1493
+ const controller = useRef<AbortController>();
1494
+
1495
+ const load = useCallback(async (q: GridQuery) => {
1496
+ controller.current?.abort(); // cancel the previous request
1497
+ controller.current = new AbortController();
1498
+ const res = await fetch(url(q), { signal: controller.current.signal });
1499
+ const body = await res.json();
1500
+ setRows(body.rows);
1501
+ setTotal(body.total);
1502
+ }, []);
1503
+ ```
1504
+
1505
+ An aborted `fetch` rejects with an `AbortError`. If you do not want that to show the error row, catch it: `catch (e) { if ((e as Error).name !== "AbortError") throw e; }`.
1506
+
1507
+ **Using a data library.** Anything that returns a promise works:
1508
+
1509
+ ```tsx
1510
+ // the grid calls your function; you call the library
1511
+ const load = (q: GridQuery) => queryClient.fetchQuery({ queryKey: ["people", q], queryFn: () => api.people(q) })
1512
+ .then((res) => { setRows(res.rows); setTotal(res.total); });
1513
+ ```
1514
+
1515
+ **Forcing the loading state.** If your loading flag lives somewhere else (a hook, a store), pass `loading` and it replaces the promise-based state:
1516
+
1517
+ ```tsx
1518
+ const { data, isFetching } = useQuery(...);
1519
+ <DataGrid serverSide rows={data?.rows ?? []} rowCount={data?.total} loading={isFetching} onQueryChange={setQuery} />
1520
+ ```
1521
+
1522
+ **Refreshing** after your data changed (for example after deleting a row) runs the last query again:
1523
+
1524
+ ```tsx
1525
+ api.current?.refresh();
1526
+ ```
1527
+
1528
+ **Search debounce.** Typing in the search box waits `queryDebounce` milliseconds (250 by default) after the last keystroke, so fast typing makes one request, not one per letter. Set `queryDebounce={0}` for instant queries.
1529
+
1530
+ **Server mode and the other features.**
1531
+
1532
+ - Selection, [CSV export of selected rows](#619-csv-export-and-the-api) and keyboard navigation work on the loaded page.
1533
+ - With `scope: "filtered"` the CSV contains the rows currently loaded (the current page), because the grid does not have the rest.
1534
+ - `defaultSort` is sent in the first query. `multiSort` lets users send several sort columns.
1535
+ - `onQueryChange` is the only place the grid asks for data. Without `serverSide`, the grid never calls it.
1536
+
1537
+ ### 6.16 Loading skeleton
1538
+
1539
+ While loading, the body shows **placeholder rows** with shimmering bars instead of data. The header, toolbar and footer stay in place, and the grid root gets `aria-busy="true"`. When the promise from `onQueryChange` settles (or `loading` turns `false`), the real rows appear.
1540
+
1541
+ ```tsx
1542
+ <DataGrid serverSide onQueryChange={load} loadingRows={8} ... />
1543
+ ```
1544
+
1545
+ - `loadingRows` is how many placeholder rows to draw. Default: the page size (at most 20), or 5 without pagination.
1546
+ - With `rowSelection` the checkbox column shows a small bar too.
1547
+ - Bar widths vary slightly from row to row so the placeholder does not look like identical stripes.
1548
+ - Colours come from `--rk-skeleton-a` and `--rk-skeleton-b`. Each theme sets them. The shimmer stops when the user has "reduce motion" turned on.
1549
+ - Style the bars with `classNames.skeleton` / `styles.skeleton` (they are `<span class="rk-grid__skeleton">`).
1550
+
1551
+ **A custom skeleton for each row.** Pass a component as `skeletonRow`. It is drawn once per placeholder row, inside a single cell that spans the whole row, and receives `{ rowIndex, columns }`:
1552
+
1553
+ ```tsx
1554
+ function PersonSkeleton({ rowIndex }: SkeletonRowProps<Person>) {
1555
+ return (
1556
+ <div style={{ display: "flex", gap: 12, alignItems: "center", padding: "4px 0" }}>
1557
+ <span className="avatar-skeleton" />
1558
+ <span style={{ display: "grid", gap: 6, flex: 1 }}>
1559
+ <span className="line-skeleton" style={{ width: `${40 + ((rowIndex * 17) % 30)}%` }} />
1560
+ <span className="line-skeleton" style={{ width: "25%" }} />
1561
+ </span>
1562
+ </div>
1563
+ );
1564
+ }
1565
+
1566
+ <DataGrid serverSide onQueryChange={load} skeletonRow={PersonSkeleton} loadingRows={6} ... />
1567
+ ```
1568
+
1569
+ You can reuse the built-in shimmer in your own placeholder by giving your elements the class `rk-grid__skeleton`:
1570
+
1571
+ ```tsx
1572
+ function RowSkeleton({ columns }: SkeletonRowProps<Person>) {
1573
+ return (
1574
+ <div style={{ display: "grid", gridTemplateColumns: `repeat(${columns.length}, 1fr)`, gap: 16 }}>
1575
+ {columns.map((c) => <span key={c.field} className="rk-grid__skeleton" style={{ width: "70%" }} />)}
1576
+ </div>
1577
+ );
1578
+ }
1579
+ ```
1580
+
1581
+ Define the skeleton component at module level (not inline in render), like every other custom component.
1582
+
1583
+ **Showing it without a server.** `loading` works in a normal grid too, for example while a parent fetches `rows`:
1584
+
1585
+ ```tsx
1586
+ <DataGrid columns={columns} rows={rows} loading={isLoading} />
1587
+ ```
1588
+
1589
+ **The error state.** If the promise from `onQueryChange` rejects, the body shows `errorMessage` (default "Could not load the data.") and a Retry button. Style it with `.rk-grid__error`:
1590
+
1591
+ ```tsx
1592
+ <DataGrid serverSide onQueryChange={load} errorMessage={<strong>The server is not answering.</strong>} ... />
1593
+ ```
1594
+
1595
+ ### 6.17 Resize, pin and hide columns
1596
+
1597
+ All three are opt-in. **There is no column reordering.**
1598
+
1599
+ **Resizing.** Add `resizable: true` to a column. A drag handle appears on its right edge. Drag it, or use a touch or pen: it uses pointer events.
1600
+
1601
+ ```tsx
1602
+ const columns: ColumnDef<Employee>[] = [
1603
+ { field: "name", resizable: true, minWidth: 100 },
1604
+ { field: "email", resizable: true, minWidth: 140, maxWidth: 480 },
1605
+ { field: "id", width: 70 }, // no handle: fixed
1606
+ ];
1607
+ ```
1608
+
1609
+ - On the first drag the grid fixes every column at the width it has at that moment (so nothing jumps) and switches the table to `table-layout: fixed`.
1610
+ - `minWidth` (at least 40px) and `maxWidth` limit the drag.
1611
+ - Widths live inside the grid and are not saved. To remember them, store the `width` you want per column yourself.
1612
+
1613
+ **Pinning.** Add `pinned: "left"` or `"right"` to keep a column visible while the rest scrolls sideways. Pinned columns are moved to the edges (left-pinned first, right-pinned last), keep their order among themselves, and have an opaque background.
1614
+
1615
+ ```tsx
1616
+ const columns: ColumnDef<Employee>[] = [
1617
+ { field: "id", width: 70, pinned: "left" },
1618
+ { field: "name", width: 160, pinned: "left" }, // sits next to ID
1619
+ { field: "email", width: 280 },
1620
+ { field: "department", width: 200 },
1621
+ { field: "salary", width: 160 },
1622
+ { field: "status", width: 120, pinned: "right" }, // always at the right edge
1623
+ ];
1624
+
1625
+ <DataGrid columns={columns} rows={rows} /> // scroll sideways: ID, Name and Status stay put
1626
+ ```
1627
+
1628
+ The row-selection checkbox column is pinned too when any column is pinned left. A thin shadow line (`.rk-grid__pinned--edge`) marks where pinned columns end. Give pinned columns an explicit `width` for the cleanest look. Pinning needs the grid to be narrower than its columns, which happens when the columns' widths add up to more than the container.
1629
+
1630
+ Colours: `--rk-pinned-bg` and `--rk-pinned-header-bg`. Stripes, hover and selection are layered on top.
1631
+
1632
+ **Showing and hiding.** Switch on `columnMenu` for a "Columns" button with a checkbox per column:
1633
+
1634
+ ```tsx
1635
+ <DataGrid
1636
+ columnMenu
1637
+ onColumnVisibilityChange={(hiddenFields) => localStorage.setItem("hidden", JSON.stringify(hiddenFields))}
1638
+ columns={[
1639
+ { field: "id", hideable: false }, // always visible, not listed
1640
+ { field: "name" },
1641
+ { field: "email" },
1642
+ { field: "notes", hidden: true }, // starts hidden
1643
+ ]}
1644
+ rows={rows}
1645
+ />
1646
+ ```
1647
+
1648
+ - `hidden: true` starts a column hidden. The menu shows it unchecked.
1649
+ - `hideable: false` keeps a column out of the menu, so it cannot be hidden.
1650
+ - The last visible column cannot be hidden.
1651
+ - Hidden columns are skipped by search, keyboard navigation and CSV export (unless `includeHidden`).
1652
+
1653
+ ### 6.18 Keyboard
1654
+
1655
+ Switch on `keyboardNavigation` to move between cells with the keyboard. The grid has **one tab stop**: Tab enters the body at the last cell you used (or the first one), and the arrow keys move from there. Tab again leaves the grid.
1656
+
1657
+ | Key (on a cell) | Action |
1658
+ | --- | --- |
1659
+ | Arrow keys | Move one cell |
1660
+ | Home / End | First / last column of the row |
1661
+ | Ctrl+Home / Ctrl+End | First cell of the first row / last cell of the last row |
1662
+ | Page Up / Page Down | Move 10 rows |
1663
+ | Enter / F2 | Start editing (editable cells) |
1664
+ | Any character | Start editing and replace the value (text and number cells) |
1665
+
1666
+ Other keys, always available:
1667
+
1668
+ | Where | Key | Action |
1669
+ | --- | --- | --- |
1670
+ | Header | Enter / Space | Sort by this column (sortable columns) |
1671
+ | Editor | Enter | Save, edit the cell below |
1672
+ | Editor | Shift+Enter | Save, edit the cell above |
1673
+ | Editor | Tab / Shift+Tab | Save, edit the next / previous cell |
1674
+ | Editor | Esc | Cancel |
1675
+ | Filter popup | Esc | Close |
1676
+ | Text filter box | Enter | Close the popup |
1677
+
1678
+ With `virtualize`, moving past the visible window scrolls the target row into view before focusing it, so you can arrow through 20,000 rows. Combined with `maxHeight`, the focused cell is scrolled into view.
1679
+
1680
+ ```tsx
1681
+ <DataGrid keyboardNavigation editable maxHeight={420} columns={columns} rows={rows} />
1682
+ ```
1683
+
1684
+ Without `keyboardNavigation`, cells are reachable with Tab (editable cells) or the mouse, as before.
1685
+
1686
+ ### 6.19 CSV export and the API
1687
+
1688
+ **The export button.** Switch on `exportable` for an "Export CSV" button in the toolbar. It downloads what the user sees: the visible columns, in order, for every row matching the search and filters (across all pages).
1689
+
1690
+ ```tsx
1691
+ <DataGrid exportable ... /> // downloads export.csv
1692
+ <DataGrid exportable={{ filename: "employees" }} ... /> // employees.csv
1693
+ <DataGrid exportable={{ filename: "page-1", scope: "page" }} ... /> // only the current page
1694
+ <DataGrid exportable={{ scope: "selected" }} rowSelection ... /> // only the selected rows
1695
+ ```
1696
+
1697
+ | `ExportOptions` | Default | Meaning |
1698
+ | --- | --- | --- |
1699
+ | `filename` | `"export"` | File name without `.csv` |
1700
+ | `scope` | `"filtered"` | `"filtered"` all rows matching search and filters, `"page"` the current page, `"selected"` the selected rows |
1701
+ | `includeHidden` | `false` | Also write hidden columns |
1702
+ | `sanitize` | `true` | Put an apostrophe in front of text starting with `=`, `+`, `-` or `@`, so Excel does not run it as a formula |
1703
+
1704
+ **What goes in the file.** The header row uses the column header names. Cell values are the displayed text (the result of `valueFormatter`, `Yes` / `No` for booleans), except that dates are written as `YYYY-MM-DD`. Numbers therefore export formatted (`$52,000`) unless you give an `exportValue`. To decide a column's value yourself, add `exportValue`:
1705
+
1706
+ ```tsx
1707
+ {
1708
+ field: "status",
1709
+ exportValue: (value) => String(value).toUpperCase(), // ACTIVE
1710
+ }
1711
+ {
1712
+ field: "salary",
1713
+ valueFormatter: (v) => `$${v.toLocaleString()}`, // shown as $52,000
1714
+ exportValue: (v) => v, // exported as 52000, so a spreadsheet can add it up
1715
+ }
1716
+ ```
1717
+
1718
+ The file starts with a byte-order mark so Excel reads accents and non-Latin text correctly. Commas, quotes and line breaks inside values are quoted.
1719
+
1720
+ **The API.** Pass a ref as `apiRef` to call methods from outside the grid:
1721
+
1722
+ ```tsx
1723
+ const api = useRef<DataGridApi<Employee> | null>(null);
1724
+
1725
+ <DataGrid apiRef={api} rowSelection ... />
1726
+
1727
+ api.current?.exportCsv({ filename: "selected", scope: "selected" }); // download
1728
+ const text = api.current?.getCsv(); // the CSV as a string
1729
+ ```
1730
+
1731
+ | Method | Does |
1732
+ | --- | --- |
1733
+ | `getCsv(options?)` | Returns the CSV text without downloading it |
1734
+ | `exportCsv(options?)` | Downloads a CSV file |
1735
+ | `getSelectedRows()` | The selected row objects |
1736
+ | `getSelectedRowIds()` | The selected ids |
1737
+ | `selectAll()` | Selects every selectable row matching the search and filters |
1738
+ | `clearSelection()` | Clears the selection |
1739
+ | `refresh()` | Runs the last query again (`serverSide` only) |
1740
+
1741
+ **Sending the CSV somewhere else** (upload, email) instead of downloading:
1742
+
1743
+ ```tsx
1744
+ const csv = api.current!.getCsv({ scope: "selected" });
1745
+ await fetch("/api/reports", { method: "POST", headers: { "Content-Type": "text/csv" }, body: csv });
1746
+ ```
1747
+
1748
+ **Your own button** next to the built-in ones:
1749
+
1750
+ ```tsx
1751
+ <DataGrid
1752
+ apiRef={api}
1753
+ toolbar={<button className="rk-grid__tool-btn" onClick={() => api.current?.exportCsv({ filename: "all", includeHidden: true })}>Export everything</button>}
1754
+ ...
1755
+ />
1756
+ ```
1757
+
1758
+ ### 6.20 Virtualization
1759
+
1760
+ For tens of thousands of rows, switch on `virtualize`. Only the rows in (and just around) the scroll window are in the DOM; empty spacer rows stand in for the rest, so the scroll bar still reflects the full data.
1761
+
1762
+ ```tsx
1763
+ <DataGrid
1764
+ virtualize
1765
+ rowHeight={40} // every row must be exactly this tall
1766
+ maxHeight={480} // the grid scrolls inside this height (400 if you give none)
1767
+ overscan={8} // extra rows drawn above and below
1768
+ columns={columns}
1769
+ rows={twentyThousandRows}
1770
+ getRowId={(r) => r.id}
1771
+ searchable
1772
+ />
1773
+ ```
1774
+
1775
+ - **Fixed row height.** Rows are laid out as `rowHeight` pixels each and cell text does not wrap (long text is cut off). Set `rowHeight` to fit your content.
1776
+ - Use it without `pagination` so everything scrolls in one list. Sorting, search and filters still work on the whole data set.
1777
+ - Row animation is skipped while virtualizing, because rows come and go as you scroll.
1778
+ - Keyboard navigation, selection and editing all work. Moving focus to a row outside the window scrolls it into view first.
1779
+ - Choose a `rowHeight` that suits your padding: 40 is right for the default cell padding.
1780
+
1781
+ ```tsx
1782
+ // 50,000 rows, searchable, selectable, keyboard friendly
1783
+ <DataGrid virtualize rowHeight={36} maxHeight="70vh" searchable rowSelection keyboardNavigation theme="graphite" ... />
1784
+ ```
1785
+
1786
+ ### 6.21 Accessibility
1787
+
1788
+ - The table has `role="grid"`. Sortable headers are focusable, expose `aria-sort`, and work with Enter and Space. Columns that are not sortable are not focus stops.
1789
+ - Editable cells are focusable (`tabindex="0"`). With `keyboardNavigation` the body has a single tab stop and arrow keys move between cells ([6.18](#618-keyboard)).
1790
+ - The search box, filter indicators (`aria-haspopup="dialog"`, `aria-expanded`), filter popups (`role="dialog"`), the Columns menu, editors, page buttons and page-size menu have `aria-label`s. Filter inputs are named after their column ("Minimum Budget", "Due from"). Checkboxes in columns with an empty header are named after the field.
1791
+ - Selected rows have `aria-selected="true"`. Row checkboxes are named "Select row N", and the header one "Select all rows".
1792
+ - The "N selected" count is a status region (`role="status"`).
1793
+ - While loading, the grid has `aria-busy="true"` and the placeholder rows are hidden from assistive technology. The error message has `role="alert"`.
1794
+ - Invalid editors set `aria-invalid="true"`.
1795
+ - The shimmer and row animations stop when the system has "reduce motion" turned on.
1796
+ - Custom components you supply (renderers, editors, skeleton rows, indicators) are responsible for their own accessibility.
1797
+
1798
+ ### 6.22 TypeScript
1799
+
1800
+ All types are exported:
1801
+
1802
+ ```ts
1803
+ import type {
1804
+ // grid and columns
1805
+ DataGridProps, ColumnDef, DataGridApi, GridTheme, GridSlot, FieldPath,
1806
+ // cells, editors, headers
1807
+ CellParams, CellEditEvent, CellEditorProps, CellEditableParams, HeaderRendererProps, MoveDirection,
1808
+ // sorting, paging
1809
+ SortModel, SortDirection, PaginationPosition,
1810
+ // filters
1811
+ FilterType, FilterValue, FilterOption, NumberRangeFilter, DateRangeFilter, FilterRendererProps, FilterIndicatorProps,
1812
+ // selection, server, export, loading
1813
+ RowSelection, GridQuery, ExportOptions, SkeletonRowProps,
1814
+ // checkbox
1815
+ ColumnCheckboxOptions, CheckboxProps, CheckboxVariant, CheckboxSlot,
1816
+ } from "react-gridix";
1817
+ ```
1818
+
1819
+ Typing tips:
1820
+
1821
+ ```tsx
1822
+ const columns: ColumnDef<Employee>[] = [ ... ]; // field is checked against Employee (dot paths too)
1823
+ function Cell({ row }: CellParams<Employee>) { ... } // row is typed as Employee
1824
+
1825
+ const api = useRef<DataGridApi<Employee> | null>(null); // typed apiRef
1826
+ const [sel, setSel] = useState<RowSelection<Employee>>({ ids: [], rows: [] });
1827
+ const [filters, setFilters] = useState<Record<string, FilterValue>>({});
1828
+
1829
+ // narrowing a filter value yourself (for example when building a server query)
1830
+ function toParams(f: Record<string, FilterValue>) {
1831
+ return Object.entries(f).map(([field, v]) => {
1832
+ if (typeof v === "string") return `${field}~${v}`; // text / exact
1833
+ if (Array.isArray(v)) return `${field}=${v.join(",")}`; // select
1834
+ if ("min" in v || "max" in v) return `${field}:${(v as NumberRangeFilter).min ?? ""}..${(v as NumberRangeFilter).max ?? ""}`;
1835
+ return `${field}:${(v as DateRangeFilter).from ?? ""}..${(v as DateRangeFilter).to ?? ""}`; // date
1836
+ });
1837
+ }
1838
+ ```
1839
+
1840
+ ### 6.23 Recipes
1841
+
1842
+ **Read-only grid**
1843
+
1844
+ ```tsx
1845
+ <DataGrid columns={columns} rows={rows} /> // editable, sortable, searchable... all default to off
1846
+ ```
1847
+
1848
+ **A typical admin table**
1849
+
1850
+ ```tsx
1851
+ <DataGrid
1852
+ columns={columns} // sortable / filterable chosen per column
1853
+ rows={rows}
1854
+ getRowId={(r) => r.id}
1855
+ searchable
1856
+ pagination
1857
+ rowSelection
1858
+ columnMenu
1859
+ exportable={{ filename: "users" }}
1860
+ striped
1861
+ theme="lavender"
1862
+ />
1863
+ ```
1864
+
1865
+ **Currency column that accepts "$1,200" when typed**
1866
+
1867
+ ```tsx
1868
+ {
1869
+ field: "salary",
1870
+ type: "number",
1871
+ align: "right",
1872
+ sortable: true,
1873
+ valueFormatter: (v) => (v == null ? "" : money.format(v)),
1874
+ valueParser: (text) => Number(text.replace(/[$,]/g, "")),
1875
+ validate: (v) => Number.isFinite(v),
1876
+ }
1877
+ ```
1878
+
1879
+ (The editor opens with the raw number, such as `52000`. The parser only matters if the user types symbols.)
1880
+
1881
+ **Only one column editable**
1882
+
1883
+ ```tsx
1884
+ <DataGrid columns={[{ field: "id" }, { field: "notes", editable: true }]} />
1885
+ ```
1886
+
1887
+ **Lock rows after approval**
1888
+
1889
+ ```tsx
1890
+ { field: "amount", editable: ({ row }) => !row.approved }
1891
+ ```
1892
+
1893
+ **Scrolling grid with sticky header**
1894
+
1895
+ ```tsx
1896
+ <DataGrid maxHeight="60vh" ... />
1897
+ ```
1898
+
1899
+ **Highlight a row based on its data**
1900
+
1901
+ ```tsx
1902
+ <DataGrid rowClassName={(row) => (row.overdue ? "bg-red-50" : "")} />
1903
+ ```
1904
+
1905
+ **Overdue dates in red**
1906
+
1907
+ ```tsx
1908
+ {
1909
+ field: "due",
1910
+ type: "date",
1911
+ cellStyle: ({ value }) => (value && String(value) < new Date().toISOString().slice(0, 10) ? { color: "#dc2626" } : {}),
1912
+ }
1913
+ ```
1914
+
1915
+ **Status as a coloured badge, with an exact filter**
1916
+
1917
+ ```tsx
1918
+ const Badge = ({ value }: CellParams<Task>) => <span className={`badge badge-${value}`}>{String(value)}</span>;
1919
+
1920
+ { field: "status", sortable: true, filterable: true, filterType: "select", cellRenderer: Badge }
1921
+ ```
1922
+
1923
+ **Delete the selected rows**
1924
+
1925
+ ```tsx
1926
+ const [rows, setRows] = useState(initial);
1927
+ const [ids, setIds] = useState<(string | number)[]>([]);
1928
+
1929
+ <button disabled={!ids.length} onClick={() => { setRows((r) => r.filter((x) => !ids.includes(x.id))); setIds([]); }}>
1930
+ Delete {ids.length}
1931
+ </button>
1932
+ <DataGrid rows={rows} columns={columns} getRowId={(r) => r.id} rowSelection selectedRowIds={ids} onSelectionChange={(s) => setIds(s.ids)} />
1933
+ ```
1934
+
1935
+ **Bulk edit the selected rows** (`onRowsChange` gives the whole array back):
1936
+
1937
+ ```tsx
1938
+ const markDone = () => setRows((rs) => rs.map((r) => (ids.includes(r.id) ? { ...r, status: "done" } : r)));
1939
+ ```
1940
+
1941
+ **Choose the page size once and remember it**
1942
+
1943
+ ```tsx
1944
+ <DataGrid pagination pageSize={Number(localStorage.getItem("ps")) || 25} onPageSizeChange={(n) => localStorage.setItem("ps", String(n))} />
1945
+ ```
1946
+
1947
+ **A fast, big, read-only table**
1948
+
1949
+ ```tsx
1950
+ <DataGrid virtualize rowHeight={32} maxHeight="80vh" animateRows={false} searchable columns={columns} rows={hugeArray} getRowId={(r) => r.id} />
1951
+ ```
1952
+
1953
+ **An infinite-looking server table with a dark theme**
1954
+
1955
+ ```tsx
1956
+ <DataGrid serverSide pagination searchable multiSort theme="aurora" columns={columns} rows={rows} rowCount={total} onQueryChange={load} />
1957
+ ```
1958
+
1959
+ ### 6.24 Known limitations
1960
+
1961
+ Not included:
1962
+
1963
+ - **Column reordering** (by design)
1964
+ - Row grouping, tree data, row detail panels and column spanning
1965
+ - Infinite scroll in server mode (paging is used instead)
1966
+ - Saving column widths or visibility between visits (use `onColumnVisibilityChange` and your own storage)
1967
+ - Variable row heights while virtualizing
1968
+
1969
+ Behaviours to be aware of:
1970
+
1971
+ - Text filters match by substring (`active` also matches `inactive`). Use `filterType: "exact"` or `"select"` for exact choices.
1972
+ - Only one filter popup is open at a time.
1973
+ - Stripes and the row animation restart on every page, sort, search or filter change.
1974
+ - `pageSize` is read once, on mount (use `onPageSizeChange`). `defaultSort` and `defaultSelectedRowIds` are read once too: to change them later, control the state.
1975
+ - `rows` must keep a stable identity between renders, otherwise edits are discarded (see [6.9](#69-editing)).
1976
+ - `onRowClick` also fires for clicks inside a cell that is being edited.
1977
+ - A `valueParser` that returns `NaN` stores `NaN`. Add `validate: (v) => Number.isFinite(v)` to reject it.
1978
+ - `virtualize` needs fixed-height rows. A cell with wrapped text will be cut off.
1979
+ - Pinned columns only stick when the table is wider than its container.
1980
+ - In `serverSide` mode the grid cannot export or select rows it has never loaded.
1981
+ - The browser's date picker looks different in each browser. Its language follows the browser, not `dateLocale` (which only formats the display).
1982
+
1983
+ ---
1984
+
1985
+ ## 7. Demos
1986
+
1987
+ The repository contains a playground with one page per feature:
1988
+
1989
+ | Demo (`#id`) | Shows |
1990
+ | --- | --- |
1991
+ | Basic (`#basic`) | Opt-in sorting, global search, per-column filter dots with popups, pagination, controlled `SortModel[]` |
1992
+ | Editing (`#editing`) | Enter-moves-down editing, Tab, validation, locked cells, `onCellEdit` |
1993
+ | Custom components (`#custom`) | `cellRenderer`, `cellEditor`, `headerRenderer`, a custom funnel `filterIndicator`, a per-column indicator style, a `filterRenderer` in the popup |
1994
+ | Checkboxes (`#checkboxes`) | Boolean columns with the three variants, an empty-label header, a custom `★` character, select-all headers, locking, styling from outside, and a standalone `Checkbox` |
1995
+ | Styling (`#styling`) | Tailwind classes and inline styles, column `className` / `style`, and `stripeColors` |
1996
+ | Pagination (`#pagination`) | Top, bottom and both, no pagination, the row animation toggle, and a **controlled page and search** grid driven by outside buttons |
1997
+ | Nested fields (`#nested`) | `contact.phone` style dot paths: editing, search, filter, sort, and proof the original data is not mutated |
1998
+ | Row selection (`#selection`) | Multiple and single modes, controlled ids, `isRowSelectable`, Shift+click ranges, select-all across pages, `apiRef.selectAll()`, a Delete button |
1999
+ | Server-side (`#server`) | A fake 237-row database with 700 ms latency: server sorting, multi-sort, search debounce, filters, paging, the loading skeleton (default and custom row), and a switch that makes the request fail so you can see the error and Retry |
2000
+ | Columns (`#columns`) | Resizing, pinned columns on both sides, the Columns menu, arrow-key navigation, CSV export with `exportValue`, multi-column sort |
2001
+ | Large data (`#large`) | 20,000 virtualized rows with search, sorting and a theme |
2002
+ | Dates and filters (`#dates`) | `type: "date"` with the date picker editor, `dateMin` / `dateMax`, a `Date`-object column, from/to, number range, select and exact filters |
2003
+ | Themes (`#themes`) | A switcher for none and all 8 themes |
2004
+
2005
+ Run it:
2006
+
2007
+ ```bash
2008
+ npm install
2009
+ npm run dev
2010
+ ```
2011
+
2012
+ Open the URL it prints (usually `http://localhost:5173`). Link straight to a demo with the `#id` in the table. The source for each demo is in `demo/examples/`, written as small, commented, copy-paste-able files.
2013
+
2014
+ The Styling demo loads Tailwind from its CDN, so it needs an internet connection. The rest works offline.
2015
+
2016
+ ---
2017
+
2018
+ ## 9. Troubleshooting
2019
+
2020
+ | Problem | Cause and fix |
2021
+ | --- | --- |
2022
+ | Everything looks unstyled | Import `react-gridix/style.css` once in your app |
2023
+ | `theme="ocean"` does nothing | The theme CSS ships in `style.css`: make sure it is imported. The attribute `data-rk-theme` should appear on the grid root |
2024
+ | Headers don't sort | Sorting is per column: add `sortable: true` to the column definition. There is no grid-level `sortable` prop any more |
2025
+ | There is no search box | Pass `searchable` |
2026
+ | There is no page info or page buttons | Pass `pagination`. Without it every row is shown and there is no footer |
2027
+ | I can't find the filter input | Filters are per column: set `filterable: true`, then click the dot in the header |
2028
+ | A date column shows `2026-11-05` instead of `Nov 5, 2026` | The column needs `type: "date"` |
2029
+ | The date picker never opens | The cell must be editable (`editable` on the grid or the column), then double-click it or press Enter / F2 |
2030
+ | The number filter popup asks for Min and Max, but I wanted text | Number columns default to a range filter. Set `filterType: "text"` |
2031
+ | Select filter lists the wrong labels | Pass `filterOptions` with `{ value, label }` pairs. `value` must equal what is stored in the row |
2032
+ | Selecting rows does nothing | Add `rowSelection`. Give the grid `getRowId` so selection survives sorting and paging |
2033
+ | The server grid asks for data twice on mount | In React StrictMode (development) effects run twice. The production build calls it once. Your handler should be safe to call repeatedly |
2034
+ | The server grid shows no skeleton | `onQueryChange` must **return** the promise (`return fetch(...)` or an `async` function). A function that starts a request and returns nothing cannot be tracked. Alternatively pass `loading` |
2035
+ | The server grid shows old rows after I changed a filter | You stored an older response last. Cancel the previous request with `AbortController` or compare a request counter |
2036
+ | Page 1 is shown after every search | By design: the page resets when search, filters, sort or page size change |
2037
+ | Resize handles do nothing | The column needs `resizable: true` |
2038
+ | Pinned columns scroll away | The columns must be wider than the container (give them `width`s), otherwise there is nothing to scroll. Give pinned columns an explicit `width` |
2039
+ | Virtualized rows look cut off or overlap | `rowHeight` is smaller than your content. Increase it, or shorten the content |
2040
+ | Virtualized grid is tiny | It scrolls inside `maxHeight` (400 if unset). Set `maxHeight` |
2041
+ | Arrow keys do nothing | Add `keyboardNavigation` and click or Tab into a cell first |
2042
+ | CSV opens with strange characters in Excel | The file has a BOM and is UTF-8. Use Excel's Data > From Text if your version ignores it |
2043
+ | CSV shows `$52,000` where I wanted `52000` | Add `exportValue: (v) => v` to the column |
2044
+ | Type error on `field: "contact.phone"` | The path must exist in your row type (optional objects are fine). If `rows` is typed loosely (`any`), type the columns as `ColumnDef<Person>[]` |
2045
+ | My checkbox column header is empty | By design: boolean columns default to no label. Set `headerName` to show one |
2046
+ | No row animation | `animateRows` is off, `virtualize` is on, or the system has "reduce motion" turned on |
2047
+ | My Tailwind class doesn't change the grid | Put it in the right slot (`classNames.headerCell`, and so on). The library CSS has zero specificity, so a class in the right place wins |
2048
+ | Edits disappear on the next render | `rows` gets a new identity every render. Keep it in `useState` or `useMemo` |
2049
+ | A custom editor, renderer or skeleton loses focus or resets while typing | The component is defined inline inside render. Move it to module level |
2050
+ | Typing in a custom editor column doesn't start editing | By design: custom editors open with double-click, Enter or F2 |
2051
+ | Enter doesn't move to the next row | The cell below is locked, you are on the last row of the page, or `enterMovesDown` is `false` |
2052
+ | Changing `pageSize` or `defaultSort` after mount does nothing | They are initial values only. Control `page` / `sort` instead |
2053
+ | A checkbox in a boolean column is greyed out and won't toggle | The cell isn't editable. Set `editable` on the grid, or `editable: true` on that column |
2054
+ | Type error: `field` not assignable | `field` must be a key of your row type. Check the spelling, and that `ColumnDef<T>` uses the same `T` as `rows` |
2055
+
2056
+ ---
2057
+
2058
+ ## 10. Migrating from the earlier version
2059
+
2060
+ Three defaults changed so that every feature is opt-in.
2061
+
2062
+ | Before | Now | What to do |
2063
+ | --- | --- | --- |
2064
+ | Grid prop `sortable` (default `true`), column `sortable` override | **Column** `sortable` only, default `false`. The grid-level prop is gone | Add `sortable: true` to each column that should sort. Remove `sortable` / `sortable={false}` from the grid |
2065
+ | `defaultSort` / `onSortChange` used one `SortModel` or `null` | `defaultSort` takes one `SortModel` **or an array**. `onSortChange` receives an **array** (empty when unsorted) | Change `(sort) => sort?.field` to `(sort) => sort[0]?.field` |
2066
+ | `searchable` default `true` | Default `false` | Add `searchable` to grids that should show the search box |
2067
+ | `pagination` default `true` | Default `false`. With it off there is no footer and no row count | Add `pagination` to grids that should page |
2068
+ | Filters were text only | Filter kinds: text, exact, number, date, select | Nothing to change. Number columns now get a Min / Max popup, boolean columns a checkbox list |
2069
+ | `FilterRendererProps` | Unchanged (`value: string`) | |
2070
+
2071
+ A quick way to get the old behaviour everywhere:
2072
+
2073
+ ```tsx
2074
+ // before: <DataGrid columns={columns} rows={rows} />
2075
+ const withSorting = (cols: ColumnDef<Row>[]) => cols.map((c) => ({ sortable: true, ...c }));
2076
+
2077
+ <DataGrid columns={withSorting(columns)} rows={rows} searchable pagination />
2078
+ ```
2079
+
2080
+ Everything else in this document is new and optional.