@tanstack/react-table 9.0.0-alpha.9 → 9.0.0-beta.2
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 +127 -0
- package/dist/FlexRender.cjs +61 -0
- package/dist/FlexRender.cjs.map +1 -0
- package/dist/FlexRender.d.cts +51 -0
- package/dist/FlexRender.d.ts +51 -0
- package/dist/FlexRender.js +58 -0
- package/dist/FlexRender.js.map +1 -0
- package/dist/Subscribe.cjs +13 -0
- package/dist/Subscribe.cjs.map +1 -0
- package/dist/Subscribe.d.cts +101 -0
- package/dist/Subscribe.d.ts +101 -0
- package/dist/Subscribe.js +13 -0
- package/dist/Subscribe.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.cjs +29 -0
- package/dist/createTableHook.cjs +313 -0
- package/dist/createTableHook.cjs.map +1 -0
- package/dist/createTableHook.d.cts +358 -0
- package/dist/createTableHook.d.ts +358 -0
- package/dist/createTableHook.js +311 -0
- package/dist/createTableHook.js.map +1 -0
- package/dist/flex-render.cjs +5 -0
- package/dist/flex-render.d.cts +2 -0
- package/dist/flex-render.d.ts +2 -0
- package/dist/flex-render.js +3 -0
- package/dist/index.cjs +18 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/legacy.cjs +14 -0
- package/dist/legacy.d.cts +2 -0
- package/dist/legacy.d.ts +2 -0
- package/dist/legacy.js +3 -0
- package/dist/reactivity.cjs +34 -0
- package/dist/reactivity.cjs.map +1 -0
- package/dist/reactivity.js +34 -0
- package/dist/reactivity.js.map +1 -0
- package/dist/static-functions.cjs +9 -0
- package/dist/static-functions.d.cts +1 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +3 -0
- package/dist/useLegacyTable.cjs +191 -0
- package/dist/useLegacyTable.cjs.map +1 -0
- package/dist/useLegacyTable.d.cts +233 -0
- package/dist/useLegacyTable.d.ts +233 -0
- package/dist/useLegacyTable.js +181 -0
- package/dist/useLegacyTable.js.map +1 -0
- package/dist/useTable.cjs +72 -0
- package/dist/useTable.cjs.map +1 -0
- package/dist/useTable.d.cts +122 -0
- package/dist/useTable.d.ts +122 -0
- package/dist/useTable.js +72 -0
- package/dist/useTable.js.map +1 -0
- package/package.json +41 -22
- package/skills/react/client-to-server/SKILL.md +377 -0
- package/skills/react/compose-with-tanstack-form/SKILL.md +363 -0
- package/skills/react/compose-with-tanstack-pacer/SKILL.md +287 -0
- package/skills/react/compose-with-tanstack-query/SKILL.md +467 -0
- package/skills/react/compose-with-tanstack-store/SKILL.md +347 -0
- package/skills/react/compose-with-tanstack-virtual/SKILL.md +388 -0
- package/skills/react/compose-with-tanstack-virtual/references/column-virtualization-and-infinite-scroll.md +136 -0
- package/skills/react/getting-started/SKILL.md +388 -0
- package/skills/react/migrate-v8-to-v9/SKILL.md +488 -0
- package/skills/react/production-readiness/SKILL.md +341 -0
- package/skills/react/react-subscribe-compiler-compat/SKILL.md +269 -0
- package/skills/react/table-state/SKILL.md +432 -0
- package/src/FlexRender.tsx +136 -0
- package/src/Subscribe.ts +153 -0
- package/src/createTableHook.tsx +1121 -0
- package/src/flex-render.ts +1 -0
- package/src/index.ts +6 -0
- package/src/legacy.ts +3 -0
- package/src/reactivity.ts +41 -0
- package/src/static-functions.ts +1 -0
- package/src/useLegacyTable.ts +487 -0
- package/src/useTable.ts +191 -0
- package/dist/cjs/index.cjs +0 -77
- package/dist/cjs/index.cjs.map +0 -1
- package/dist/cjs/index.d.cts +0 -9
- package/dist/esm/index.d.ts +0 -9
- package/dist/esm/index.js +0 -55
- package/dist/esm/index.js.map +0 -1
- package/src/index.tsx +0 -92
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react/compose-with-tanstack-form
|
|
3
|
+
description: >
|
|
4
|
+
Editable cells for `@tanstack/react-table` v9 via `@tanstack/react-form`. The
|
|
5
|
+
table is the layout primitive; the form owns editing state. Use
|
|
6
|
+
`createFormHook` to register reusable field components (`TextField`,
|
|
7
|
+
`NumberField`, `SelectField`), then in each column's `cell` return
|
|
8
|
+
`<form.AppField name={`data[${row.index}].field`}>{(field) => <field.TextField />}</form.AppField>`.
|
|
9
|
+
Critical typing gotcha: if your row has a recursive `subRows`, use
|
|
10
|
+
`Omit<Row, 'subRows'>` for the form row type — TanStack Form's `DeepKeys`
|
|
11
|
+
recurses and hits TS2589. Subscribe to `form.state.values.data.length` (not
|
|
12
|
+
the whole array) for row add/remove re-renders.
|
|
13
|
+
type: composition
|
|
14
|
+
library: tanstack-table
|
|
15
|
+
framework: react
|
|
16
|
+
library_version: '9.0.0-alpha.48'
|
|
17
|
+
requires:
|
|
18
|
+
- row-selection
|
|
19
|
+
- column-definitions
|
|
20
|
+
- react/table-state
|
|
21
|
+
sources:
|
|
22
|
+
- TanStack/table:examples/react/with-tanstack-form/src/main.tsx
|
|
23
|
+
- TanStack/table:examples/react/with-tanstack-form/src/form.tsx
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
This skill builds on `tanstack-table/state-management`, `tanstack-table/react/table-state`, and `tanstack-table/column-definitions`. Read those first.
|
|
27
|
+
|
|
28
|
+
## Why this exists
|
|
29
|
+
|
|
30
|
+
TanStack Table v9 deliberately ships no built-in editing — Kevin (the maintainer) scoped it out in favor of composing with TanStack Form. The form owns row-level state, validation, dirty tracking, submit; the table is the layout/sort/filter/paginate engine. This is the v9-blessed answer to "how do I make editable cells?"
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @tanstack/react-table @tanstack/react-form zod
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Define your field components and a form hook in a `form.tsx` module. Source: `examples/react/with-tanstack-form/src/form.tsx`.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
|
|
42
|
+
|
|
43
|
+
const { fieldContext, formContext } = createFormHookContexts()
|
|
44
|
+
|
|
45
|
+
function TextField() {
|
|
46
|
+
/* reads field state from fieldContext */
|
|
47
|
+
}
|
|
48
|
+
function NumberField() {
|
|
49
|
+
/* … */
|
|
50
|
+
}
|
|
51
|
+
function SelectField() {
|
|
52
|
+
/* … */
|
|
53
|
+
}
|
|
54
|
+
function SubmitButton() {
|
|
55
|
+
/* … */
|
|
56
|
+
}
|
|
57
|
+
function FormStateIndicator() {
|
|
58
|
+
/* … */
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const { useAppForm } = createFormHook({
|
|
62
|
+
fieldComponents: { TextField, NumberField, SelectField },
|
|
63
|
+
formComponents: { SubmitButton, FormStateIndicator },
|
|
64
|
+
fieldContext,
|
|
65
|
+
formContext,
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Core Pattern — editable people table
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import * as React from 'react'
|
|
73
|
+
import {
|
|
74
|
+
useTable,
|
|
75
|
+
tableFeatures,
|
|
76
|
+
columnFilteringFeature,
|
|
77
|
+
rowPaginationFeature,
|
|
78
|
+
createColumnHelper,
|
|
79
|
+
createFilteredRowModel,
|
|
80
|
+
createPaginatedRowModel,
|
|
81
|
+
filterFns,
|
|
82
|
+
} from '@tanstack/react-table'
|
|
83
|
+
import { useStore } from '@tanstack/react-form'
|
|
84
|
+
import { z } from 'zod'
|
|
85
|
+
import { useAppForm } from './form'
|
|
86
|
+
import type { Person } from './makeData'
|
|
87
|
+
|
|
88
|
+
// CRITICAL: flatten recursive subRows before handing rows to the form.
|
|
89
|
+
// Without Omit, TanStack Form's DeepKeys walks subRows and hits TS2589.
|
|
90
|
+
type FormRow = Omit<Person, 'subRows'>
|
|
91
|
+
|
|
92
|
+
const features = tableFeatures({
|
|
93
|
+
rowPaginationFeature,
|
|
94
|
+
columnFilteringFeature,
|
|
95
|
+
})
|
|
96
|
+
const columnHelper = createColumnHelper<typeof features, FormRow>()
|
|
97
|
+
|
|
98
|
+
function App() {
|
|
99
|
+
const initialData: FormRow[] = makeData(100)
|
|
100
|
+
|
|
101
|
+
const form = useAppForm({
|
|
102
|
+
defaultValues: { data: initialData },
|
|
103
|
+
onSubmit: ({ value }) => {
|
|
104
|
+
alert(`Submitted ${value.data.length} records`)
|
|
105
|
+
},
|
|
106
|
+
validators: { onChange: z.object({ data: z.array(personSchema) }) },
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Memo'd columns — field bindings close over `form`, so without memoization
|
|
110
|
+
// we'd build new column defs on every keystroke.
|
|
111
|
+
const columns = React.useMemo(
|
|
112
|
+
() =>
|
|
113
|
+
columnHelper.columns([
|
|
114
|
+
columnHelper.accessor('firstName', {
|
|
115
|
+
header: 'First Name',
|
|
116
|
+
cell: ({ row }) => (
|
|
117
|
+
<form.AppField
|
|
118
|
+
name={`data[${row.index}].firstName`}
|
|
119
|
+
validators={{ onChange: z.string().min(1, 'Required') }}
|
|
120
|
+
>
|
|
121
|
+
{(field) => <field.TextField />}
|
|
122
|
+
</form.AppField>
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
columnHelper.accessor('age', {
|
|
126
|
+
header: 'Age',
|
|
127
|
+
cell: ({ row }) => (
|
|
128
|
+
<form.AppField
|
|
129
|
+
name={`data[${row.index}].age`}
|
|
130
|
+
validators={{ onChange: z.number().min(0).max(150) }}
|
|
131
|
+
>
|
|
132
|
+
{(field) => <field.NumberField />}
|
|
133
|
+
</form.AppField>
|
|
134
|
+
),
|
|
135
|
+
}),
|
|
136
|
+
columnHelper.accessor('status', {
|
|
137
|
+
header: 'Status',
|
|
138
|
+
cell: ({ row }) => (
|
|
139
|
+
<form.AppField name={`data[${row.index}].status`}>
|
|
140
|
+
{(field) => <field.SelectField />}
|
|
141
|
+
</form.AppField>
|
|
142
|
+
),
|
|
143
|
+
}),
|
|
144
|
+
]),
|
|
145
|
+
[form],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// Subscribe ONLY to length — triggers re-renders on add/remove without infinite loops
|
|
149
|
+
// (vs subscribing to data, which fires on every keystroke).
|
|
150
|
+
const dataLength = useStore(form.store, (state) => state.values.data.length)
|
|
151
|
+
void dataLength
|
|
152
|
+
|
|
153
|
+
const table = useTable({
|
|
154
|
+
features,
|
|
155
|
+
rowModels: {
|
|
156
|
+
filteredRowModel: createFilteredRowModel(filterFns),
|
|
157
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
158
|
+
},
|
|
159
|
+
columns,
|
|
160
|
+
data: form.state.values.data, // table reads fresh form values each render
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const addRow = () =>
|
|
164
|
+
form.pushFieldValue('data', {
|
|
165
|
+
firstName: '',
|
|
166
|
+
lastName: '',
|
|
167
|
+
age: 0,
|
|
168
|
+
visits: 0,
|
|
169
|
+
progress: 0,
|
|
170
|
+
status: 'single',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const refreshData = () => form.reset({ data: makeData(100) })
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<button onClick={addRow}>Add Row</button>
|
|
178
|
+
<button onClick={refreshData}>Refresh Data</button>
|
|
179
|
+
<table>
|
|
180
|
+
<thead>{/* … */}</thead>
|
|
181
|
+
<tbody>
|
|
182
|
+
{table.getRowModel().rows.map((row) => (
|
|
183
|
+
<tr key={row.id}>
|
|
184
|
+
{row.getAllCells().map((cell) => (
|
|
185
|
+
<td key={cell.id}>
|
|
186
|
+
<table.FlexRender cell={cell} />
|
|
187
|
+
</td>
|
|
188
|
+
))}
|
|
189
|
+
</tr>
|
|
190
|
+
))}
|
|
191
|
+
</tbody>
|
|
192
|
+
</table>
|
|
193
|
+
<form.SubmitButton />
|
|
194
|
+
</>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Source: `examples/react/with-tanstack-form/src/main.tsx`.
|
|
200
|
+
|
|
201
|
+
## Add / remove rows
|
|
202
|
+
|
|
203
|
+
`form.pushFieldValue('data', newRow)` adds; `form.removeFieldValue('data', index)` removes; `form.reset({ data })` replaces. The `useStore` subscription on `state.values.data.length` re-renders the holder so the table sees the new array length and renders the new row.
|
|
204
|
+
|
|
205
|
+
## Common Mistakes
|
|
206
|
+
|
|
207
|
+
### CRITICAL Typing rows as `Person` with recursive `subRows`
|
|
208
|
+
|
|
209
|
+
Wrong:
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
const form = useAppForm({ defaultValues: { data: makeData(100) as Person[] } })
|
|
213
|
+
// TanStack Form's DeepKeys walks Person.subRows recursively → TS2589
|
|
214
|
+
// ("Type instantiation is excessively deep and possibly infinite")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Correct:
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
type FormRow = Omit<Person, 'subRows'>
|
|
221
|
+
const initialData: FormRow[] = makeData(100)
|
|
222
|
+
const form = useAppForm({ defaultValues: { data: initialData } })
|
|
223
|
+
const columnHelper = createColumnHelper<typeof features, FormRow>()
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Always strip the recursive child field from the row type you hand to the form.
|
|
227
|
+
Source: `examples/react/with-tanstack-form/src/main.tsx`.
|
|
228
|
+
|
|
229
|
+
### CRITICAL Subscribing to the whole `state.values.data` array
|
|
230
|
+
|
|
231
|
+
Wrong:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
// Every keystroke in any cell re-renders App → recreates form → re-binds every cell.
|
|
235
|
+
const data = useStore(form.store, (s) => s.values.data)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Correct:
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
// Subscribe to length only — triggers re-renders on add/remove, ignores edits.
|
|
242
|
+
const dataLength = useStore(form.store, (state) => state.values.data.length)
|
|
243
|
+
void dataLength
|
|
244
|
+
// Table reads `data: form.state.values.data` directly on render.
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Source: `examples/react/with-tanstack-form/src/main.tsx`.
|
|
248
|
+
|
|
249
|
+
### HIGH Forgetting `useMemo` around columns
|
|
250
|
+
|
|
251
|
+
Wrong:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
function App() {
|
|
255
|
+
const form = useAppForm({
|
|
256
|
+
/* … */
|
|
257
|
+
})
|
|
258
|
+
const columns = columnHelper.columns([
|
|
259
|
+
// new column defs every render
|
|
260
|
+
columnHelper.accessor('firstName', {
|
|
261
|
+
cell: ({ row }) => (
|
|
262
|
+
<form.AppField name={`data[${row.index}].firstName`}>
|
|
263
|
+
{(field) => <field.TextField />}
|
|
264
|
+
</form.AppField>
|
|
265
|
+
),
|
|
266
|
+
}),
|
|
267
|
+
])
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Correct:
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
const columns = React.useMemo(
|
|
275
|
+
() =>
|
|
276
|
+
columnHelper.columns([
|
|
277
|
+
columnHelper.accessor('firstName', {
|
|
278
|
+
cell: ({ row }) => (
|
|
279
|
+
<form.AppField name={`data[${row.index}].firstName`}>
|
|
280
|
+
{(field) => <field.TextField />}
|
|
281
|
+
</form.AppField>
|
|
282
|
+
),
|
|
283
|
+
}),
|
|
284
|
+
]),
|
|
285
|
+
[form],
|
|
286
|
+
)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Cell renderers close over `form`. Without memoization the column defs change every render, busting internal memos and remounting field components.
|
|
290
|
+
Source: `examples/react/with-tanstack-form/src/main.tsx`.
|
|
291
|
+
|
|
292
|
+
### HIGH Passing the form itself in `useTable`'s `data`
|
|
293
|
+
|
|
294
|
+
Wrong:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
const table = useTable({
|
|
298
|
+
features,
|
|
299
|
+
rowModels: {
|
|
300
|
+
/* … */
|
|
301
|
+
},
|
|
302
|
+
columns,
|
|
303
|
+
data: form, // wrong — table only needs the row array
|
|
304
|
+
})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Correct:
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
const table = useTable({
|
|
311
|
+
features,
|
|
312
|
+
rowModels: {
|
|
313
|
+
/* … */
|
|
314
|
+
},
|
|
315
|
+
columns,
|
|
316
|
+
data: form.state.values.data,
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The table consumes the rows array. Mix the form's data into the table's `data` prop; don't try to make the table aware of the form instance.
|
|
321
|
+
Source: `examples/react/with-tanstack-form/src/main.tsx`.
|
|
322
|
+
|
|
323
|
+
### MEDIUM Trying to reuse v8's `tableMeta.updateData` pattern
|
|
324
|
+
|
|
325
|
+
Wrong:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
// v8 muscle memory: track edits in tableMeta with a per-cell useState.
|
|
329
|
+
const table = useReactTable({
|
|
330
|
+
data,
|
|
331
|
+
columns,
|
|
332
|
+
meta: {
|
|
333
|
+
updateData: (rowIndex, columnId, value) => {
|
|
334
|
+
/* manual setState dance */
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Correct:
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
// v9 idiom: TanStack Form owns the data, table renders it.
|
|
344
|
+
const form = useAppForm({ defaultValues: { data } })
|
|
345
|
+
const table = useTable({
|
|
346
|
+
features,
|
|
347
|
+
rowModels: {
|
|
348
|
+
/* … */
|
|
349
|
+
},
|
|
350
|
+
columns,
|
|
351
|
+
data: form.state.values.data,
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The v8 `tableMeta.updateData` pattern still works mechanically, but the form composition handles validation, dirty tracking, submit, and add/remove for free.
|
|
356
|
+
Source: maintainer guidance.
|
|
357
|
+
|
|
358
|
+
## See Also
|
|
359
|
+
|
|
360
|
+
- `tanstack-table/react/table-state` — base table reactivity.
|
|
361
|
+
- `tanstack-table/react/compose-with-tanstack-pacer` — debounce column filter inputs on the same screen.
|
|
362
|
+
- `tanstack-table/column-definitions` — cell renderer API.
|
|
363
|
+
- `tanstack-table/row-selection` — row selection works alongside per-cell editing.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react/compose-with-tanstack-pacer
|
|
3
|
+
description: >
|
|
4
|
+
Use `@tanstack/react-pacer` to debounce/throttle the high-frequency writes
|
|
5
|
+
that drive an interactive `@tanstack/react-table` v9 table: column filter
|
|
6
|
+
inputs and column resize state. Pattern: import `useDebouncedCallback`
|
|
7
|
+
from `@tanstack/react-pacer/debouncer`, wrap your `onChange` writer in
|
|
8
|
+
it, and keep local input state so typing feels instant. For column
|
|
9
|
+
resizing, throttle `onColumnResizingChange` so a drag doesn't push 60+
|
|
10
|
+
state updates per second. Pacer is the v9 replacement for the hand-rolled
|
|
11
|
+
`DebouncedInput` setTimeout component from v8 examples.
|
|
12
|
+
type: composition
|
|
13
|
+
library: tanstack-table
|
|
14
|
+
framework: react
|
|
15
|
+
library_version: '9.0.0-alpha.48'
|
|
16
|
+
requires:
|
|
17
|
+
- filtering
|
|
18
|
+
- column-layout
|
|
19
|
+
- react/table-state
|
|
20
|
+
sources:
|
|
21
|
+
- TanStack/table:examples/react/basic-subscribe/src/main.tsx
|
|
22
|
+
- TanStack/table:examples/react/with-tanstack-form/src/main.tsx
|
|
23
|
+
- TanStack/table:examples/react/kitchen-sink/src/main.tsx
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
This skill builds on `tanstack-table/state-management`, `tanstack-table/react/table-state`, and the `filtering` core skill. Read those first.
|
|
27
|
+
|
|
28
|
+
## Why this exists
|
|
29
|
+
|
|
30
|
+
A column filter input writes to `state.columnFilters` on every keystroke. Each write recomputes the filtered row model. For 100 rows that's fine; for 10k+ it's visibly janky. Pacer debounces the write so the filter only fires once after typing stops, while the input itself updates instantly via local state.
|
|
31
|
+
|
|
32
|
+
The same principle applies to drag-to-resize: a single drag can fire `onColumnResizingChange` 60+ times per second. Throttling at ~16ms (one frame) keeps the perceived smoothness without spamming the store.
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @tanstack/react-pacer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer'
|
|
42
|
+
import { useThrottledCallback } from '@tanstack/react-pacer/throttler'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Core Pattern — `DebouncedInput` for column filters
|
|
46
|
+
|
|
47
|
+
The shape comes straight from the v9 examples — keep local input state so the input is instant, debounce the writer so the store only sees the trailing value.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import * as React from 'react'
|
|
51
|
+
import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer'
|
|
52
|
+
|
|
53
|
+
function DebouncedInput({
|
|
54
|
+
value: initialValue,
|
|
55
|
+
onChange,
|
|
56
|
+
debounce = 300,
|
|
57
|
+
...props
|
|
58
|
+
}: {
|
|
59
|
+
value: string | number
|
|
60
|
+
onChange: (value: string | number) => void
|
|
61
|
+
debounce?: number
|
|
62
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>) {
|
|
63
|
+
// Local state so the input feels instant.
|
|
64
|
+
const [value, setValue] = React.useState(initialValue)
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
setValue(initialValue)
|
|
68
|
+
}, [initialValue])
|
|
69
|
+
|
|
70
|
+
// Debounced writer — fires once after `debounce` ms of inactivity.
|
|
71
|
+
const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce })
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<input
|
|
75
|
+
{...props}
|
|
76
|
+
value={value}
|
|
77
|
+
onChange={(e) => {
|
|
78
|
+
setValue(e.target.value) // instant UI update
|
|
79
|
+
debouncedOnChange(e.target.value) // debounced store write
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Usage in a column filter:
|
|
86
|
+
;<DebouncedInput
|
|
87
|
+
value={(column.getFilterValue() ?? '') as string}
|
|
88
|
+
onChange={(v) => column.setFilterValue(v)}
|
|
89
|
+
placeholder="Search..."
|
|
90
|
+
/>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Source: `examples/react/basic-subscribe/src/main.tsx`; `examples/react/with-tanstack-form/src/main.tsx`.
|
|
94
|
+
|
|
95
|
+
## Debounce vs throttle — choose by intent
|
|
96
|
+
|
|
97
|
+
| Pattern | Use case | Typical wait |
|
|
98
|
+
| ------------ | ------------------------------------------------------------------------ | ---------------- |
|
|
99
|
+
| **Debounce** | "Wait until they stop typing, then commit" — filter inputs, search boxes | 250–500ms |
|
|
100
|
+
| **Throttle** | "Fire at most every N ms" — drag-to-resize, scroll-triggered fetches | 16ms (one frame) |
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
// Throttled column resize
|
|
104
|
+
const throttledResize = useThrottledCallback(
|
|
105
|
+
(next: ColumnResizingState) => columnResizingAtom.set(next),
|
|
106
|
+
{ wait: 16 },
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Global filter with debounce + instant input
|
|
111
|
+
|
|
112
|
+
The exact pattern used in `examples/react/basic-subscribe/src/main.tsx`:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<table.Subscribe source={table.atoms.globalFilter}>
|
|
116
|
+
{(globalFilter) => (
|
|
117
|
+
<DebouncedInput
|
|
118
|
+
value={globalFilter ?? ''}
|
|
119
|
+
onChange={(value) => table.setGlobalFilter(value)}
|
|
120
|
+
placeholder="Search all columns..."
|
|
121
|
+
/>
|
|
122
|
+
)}
|
|
123
|
+
</table.Subscribe>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `<table.Subscribe>` wrapper keeps the input controlled by the table's atom (so external resets propagate), while `DebouncedInput`'s local state keeps the visual update instant.
|
|
127
|
+
|
|
128
|
+
## Common Mistakes
|
|
129
|
+
|
|
130
|
+
### CRITICAL Writing filter values directly on every keystroke
|
|
131
|
+
|
|
132
|
+
Wrong:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
<input onChange={(e) => column.setFilterValue(e.target.value)} />
|
|
136
|
+
// On a 10k-row table, this recomputes the filtered row model per character — janky.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Correct:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
<DebouncedInput
|
|
143
|
+
value={(column.getFilterValue() ?? '') as string}
|
|
144
|
+
onChange={(v) => column.setFilterValue(v)}
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Debounce the store write; keep the input instant via local state.
|
|
149
|
+
Source: `examples/react/basic-subscribe/src/main.tsx`.
|
|
150
|
+
|
|
151
|
+
### HIGH Rolling your own `setTimeout` debounce
|
|
152
|
+
|
|
153
|
+
Wrong:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
function DebouncedInput({ value, onChange, debounce = 300 }) {
|
|
157
|
+
const [v, setV] = React.useState(value)
|
|
158
|
+
React.useEffect(() => {
|
|
159
|
+
const t = setTimeout(() => onChange(v), debounce)
|
|
160
|
+
return () => clearTimeout(t)
|
|
161
|
+
}, [v])
|
|
162
|
+
// Works, but reinvents what useDebouncedCallback already provides with proper
|
|
163
|
+
// ref stability, cleanup, and trailing-edge semantics.
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Correct:
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
function DebouncedInput({
|
|
171
|
+
value: initialValue,
|
|
172
|
+
onChange,
|
|
173
|
+
debounce = 300,
|
|
174
|
+
...props
|
|
175
|
+
}) {
|
|
176
|
+
const [value, setValue] = React.useState(initialValue)
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
setValue(initialValue)
|
|
179
|
+
}, [initialValue])
|
|
180
|
+
const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce })
|
|
181
|
+
return (
|
|
182
|
+
<input
|
|
183
|
+
{...props}
|
|
184
|
+
value={value}
|
|
185
|
+
onChange={(e) => {
|
|
186
|
+
setValue(e.target.value)
|
|
187
|
+
debouncedOnChange(e.target.value)
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
v8 examples shipped the hand-rolled version. v9 explicitly delegates to Pacer.
|
|
195
|
+
Source: `examples/react/basic-subscribe/src/main.tsx`.
|
|
196
|
+
|
|
197
|
+
### HIGH Debouncing the local input state instead of (or in addition to) the writer
|
|
198
|
+
|
|
199
|
+
Wrong:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
const debouncedSetLocal = useDebouncedCallback(setValue, { wait: 300 })
|
|
203
|
+
<input value={value} onChange={(e) => debouncedSetLocal(e.target.value)} />
|
|
204
|
+
// User sees stale characters in the input box.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Correct:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
const debouncedOnChange = useDebouncedCallback(onChange, { wait: 300 })
|
|
211
|
+
<input
|
|
212
|
+
value={value}
|
|
213
|
+
onChange={(e) => {
|
|
214
|
+
setValue(e.target.value) // instant local update
|
|
215
|
+
debouncedOnChange(e.target.value) // debounced store write only
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Local state should always be instant. Only the expensive store write should debounce.
|
|
221
|
+
Source: `examples/react/basic-subscribe/src/main.tsx`.
|
|
222
|
+
|
|
223
|
+
### HIGH Throttling column resize at 250ms
|
|
224
|
+
|
|
225
|
+
Wrong:
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
const throttledResize = useThrottledCallback(setResize, { wait: 250 })
|
|
229
|
+
// 4 fps drag → visibly laggy.
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Correct:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
const throttledResize = useThrottledCallback(setResize, { wait: 16 })
|
|
236
|
+
// ~60 fps, smooth.
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Use roughly one frame (16ms). 250ms is fine for filter writes; far too long for drag interactions.
|
|
240
|
+
Source: maintainer guidance.
|
|
241
|
+
|
|
242
|
+
### MEDIUM Wrapping `column.setFilterValue` directly without local input state
|
|
243
|
+
|
|
244
|
+
Wrong:
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
const debouncedSet = useDebouncedCallback(column.setFilterValue, { wait: 300 })
|
|
248
|
+
<input onChange={(e) => debouncedSet(e.target.value)} />
|
|
249
|
+
// Input becomes uncontrolled-feeling because the render goes through the slow path.
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Correct:
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
<DebouncedInput
|
|
256
|
+
value={(column.getFilterValue() ?? '') as string}
|
|
257
|
+
onChange={(v) => column.setFilterValue(v)}
|
|
258
|
+
/>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The `DebouncedInput` pattern combines local state (instant) with debounced commit (cheap). Don't skip the local state.
|
|
262
|
+
Source: `examples/react/basic-subscribe/src/main.tsx`.
|
|
263
|
+
|
|
264
|
+
### MEDIUM Using `wait: 0` and expecting debouncing
|
|
265
|
+
|
|
266
|
+
Wrong:
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
const debouncedOnChange = useDebouncedCallback(onChange, { wait: 0 })
|
|
270
|
+
// Effectively no debounce — fires synchronously.
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Correct:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
const debouncedOnChange = useDebouncedCallback(onChange, { wait: 300 })
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`wait` is meaningful. 250–500ms is the typical sweet spot for filter inputs; 16ms is the typical sweet spot for resize/scroll throttling.
|
|
280
|
+
Source: maintainer guidance.
|
|
281
|
+
|
|
282
|
+
## See Also
|
|
283
|
+
|
|
284
|
+
- `tanstack-table/react/table-state` — Subscribe boundaries for the debounced writers to feed into.
|
|
285
|
+
- `tanstack-table/filtering` — column filter feature API surface.
|
|
286
|
+
- `tanstack-table/column-layout` — column resize feature.
|
|
287
|
+
- `tanstack-table/react/compose-with-tanstack-query` — debounce filter input that feeds a server-side query.
|