@kitr/table 0.0.0-stage → 0.3.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/docs/index.md ADDED
@@ -0,0 +1,864 @@
1
+ ---
2
+ title: Tables
3
+ description: Show rows of data with sorting, paging and filters that stay in the URL.
4
+ order: 1
5
+ ---
6
+
7
+ # Tables
8
+
9
+ Admin screens, dashboards and reports all need a table people can sort, page through and filter. Grace sorts Acme's orders by total, filters to the open ones, goes to page 3, and sends Linus the link. `@kitr/table` keeps those choices in the page's URL, so the view survives a reload, works with the back button, and opens the same for Linus.
10
+
11
+ > [!SUMMARY]
12
+ > Sortable, pageable, filterable tables whose state lives in the URL. Add it with `kitr add table`; the overview is at [/packages/table](/packages/table).
13
+
14
+ It is [TanStack Table](https://tanstack.com/table/latest) v9, re-exported unchanged, plus `useUrlTableState()` (binds sorting, pagination and filters to search params), `parseTableSearch()` (validates those params from an untrusted URL) and `dataTableFeatures` (the feature set kitr's `<DataTable>` expects). It runs in the browser, inside React; the examples use [router](/packages/router).
15
+
16
+ The examples build Acme's orders screen at `app.acme.com/orders`: thousands of orders with a customer, a total, a status and a date, served one page at a time by `/api/orders`.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ kitr add table
22
+ ```
23
+
24
+ <details>
25
+ <summary>Everything kitr add changes</summary>
26
+
27
+ - Adds `@kitr/table` and `@tanstack/react-table` to `dependencies` and installs them. Pass `--no-install` to skip the install.
28
+ - Adds `'@kitr/table'` to `packages` in `kitrrc.ts`. It registers no provider.
29
+ - Adds a `## kitr: table` section to `AGENTS.md`.
30
+
31
+ It writes no config file, adds no env variables or scripts, and asks no questions. Tables are a client package, so in a project without React `kitr add` skips it with a warning. The `app`, `fullstack`, `saas`, `desktop` and `mobile` kits include it, along with the `data-table` component.
32
+
33
+ `kitr remove table` takes the package out of `kitrrc.ts`, removes both dependencies and the `AGENTS.md` section, and lists files that still import `@kitr/table`. It leaves `data-table.tsx` in place.
34
+
35
+ </details>
36
+
37
+ The styled table component is yours to edit, so copy it in separately:
38
+
39
+ ```sh
40
+ kitr ui add data-table
41
+ ```
42
+
43
+ That writes `app/components/ui/data-table.tsx`, plus the `button` and `icon` components it uses.
44
+
45
+ ## Configure
46
+
47
+ `@kitr/table` has no config file. Each table is configured where you call `useUrlTableState`:
48
+
49
+ ```ts
50
+ // app/routes.ts
51
+ import { parseTableSearch } from '@kitr/table'
52
+
53
+ r.page('/orders', {
54
+ validateSearch: parseTableSearch,
55
+ lazy: () => import('./pages/orders/index'),
56
+ })
57
+ ```
58
+
59
+ ```tsx
60
+ // app/pages/orders/index.tsx
61
+ import { useUrlTableState } from '@kitr/table'
62
+ import { getRouteApi } from '@kitr/router'
63
+
64
+ const route = getRouteApi('/orders')
65
+
66
+ export default function OrdersPage() {
67
+ const search = route.useSearch()
68
+ const navigate = route.useNavigate()
69
+ const urlState = useUrlTableState({
70
+ search,
71
+ setSearch: (patch) => navigate({ search: (prev) => ({ ...prev, ...patch }) }),
72
+ defaultPageSize: 25,
73
+ })
74
+ // ...
75
+ }
76
+ ```
77
+
78
+ | Option | Default | Description |
79
+ | --- | --- | --- |
80
+ | `search` | required | The current table params, `{ page?, size?, sort?, filter? }`. Usually `getRouteApi(path).useSearch()`. |
81
+ | `setSearch` | required | Merges a patch into the URL. A key set to `undefined` is removed from it. |
82
+ | `defaultPageSize` | `20` | Rows per page when the URL has no `size`. A size equal to it is left out of the URL. |
83
+ | `manual` | `true` | `false` turns the three manual flags off, for data the table sorts and pages itself (with `clientTableFeatures`). |
84
+
85
+ `useUrlTableState` returns options to spread into `useTable` or `<DataTable>`:
86
+
87
+ | Returned | Value |
88
+ | --- | --- |
89
+ | `state` | `{ sorting, pagination, columnFilters }`, derived from `search`. |
90
+ | `onSortingChange`, `onPaginationChange`, `onColumnFiltersChange` | Write the change to the URL through `setSearch`. |
91
+ | `manualSorting`, `manualPagination`, `manualFiltering` | All `true`: the server sorts, pages and filters. See [Sort and page in the browser](#sort-and-page-in-the-browser) to turn them off. |
92
+
93
+ The `<DataTable>` component from `kitr ui add data-table` takes these props, plus any [TanStack table option](https://tanstack.com/table/latest/docs/api/core/table):
94
+
95
+ | Prop | Default | Description |
96
+ | --- | --- | --- |
97
+ | `columns` | required | Column definitions, typed `ColumnDef<DataTableFeatures, Order>[]`. |
98
+ | `data` | required | The rows to show: the current page when the server pages. |
99
+ | `rowCount` | none | Total rows across all pages, so the footer can say "Page 3 of 40" and disable Next on the last page. |
100
+ | `empty` | `'No results.'` | What the body shows when `data` is empty. |
101
+ | `className` | none | Classes for the outer wrapper. |
102
+
103
+ ## Show your first sortable table
104
+
105
+ Three pieces: column definitions, a route that accepts table params, and the table bound to them. Start with the columns:
106
+
107
+ ```tsx
108
+ // app/components/orders/columns.tsx
109
+ import type { ColumnDef, DataTableFeatures } from '@kitr/table'
110
+
111
+ export interface Order {
112
+ id: string
113
+ customer: string
114
+ total: number
115
+ status: 'open' | 'paid' | 'refunded'
116
+ placedAt: string
117
+ }
118
+
119
+ export const columns: ColumnDef<DataTableFeatures, Order>[] = [
120
+ { accessorKey: 'customer', header: 'Customer' },
121
+ {
122
+ accessorKey: 'total',
123
+ header: 'Total',
124
+ cell: ({ row }) => `€${row.original.total.toFixed(2)}`,
125
+ },
126
+ { accessorKey: 'status', header: 'Status', enableSorting: false },
127
+ {
128
+ accessorKey: 'placedAt',
129
+ header: 'Placed',
130
+ cell: ({ row }) => new Date(row.original.placedAt).toLocaleDateString('en-GB'),
131
+ },
132
+ ]
133
+ ```
134
+
135
+ Then the page. `validateSearch: parseTableSearch` makes `page`, `size`, `sort` and `filter` typed search params; the loader fetches the matching page; `useUrlTableState` turns the params into table state:
136
+
137
+ ```ts
138
+ // app/routes.ts
139
+ import { parseTableSearch } from '@kitr/table'
140
+ import { ordersPageQuery } from '#app/queries/orders'
141
+
142
+ r.page('/orders', {
143
+ validateSearch: parseTableSearch,
144
+ loaderDeps: ({ search }) => search,
145
+ loader: ({ context, deps }) => context.queryClient.ensureQueryData(ordersPageQuery.options(deps)),
146
+ lazy: () => import('./pages/orders/index'),
147
+ })
148
+ ```
149
+
150
+ ```tsx
151
+ // app/pages/orders/index.tsx
152
+ import { useSuspenseQuery } from '@kitr/query'
153
+ import { useUrlTableState } from '@kitr/table'
154
+ import { DataTable } from '#components/ui/data-table'
155
+ import { columns } from '#app/components/orders/columns'
156
+ import { ordersPageQuery } from '#app/queries/orders'
157
+ import { getRouteApi } from '@kitr/router'
158
+
159
+ const route = getRouteApi('/orders')
160
+
161
+ export default function OrdersPage() {
162
+ const search = route.useSearch()
163
+ const navigate = route.useNavigate()
164
+ const { data } = useSuspenseQuery(ordersPageQuery.options(search))
165
+
166
+ const urlState = useUrlTableState({
167
+ search,
168
+ setSearch: (patch) => navigate({ search: (prev) => ({ ...prev, ...patch }) }),
169
+ })
170
+
171
+ return <DataTable columns={columns} data={data.rows} rowCount={data.total} {...urlState} />
172
+ }
173
+ ```
174
+
175
+ Open `/orders` and click the Total header. The URL becomes `/orders?sort=-total` and the table shows the largest orders first, even after a reload. [Fetch one page from the server](#fetch-one-page-from-the-server) shows `ordersPageQuery` and the API behind it.
176
+
177
+ ## Define columns
178
+
179
+ A column needs an id and a header. `accessorKey` reads a field and doubles as the id; `cell` renders it; `id` names a column that reads no field, such as a row of buttons:
180
+
181
+ ```tsx
182
+ // app/components/orders/columns.tsx
183
+ import type { ColumnDef, DataTableFeatures } from '@kitr/table'
184
+ import { Badge } from '#components/ui/badge'
185
+ import { Button } from '#components/ui/button'
186
+ import type { Order } from '#app/lib/orders/types'
187
+
188
+ export const columns: ColumnDef<DataTableFeatures, Order>[] = [
189
+ { accessorKey: 'customer', header: 'Customer' },
190
+ {
191
+ accessorKey: 'status',
192
+ header: 'Status',
193
+ enableSorting: false,
194
+ cell: ({ row }) => (
195
+ <Badge variant={row.original.status === 'paid' ? 'default' : 'secondary'}>
196
+ {row.original.status}
197
+ </Badge>
198
+ ),
199
+ },
200
+ {
201
+ id: 'actions',
202
+ header: '',
203
+ enableSorting: false,
204
+ cell: ({ row }) => (
205
+ <Button variant="ghost" size="sm" onClick={() => console.log('refund', row.original.id)}>
206
+ Refund
207
+ </Button>
208
+ ),
209
+ },
210
+ ]
211
+ ```
212
+
213
+ The column id is what appears in the URL (`sort=customer`), so keep ids short and stable: changing one breaks saved links. `enableSorting: false` makes a header plain text in `<DataTable>`.
214
+
215
+ For a typed `getValue()` in each cell, use TanStack's column helper with the same feature set:
216
+
217
+ ```tsx
218
+ // app/components/orders/columns.tsx
219
+ import { createColumnHelper, type DataTableFeatures } from '@kitr/table'
220
+ import type { Order } from '#app/lib/orders/types'
221
+
222
+ const column = createColumnHelper<DataTableFeatures, Order>()
223
+
224
+ export const columns = [
225
+ column.accessor('customer', { header: 'Customer' }),
226
+ column.accessor('total', {
227
+ header: 'Total',
228
+ cell: (info) => `€${info.getValue().toFixed(2)}`,
229
+ }),
230
+ ]
231
+ ```
232
+
233
+ Define columns outside the component, or in `useMemo`, so the table does not rebuild them on every render.
234
+
235
+ ## Keep sorting, paging and filters in the URL
236
+
237
+ `useUrlTableState` reads the table's state from `search` and writes every change back through `setSearch`. The URL is the only copy of the state, so there is nothing to sync. With TanStack Router's default serialiser:
238
+
239
+ | Table state | URL | Notes |
240
+ | --- | --- | --- |
241
+ | Page 3 | `?page=3` | One-based, unlike TanStack's zero-based `pageIndex`. Page 1 is left out. |
242
+ | 50 rows per page | `?size=50` | The default size is left out. `parseTableSearch` accepts up to 500. |
243
+ | Total, largest first | `?sort=-total` | A leading `-` means descending. |
244
+ | Customer A to Z, then newest | `?sort=customer%2C-placedAt` | Comma-separated, in priority order. The comma is URL-encoded. |
245
+ | Status is open | `?filter=%7B%22status%22%3A%22open%22%7D` | `filter` is an object, which the router stores as JSON: `{"status":"open"}`. |
246
+
247
+ Some writes also reset the page, because page 4 of the old order means nothing in the new one:
248
+
249
+ | Change | What `setSearch` receives |
250
+ | --- | --- |
251
+ | Click a header | `{ sort: '-total', page: undefined }` |
252
+ | Clear sorting | `{ sort: undefined, page: undefined }` |
253
+ | Set a filter | `{ filter: { status: 'open' }, page: undefined }` |
254
+ | Clear the last filter | `{ filter: undefined, page: undefined }` |
255
+ | Next page | `{ page: 2, size: undefined }` |
256
+ | Back to page 1 at the default size | `{ page: undefined, size: undefined }` |
257
+
258
+ Clicking a header cycles the sort directions. A number column such as Total starts largest first (`-total`, then `total`, then unsorted); a text column starts A to Z.
259
+
260
+ > [!WARNING] One source of truth
261
+ > Don't keep a copy of the sort or page in `useState` next to the table. The two drift apart, and the back button restores only one.
262
+
263
+ ## Validate the URL
264
+
265
+ Anyone can edit a URL, so every search param is untrusted. `parseTableSearch` keeps what is valid and drops the rest without throwing:
266
+
267
+ ```ts
268
+ // app/lib/orders/search.ts
269
+ import { parseTableSearch } from '@kitr/table'
270
+
271
+ parseTableSearch({ page: '2', size: '50', sort: '-total,customer', filter: { status: 'open' } })
272
+ // => { page: 2, size: 50, sort: '-total,customer', filter: { status: 'open' } }
273
+
274
+ parseTableSearch({ page: '0', size: '9999', sort: 'total;drop table', filter: 'open' })
275
+ // => {}
276
+
277
+ parseTableSearch({ page: '1.5', filter: { status: 'open', total: 100 } })
278
+ // => { filter: { status: 'open' } }
279
+ ```
280
+
281
+ | Param | Kept when |
282
+ | --- | --- |
283
+ | `page` | A whole number of 1 or more. |
284
+ | `size` | A whole number from 1 to 500. |
285
+ | `sort` | Comma-separated column ids of letters, digits, `_` and `.`, each optionally prefixed with `-`. |
286
+ | `filter` | An object; only its string values are kept. |
287
+
288
+ For params of your own, such as a search box, call it inside your own `validateSearch`:
289
+
290
+ ```ts
291
+ // app/routes.ts
292
+ import { parseTableSearch } from '@kitr/table'
293
+
294
+ r.page('/orders', {
295
+ validateSearch: (raw: Record<string, unknown>) => ({
296
+ ...parseTableSearch(raw),
297
+ q: typeof raw.q === 'string' && raw.q.length <= 100 ? raw.q : undefined,
298
+ }),
299
+ lazy: () => import('./pages/orders/index'),
300
+ })
301
+ ```
302
+
303
+ ```tsx
304
+ // app/pages/orders/index.tsx
305
+ import { getRouteApi } from '@kitr/router'
306
+
307
+ const route = getRouteApi('/orders')
308
+
309
+ export default function OrdersPage() {
310
+ const { q } = route.useSearch()
311
+ return <p>Searching for {q}</p>
312
+ }
313
+ ```
314
+
315
+ The sort pattern allows dotted ids such as `customer.name`. Your API should still accept only the columns it knows.
316
+
317
+ ## Fetch one page from the server
318
+
319
+ `useUrlTableState` sets `manualSorting`, `manualPagination` and `manualFiltering`: the server sorts, filters and slices, and the table shows what came back. Turn the search into a request in the query:
320
+
321
+ ```ts
322
+ // app/queries/orders.ts
323
+ import { queryFrom } from '@kitr/query'
324
+ import type { TableSearch } from '@kitr/table'
325
+ import type { Order } from './columns'
326
+
327
+ export interface OrdersPage {
328
+ rows: Order[]
329
+ total: number
330
+ }
331
+
332
+ export const ordersPageQuery = queryFrom<TableSearch, OrdersPage>({
333
+ key: (search) => ['orders', 'page', search],
334
+ fetch: async (search, { signal }) => {
335
+ const params = new URLSearchParams()
336
+ if (search.page) params.set('page', String(search.page))
337
+ if (search.size) params.set('size', String(search.size))
338
+ if (search.sort) params.set('sort', search.sort)
339
+ for (const [column, value] of Object.entries(search.filter ?? {})) params.set(column, value)
340
+ const res = await fetch(`/api/orders?${params}`, { signal })
341
+ return (await res.json()) as OrdersPage
342
+ },
343
+ })
344
+ ```
345
+
346
+ The whole `search` object is in the key, so every view is cached on its own and the back button shows the previous page instantly. On the server, read the same params, allow only known columns, and return the page with the total:
347
+
348
+ ```ts
349
+ // server/controllers/orders-controller.ts
350
+ import type { HttpContext } from '@kitr/api'
351
+ import { parseSort } from '@kitr/table'
352
+ import { type Order, orders } from '#server/orders/data'
353
+
354
+ const sorters = {
355
+ customer: (a: Order, b: Order) => a.customer.localeCompare(b.customer),
356
+ total: (a: Order, b: Order) => a.total - b.total,
357
+ placedAt: (a: Order, b: Order) => a.placedAt.localeCompare(b.placedAt),
358
+ }
359
+
360
+ export default class OrdersController {
361
+ async index({ query }: HttpContext<Record<string, string>, Record<string, string>>) {
362
+ const page = Math.max(1, Number(query.page) || 1)
363
+ const size = Math.min(500, Number(query.size) || 20)
364
+ const sorting = parseSort(query.sort).filter((s) => s.id in sorters)
365
+
366
+ const rows = query.status ? orders.filter((o) => o.status === query.status) : [...orders]
367
+ rows.sort((a, b) => {
368
+ for (const { id, desc } of sorting) {
369
+ const result = sorters[id as keyof typeof sorters](a, b)
370
+ if (result) return desc ? -result : result
371
+ }
372
+ return 0
373
+ })
374
+ return { rows: rows.slice((page - 1) * size, page * size), total: rows.length }
375
+ }
376
+ }
377
+ ```
378
+
379
+ `parseSort('-total,customer')` returns `[{ id: 'total', desc: true }, { id: 'customer', desc: false }]`, and `formatSort()` reverses it. Use the same page size default on both sides, or always pass `size`. Pass the total as `rowCount`, or Next stays disabled.
380
+
381
+ > [!WARNING] Allow-list sort columns
382
+ > Only ids in `sorters` are used, so a hand-edited `?sort=password` sorts nothing. With a database, build the `ORDER BY` from such an allow-list, never from the raw string.
383
+
384
+ `@kitr/table` imports React; in a server without React, split the sort string yourself.
385
+
386
+ ## Filter by a column
387
+
388
+ A filter is a column id and a string value. Set it from any input with `column.setFilterValue()`; `useUrlTableState` writes it to the URL and resets to page 1. An empty string removes it:
389
+
390
+ ```tsx
391
+ // app/components/orders/status-filter.tsx
392
+ import type { DataTableFeatures, ReactTable } from '@kitr/table'
393
+ import type { Order } from './columns'
394
+
395
+ export function StatusFilter({ table }: { table: ReactTable<DataTableFeatures, Order> }) {
396
+ const column = table.getColumn('status')
397
+ return (
398
+ <select
399
+ aria-label="Status"
400
+ value={(column?.getFilterValue() as string | undefined) ?? ''}
401
+ onChange={(event) => column?.setFilterValue(event.target.value)}
402
+ >
403
+ <option value="">All statuses</option>
404
+ <option value="open">Open</option>
405
+ <option value="paid">Paid</option>
406
+ <option value="refunded">Refunded</option>
407
+ </select>
408
+ )
409
+ }
410
+ ```
411
+
412
+ `<DataTable>` builds its table inside, so a filter outside it writes the URL directly, which is the same thing because the URL is the state:
413
+
414
+ ```ts
415
+ // app/routes.ts
416
+ import { parseTableSearch } from '@kitr/table'
417
+
418
+ r.page('/orders', {
419
+ validateSearch: parseTableSearch,
420
+ lazy: () => import('./pages/orders/index'),
421
+ })
422
+ ```
423
+
424
+ ```tsx
425
+ // app/pages/orders/index.tsx
426
+ import { getRouteApi } from '@kitr/router'
427
+
428
+ const route = getRouteApi('/orders')
429
+
430
+ export default function OrdersToolbar() {
431
+ const search = route.useSearch()
432
+ const navigate = route.useNavigate()
433
+ return (
434
+ <select
435
+ aria-label="Status"
436
+ value={search.filter?.status ?? ''}
437
+ onChange={(event) => {
438
+ const status = event.target.value
439
+ const { status: _, ...rest } = search.filter ?? {}
440
+ const filter = status ? { ...rest, status } : rest
441
+ navigate({
442
+ search: (prev) => ({
443
+ ...prev,
444
+ filter: Object.keys(filter).length ? filter : undefined,
445
+ page: undefined,
446
+ }),
447
+ })
448
+ }}
449
+ >
450
+ <option value="">All statuses</option>
451
+ <option value="open">Open</option>
452
+ <option value="paid">Paid</option>
453
+ </select>
454
+ )
455
+ }
456
+ ```
457
+
458
+ Filter values are always strings in the URL, so convert a number filter (`minTotal=100`) on the server. For free-text search across columns, add your own `q` param as in [Validate the URL](#validate-the-url).
459
+
460
+ ## Change the page size
461
+
462
+ `table.setPageSize()` writes `size` to the URL and keeps the first visible row on screen:
463
+
464
+ ```tsx
465
+ // app/components/orders/page-size.tsx
466
+ import type { DataTableFeatures, ReactTable } from '@kitr/table'
467
+ import type { Order } from './columns'
468
+
469
+ export function PageSize({ table }: { table: ReactTable<DataTableFeatures, Order> }) {
470
+ return (
471
+ <select
472
+ aria-label="Rows per page"
473
+ value={table.state.pagination.pageSize}
474
+ onChange={(event) => table.setPageSize(Number(event.target.value))}
475
+ >
476
+ {[20, 50, 100].map((size) => (
477
+ <option key={size} value={size}>
478
+ {size} rows
479
+ </option>
480
+ ))}
481
+ </select>
482
+ )
483
+ }
484
+ // choosing 50 => setSearch({ page: undefined, size: 50 })
485
+ ```
486
+
487
+ The default size is removed from the URL, and `parseTableSearch` drops sizes above 500, so a hand-edited `?size=100000` falls back to the default.
488
+
489
+ ## Sort and page in the browser
490
+
491
+ For a small list that arrives in one response, such as one customer's twenty orders, let the table sort, filter and page itself. Add TanStack's row models to the feature set, and turn the manual flags off after the spread:
492
+
493
+ ```tsx
494
+ // app/components/orders/customer-orders.tsx
495
+ import {
496
+ type ColumnDef,
497
+ createFilteredRowModel,
498
+ createPaginatedRowModel,
499
+ createSortedRowModel,
500
+ dataTableFeatures,
501
+ filterFn_equalsString,
502
+ sortFn_alphanumeric,
503
+ sortFn_basic,
504
+ type TableSearch,
505
+ tableFeatures,
506
+ useTable,
507
+ useUrlTableState,
508
+ } from '@kitr/table'
509
+ import type { Order } from './columns'
510
+
511
+ const clientFeatures = tableFeatures({
512
+ ...dataTableFeatures,
513
+ sortedRowModel: createSortedRowModel(),
514
+ paginatedRowModel: createPaginatedRowModel(),
515
+ filteredRowModel: createFilteredRowModel(),
516
+ sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
517
+ filterFns: { equalsString: filterFn_equalsString },
518
+ })
519
+
520
+ const columns: ColumnDef<typeof clientFeatures, Order>[] = [
521
+ { accessorKey: 'customer', header: 'Customer' },
522
+ { accessorKey: 'total', header: 'Total' },
523
+ { accessorKey: 'status', header: 'Status', filterFn: 'equalsString' },
524
+ ]
525
+
526
+ export function CustomerOrders(props: {
527
+ orders: Order[]
528
+ search: TableSearch
529
+ setSearch: (patch: Partial<TableSearch>) => void
530
+ }) {
531
+ const urlState = useUrlTableState({ ...props, defaultPageSize: 10 })
532
+ const table = useTable({
533
+ features: clientFeatures,
534
+ columns,
535
+ data: props.orders,
536
+ ...urlState,
537
+ manualSorting: false,
538
+ manualPagination: false,
539
+ manualFiltering: false,
540
+ })
541
+ return <p>{table.getRowModel().rows.map((row) => row.original.customer).join(', ')}</p>
542
+ }
543
+ ```
544
+
545
+ Given Grace's 120, Ada's 80 and Linus's 300 and `?sort=-total`, the rows come out Linus, Grace, Ada. The state still lives in the URL; only the work moved to the browser.
546
+
547
+ > [!WARNING] Add the row models
548
+ > Turning the flags off is not enough: `dataTableFeatures` has no row models, so without `sortedRowModel` and the rest the table keeps your row order, whatever the URL says.
549
+
550
+ `clientTableFeatures` is that feature set ready-made. With it, `useUrlTableState({ search, setSearch, manual: false })` is all a client-side table needs, and `<DataTable columns={columns} data={rows} clientSide {...tableState} />` does both.
551
+
552
+ ## Hide columns
553
+
554
+ `dataTableFeatures` includes column visibility. Keep it in component state, or in [state](/packages/state) to remember it across visits, and merge it into the URL state:
555
+
556
+ ```tsx
557
+ // app/components/orders/orders-table.tsx
558
+ import {
559
+ type ColumnDef,
560
+ type DataTableFeatures,
561
+ dataTableFeatures,
562
+ type TableSearch,
563
+ useTable,
564
+ useUrlTableState,
565
+ type VisibilityState,
566
+ } from '@kitr/table'
567
+ import { useState } from 'react'
568
+ import type { Order } from './columns'
569
+
570
+ export function OrdersTable(props: {
571
+ columns: ColumnDef<DataTableFeatures, Order>[]
572
+ rows: Order[]
573
+ search: TableSearch
574
+ setSearch: (patch: Partial<TableSearch>) => void
575
+ }) {
576
+ const urlState = useUrlTableState(props)
577
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ placedAt: false })
578
+ const table = useTable({
579
+ features: dataTableFeatures,
580
+ columns: props.columns,
581
+ data: props.rows,
582
+ ...urlState,
583
+ state: { ...urlState.state, columnVisibility },
584
+ onColumnVisibilityChange: setColumnVisibility,
585
+ })
586
+
587
+ return (
588
+ <fieldset>
589
+ {table.getAllLeafColumns().map((column) => (
590
+ <label key={column.id}>
591
+ <input
592
+ type="checkbox"
593
+ checked={column.getIsVisible()}
594
+ onChange={column.getToggleVisibilityHandler()}
595
+ />
596
+ {column.id}
597
+ </label>
598
+ ))}
599
+ </fieldset>
600
+ )
601
+ }
602
+ ```
603
+
604
+ Spread `urlState` first and merge `urlState.state` into `state`: writing `state: { columnVisibility }` alone would drop sorting, paging and filters. Visibility is a viewer's preference, so it stays out of the URL.
605
+
606
+ ## Select rows
607
+
608
+ Row selection is not in the default set. Add `rowSelectionFeature` to a feature set of your own, and give rows a stable id so a selection survives a refetch:
609
+
610
+ ```tsx
611
+ // app/components/orders/refund-table.tsx
612
+ import {
613
+ type ColumnDef,
614
+ dataTableFeatures,
615
+ type RowSelectionState,
616
+ rowSelectionFeature,
617
+ tableFeatures,
618
+ useTable,
619
+ } from '@kitr/table'
620
+ import { useState } from 'react'
621
+ import type { Order } from './columns'
622
+
623
+ const features = tableFeatures({ ...dataTableFeatures, rowSelectionFeature })
624
+
625
+ const columns: ColumnDef<typeof features, Order>[] = [{ accessorKey: 'customer', header: 'Customer' }]
626
+
627
+ export function RefundTable({ orders }: { orders: Order[] }) {
628
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
629
+ const table = useTable({
630
+ features,
631
+ columns,
632
+ data: orders,
633
+ getRowId: (order) => order.id,
634
+ state: { rowSelection },
635
+ onRowSelectionChange: setRowSelection,
636
+ })
637
+
638
+ return (
639
+ <form>
640
+ {table.getRowModel().rows.map((row) => (
641
+ <label key={row.id}>
642
+ <input
643
+ type="checkbox"
644
+ checked={row.getIsSelected()}
645
+ onChange={row.getToggleSelectedHandler()}
646
+ />
647
+ {row.original.customer}
648
+ </label>
649
+ ))}
650
+ <button type="submit" disabled={!table.getSelectedRowModel().rows.length}>
651
+ Refund {table.getSelectedRowModel().rows.length} orders
652
+ </button>
653
+ </form>
654
+ )
655
+ }
656
+ ```
657
+
658
+ Checking Grace's order 1001 sets `rowSelection` to `{ '1001': true }`. Without `getRowId`, rows are keyed by page index, so a selection points at different orders after a sort. `<DataTable>` is typed for `dataTableFeatures`; for extra features, edit your copy of `data-table.tsx` or render the rows yourself.
659
+
660
+ ## Render your own markup
661
+
662
+ `<DataTable>` is one rendering. For another, such as cards on phones, call `useTable` with `dataTableFeatures` and render with `table.FlexRender`:
663
+
664
+ ```tsx
665
+ // app/components/orders/orders-grid.tsx
666
+ import {
667
+ type ColumnDef,
668
+ type DataTableFeatures,
669
+ dataTableFeatures,
670
+ type TableSearch,
671
+ useTable,
672
+ useUrlTableState,
673
+ } from '@kitr/table'
674
+ import type { Order } from './columns'
675
+
676
+ export function OrdersGrid(props: {
677
+ columns: ColumnDef<DataTableFeatures, Order>[]
678
+ rows: Order[]
679
+ total: number
680
+ search: TableSearch
681
+ setSearch: (patch: Partial<TableSearch>) => void
682
+ }) {
683
+ const table = useTable({
684
+ features: dataTableFeatures,
685
+ columns: props.columns,
686
+ data: props.rows,
687
+ rowCount: props.total,
688
+ ...useUrlTableState(props),
689
+ })
690
+
691
+ return (
692
+ <table>
693
+ <thead>
694
+ {table.getHeaderGroups().map((group) => (
695
+ <tr key={group.id}>
696
+ {group.headers.map((header) => (
697
+ <th key={header.id} aria-sort={header.column.getIsSorted() === 'desc' ? 'descending' : header.column.getIsSorted() === 'asc' ? 'ascending' : 'none'}>
698
+ <button
699
+ type="button"
700
+ disabled={!header.column.getCanSort()}
701
+ onClick={header.column.getToggleSortingHandler()}
702
+ >
703
+ <table.FlexRender header={header} />
704
+ </button>
705
+ </th>
706
+ ))}
707
+ </tr>
708
+ ))}
709
+ </thead>
710
+ <tbody>
711
+ {table.getRowModel().rows.map((row) => (
712
+ <tr key={row.id}>
713
+ {row.getVisibleCells().map((cell) => (
714
+ <td key={cell.id}>
715
+ <table.FlexRender cell={cell} />
716
+ </td>
717
+ ))}
718
+ </tr>
719
+ ))}
720
+ </tbody>
721
+ <tfoot>
722
+ <tr>
723
+ <td>
724
+ Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
725
+ <button type="button" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
726
+ Next
727
+ </button>
728
+ </td>
729
+ </tr>
730
+ </tfoot>
731
+ </table>
732
+ )
733
+ }
734
+ ```
735
+
736
+ With 45 orders and the default size of 20, `table.getPageCount()` is `3`. [TanStack's table guides](https://tanstack.com/table/latest/docs/guide/tables) cover header groups, footers, pinning and the rest.
737
+
738
+ ## Keep table state out of the URL
739
+
740
+ `useUrlTableState` only needs `search` and `setSearch`, so any state store works. For a table in a dialog, where the URL should not change, use `useState`:
741
+
742
+ ```tsx
743
+ // app/components/orders/pick-order-dialog.tsx
744
+ import { type ColumnDef, type DataTableFeatures, type TableSearch, useUrlTableState } from '@kitr/table'
745
+ import { useState } from 'react'
746
+ import { DataTable } from '#components/ui/data-table'
747
+ import type { Order } from './columns'
748
+
749
+ export function PickOrderDialog(props: { columns: ColumnDef<DataTableFeatures, Order>[]; orders: Order[] }) {
750
+ const [search, setSearchState] = useState<TableSearch>({})
751
+ const urlState = useUrlTableState({
752
+ search,
753
+ setSearch: (patch) => setSearchState((prev) => ({ ...prev, ...patch })),
754
+ defaultPageSize: 10,
755
+ })
756
+ return <DataTable columns={props.columns} data={props.orders} {...urlState} />
757
+ }
758
+ ```
759
+
760
+ Closing the dialog forgets the sort; everything else behaves as it does in the URL.
761
+
762
+ ## Testing
763
+
764
+ A test renders the table with a `search` object and a mock `setSearch`, clicks, and asserts what would have gone into the URL. No router is needed:
765
+
766
+ ```tsx
767
+ // app/components/orders/orders-grid.test.tsx
768
+ import { fireEvent, render, screen } from '@testing-library/react'
769
+ import {
770
+ type ColumnDef,
771
+ type DataTableFeatures,
772
+ dataTableFeatures,
773
+ type TableSearch,
774
+ useTable,
775
+ useUrlTableState,
776
+ } from '@kitr/table'
777
+ import { describe, expect, it, vi } from 'vitest'
778
+
779
+ interface Order {
780
+ id: string
781
+ customer: string
782
+ total: number
783
+ status: string
784
+ }
785
+
786
+ const orders: Order[] = [
787
+ { id: '1001', customer: 'Grace', total: 120, status: 'open' },
788
+ { id: '1002', customer: 'Ada', total: 80, status: 'paid' },
789
+ ]
790
+
791
+ const columns: ColumnDef<DataTableFeatures, Order>[] = [
792
+ { accessorKey: 'customer', header: 'Customer' },
793
+ { accessorKey: 'total', header: 'Total' },
794
+ { accessorKey: 'status', header: 'Status', enableSorting: false },
795
+ ]
796
+
797
+ function OrdersGrid(props: { search: TableSearch; setSearch: (patch: Partial<TableSearch>) => void }) {
798
+ const table = useTable({
799
+ features: dataTableFeatures,
800
+ columns,
801
+ data: orders,
802
+ rowCount: 45,
803
+ ...useUrlTableState(props),
804
+ })
805
+ return (
806
+ <div>
807
+ {table.getHeaderGroups()[0]?.headers.map((header) => (
808
+ <button
809
+ key={header.id}
810
+ type="button"
811
+ disabled={!header.column.getCanSort()}
812
+ onClick={header.column.getToggleSortingHandler()}
813
+ >
814
+ <table.FlexRender header={header} />
815
+ </button>
816
+ ))}
817
+ <button type="button" onClick={() => table.nextPage()}>
818
+ Next
819
+ </button>
820
+ <p>
821
+ Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
822
+ </p>
823
+ </div>
824
+ )
825
+ }
826
+
827
+ describe('orders grid', () => {
828
+ it('reads the page from the URL', () => {
829
+ render(<OrdersGrid search={{ page: 2 }} setSearch={vi.fn()} />)
830
+ expect(screen.getByText('Page 2 of 3')).toBeTruthy()
831
+ })
832
+
833
+ it('sorts by total, largest first, and resets the page', () => {
834
+ const setSearch = vi.fn()
835
+ render(<OrdersGrid search={{ page: 2 }} setSearch={setSearch} />)
836
+
837
+ fireEvent.click(screen.getByRole('button', { name: 'Total' }))
838
+
839
+ expect(setSearch).toHaveBeenLastCalledWith({ sort: '-total', page: undefined })
840
+ })
841
+
842
+ it('writes the next page to the URL', () => {
843
+ const setSearch = vi.fn()
844
+ render(<OrdersGrid search={{}} setSearch={setSearch} />)
845
+
846
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
847
+
848
+ expect(setSearch).toHaveBeenLastCalledWith({ page: 2, size: undefined })
849
+ })
850
+
851
+ it('does not sort by status', () => {
852
+ render(<OrdersGrid search={{}} setSearch={vi.fn()} />)
853
+ expect((screen.getByRole('button', { name: 'Status' }) as HTMLButtonElement).disabled).toBe(true)
854
+ })
855
+ })
856
+ ```
857
+
858
+ To test the route's own `validateSearch`, call `parseTableSearch` with the raw params a link could carry, as in [Validate the URL](#validate-the-url). To render the real page with its loader, see [testing](/packages/testing#render-a-whole-page).
859
+
860
+ ## Next steps
861
+
862
+ - [API reference](/packages/table/api): every export of `@kitr/table`.
863
+ - [Router](/packages/router): `validateSearch`, `loaderDeps` and `navigate`, which the URL state uses.
864
+ - [Query](/packages/query): cache each page of rows by its search params.