| name | react/compose-with-tanstack-form |
| description | Editable cells for `@tanstack/react-table` v9 via `@tanstack/react-form`. The table is the layout primitive; the form owns editing state. Use `createFormHook` to register reusable field components (`TextField`, `NumberField`, `SelectField`), then in each column's `cell` return `<form.AppField name={`data[${row.index}].field`}>{(field) => <field.TextField />}</form.AppField>`. Critical typing gotcha: if your row has a recursive `subRows`, use `Omit<Row, 'subRows'>` for the form row type — TanStack Form's `DeepKeys` recurses and hits TS2589. Subscribe to `form.state.values.data.length` (not the whole array) for row add/remove re-renders.
|
| type | composition |
| library | tanstack-table |
| framework | react |
| library_version | 9.0.0-alpha.48 |
| requires | ["row-selection","column-definitions","react/table-state"] |
| sources | ["TanStack/table:examples/react/with-tanstack-form/src/main.tsx","TanStack/table:examples/react/with-tanstack-form/src/form.tsx"] |
This skill builds on tanstack-table/state-management, tanstack-table/react/table-state, and tanstack-table/column-definitions. Read those first.
Why this exists
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?"
Setup
pnpm add @tanstack/react-table @tanstack/react-form zod
Define your field components and a form hook in a form.tsx module. Source: examples/react/with-tanstack-form/src/form.tsx.
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
const { fieldContext, formContext } = createFormHookContexts()
function TextField() {
}
function NumberField() {
}
function SelectField() {
}
function SubmitButton() {
}
function FormStateIndicator() {
}
export const { useAppForm } = createFormHook({
fieldComponents: { TextField, NumberField, SelectField },
formComponents: { SubmitButton, FormStateIndicator },
fieldContext,
formContext,
})
Core Pattern — editable people table
import * as React from 'react'
import {
useTable,
tableFeatures,
columnFilteringFeature,
rowPaginationFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
filterFns,
} from '@tanstack/react-table'
import { useStore } from '@tanstack/react-form'
import { z } from 'zod'
import { useAppForm } from './form'
import type { Person } from './makeData'
type FormRow = Omit<Person, 'subRows'>
const features = tableFeatures({
rowPaginationFeature,
columnFilteringFeature,
})
const columnHelper = createColumnHelper<typeof features, FormRow>()
function App() {
const initialData: FormRow[] = makeData(100)
const form = useAppForm({
defaultValues: { data: initialData },
onSubmit: ({ value }) => {
alert(`Submitted ${value.data.length} records`)
},
validators: { onChange: z.object({ data: z.array(personSchema) }) },
})
const columns = React.useMemo(
() =>
columnHelper.columns([
columnHelper.accessor('firstName', {
header: 'First Name',
cell: ({ row }) => (
<form.AppField
name={`data[${row.index}].firstName`}
validators={{ onChange: z.string().min(1, 'Required') }}
>
{(field) => <field.TextField />}
</form.AppField>
),
}),
columnHelper.accessor('age', {
header: 'Age',
cell: ({ row }) => (
<form.AppField
name={`data[${row.index}].age`}
validators={{ onChange: z.number().min(0).max(150) }}
>
{(field) => <field.NumberField />}
</form.AppField>
),
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ row }) => (
<form.AppField name={`data[${row.index}].status`}>
{(field) => <field.SelectField />}
</form.AppField>
),
}),
]),
[form],
)
const dataLength = useStore(form.store, (state) => state.values.data.length)
void dataLength
const table = useTable({
features,
rowModels: {
filteredRowModel: createFilteredRowModel(filterFns),
paginatedRowModel: createPaginatedRowModel(),
},
columns,
data: form.state.values.data,
})
const addRow = () =>
form.pushFieldValue('data', {
firstName: '',
lastName: '',
age: 0,
visits: 0,
progress: 0,
status: 'single',
})
const refreshData = () => form.reset({ data: makeData(100) })
return (
<>
<button onClick={addRow}>Add Row</button>
<button onClick={refreshData}>Refresh Data</button>
<table>
<thead>{/* … */}</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getAllCells().map((cell) => (
<td key={cell.id}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
<form.SubmitButton />
</>
)
}
Source: examples/react/with-tanstack-form/src/main.tsx.
Add / remove rows
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.
Common Mistakes
CRITICAL Typing rows as Person with recursive subRows
Wrong:
const form = useAppForm({ defaultValues: { data: makeData(100) as Person[] } })
Correct:
type FormRow = Omit<Person, 'subRows'>
const initialData: FormRow[] = makeData(100)
const form = useAppForm({ defaultValues: { data: initialData } })
const columnHelper = createColumnHelper<typeof features, FormRow>()
Always strip the recursive child field from the row type you hand to the form.
Source: examples/react/with-tanstack-form/src/main.tsx.
CRITICAL Subscribing to the whole state.values.data array
Wrong:
const data = useStore(form.store, (s) => s.values.data)
Correct:
const dataLength = useStore(form.store, (state) => state.values.data.length)
void dataLength
Source: examples/react/with-tanstack-form/src/main.tsx.
HIGH Forgetting useMemo around columns
Wrong:
function App() {
const form = useAppForm({
})
const columns = columnHelper.columns([
columnHelper.accessor('firstName', {
cell: ({ row }) => (
<form.AppField name={`data[${row.index}].firstName`}>
{(field) => <field.TextField />}
</form.AppField>
),
}),
])
}
Correct:
const columns = React.useMemo(
() =>
columnHelper.columns([
columnHelper.accessor('firstName', {
cell: ({ row }) => (
<form.AppField name={`data[${row.index}].firstName`}>
{(field) => <field.TextField />}
</form.AppField>
),
}),
]),
[form],
)
Cell renderers close over form. Without memoization the column defs change every render, busting internal memos and remounting field components.
Source: examples/react/with-tanstack-form/src/main.tsx.
HIGH Passing the form itself in useTable's data
Wrong:
const table = useTable({
features,
rowModels: {
},
columns,
data: form,
})
Correct:
const table = useTable({
features,
rowModels: {
},
columns,
data: form.state.values.data,
})
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.
Source: examples/react/with-tanstack-form/src/main.tsx.
MEDIUM Trying to reuse v8's tableMeta.updateData pattern
Wrong:
const table = useReactTable({
data,
columns,
meta: {
updateData: (rowIndex, columnId, value) => {
},
},
})
Correct:
const form = useAppForm({ defaultValues: { data } })
const table = useTable({
features,
rowModels: {
},
columns,
data: form.state.values.data,
})
The v8 tableMeta.updateData pattern still works mechanically, but the form composition handles validation, dirty tracking, submit, and add/remove for free.
Source: maintainer guidance.
See Also
tanstack-table/react/table-state — base table reactivity.
tanstack-table/react/compose-with-tanstack-pacer — debounce column filter inputs on the same screen.
tanstack-table/column-definitions — cell renderer API.
tanstack-table/row-selection — row selection works alongside per-cell editing.