diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml new file mode 100644 index 0000000000..8675617d1c --- /dev/null +++ b/.github/workflows/check-skills.yml @@ -0,0 +1,43 @@ +# check-skills.yml +# +# Validates @tanstack/intent skills on PRs that touch skills or artifacts. +# +# Staleness checking after a release is intentionally NOT automated here — run +# `pnpm test:intent` (which calls `intent validate && intent stale`) locally +# before cutting a release. Keeping this workflow validation-only means it +# needs zero write permissions. + +name: Check Skills + +on: + pull_request: + paths: + - 'skills/**' + - '**/skills/**' + - '_artifacts/**' + - '**/_artifacts/**' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + validate: + name: Validate intent skills + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 20 + + - name: Install intent + run: npm install -g @tanstack/intent + + - name: Validate skills + run: intent validate --github-summary diff --git a/README.md b/README.md index 4490fe2758..e82fa87596 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml new file mode 100644 index 0000000000..12069b6bec --- /dev/null +++ b/_artifacts/domain_map.yaml @@ -0,0 +1,7110 @@ +library: + name: '@tanstack/table' + version: 9.0.0-alpha.47 + repository: https://github.com/TanStack/table + homepage: https://tanstack.com/table + description: + 'Headless data-grid library: features, state, and APIs for building powerful, type-safe + tables and datagrids while keeping full control of markup, styles, and behavior. Framework-agnostic + core (`@tanstack/table-core`) with adapters for React, Vue, Solid, Svelte, Angular, Lit, and Preact.' + primary_framework: framework-agnostic + monorepo: true +meta: + generated_by: '@tanstack/intent scaffold (Phase 3 deep-read merge)' + date: '2026-05-17' + status: reviewed + maintainer_review_pending: false + phase_4_date: '2026-05-17' +domains: + - name: Core foundations + slug: core-foundations + description: + Setup, column definitions, state management, and customization of built-in feature behavior. + State-management is the prerequisite for every other skill. + - name: Row-model features + slug: row-model-features + description: + Features driven by entries in `_rowModels` — filtering (column+global+faceting+fuzzy), + sorting, pagination, grouping, expanding. All have `manual` opt-outs for server-side data. + - name: UI-state features + slug: ui-state-features + description: + Features that are pure UI state — no row model needed. Column layout (visibility/ordering/pinning/sizing/resizing), + row pinning, row selection. + - name: Framework adapters + slug: framework-adapters + description: + Per-framework reactivity bindings and rendering integration. Each adapter has its own `table-state` + skill; React adds Subscribe-for-React-Compiler, Angular adds structural directives, Lit adds the TableController + pattern. + - name: Lifecycle + slug: lifecycle + description: + 'End-to-end journeys that cross-cut features: getting-started, v8→v9 migration, client-to-server + conversion, production-readiness.' + - name: Composition + slug: composition + description: + Patterns for using TanStack Table with sibling TanStack libraries (Store, Query, Virtual, + Form, Pacer, Devtools). Per maintainer decision, no per-UI-library composition skills. +skills: + - name: Setup + slug: setup + domain: core-foundations + description: + Install a table adapter and wire up a first working table with `_features`, `_rowModels`, + columns, and data. + type: core + packages: + - '@tanstack/table-core' + covers: + - tableFeatures + - _features + - _rowModels + - useTable + - constructTable + - _createTable + - stockFeatures + - coreFeatures + - storeReactivityBindings + - FlexRender + - flexRender + - table.getHeaderGroups + - table.getRowModel + tasks: + - Render a first read-only table from an array of objects + - Decide between an empty `tableFeatures({})` core-only setup vs `stockFeatures` (all features) vs hand-picked + feature imports + - Wire up the matching adapter (`useTable` / `injectTable` / `createTable` / `constructTable`) and render + markup with `` or `flexRender` + - Use `@tanstack/table-core` + `storeReactivityBindings()` directly (vanilla JS) when no framework adapter + exists + failure_modes: + - mistake: Omits `_features` and `_rowModels` + mechanism: + v9 requires `_features` (and an `_rowModels` map, even if empty) at the top of `useTable`/`constructTable` + options. Without `_features`, TypeScript loses feature-state inference and runtime construction + has no feature plugins to register. + wrong_pattern: | + // v8-flavoured, breaks in v9 + const table = useTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), // v8 option name + }) + correct_pattern: | + // v9 minimal table + const _features = tableFeatures({}) // empty = core features only + const table = useTable({ + _features, + _rowModels: {}, // core row model auto-included + columns, + data, + }) + source: examples/react/basic-use-table/src/main.tsx:56-109; docs/guide/tables.md:33-47; docs/framework/react/guide/migrating.md:78-103 + priority: CRITICAL + status: active + version_context: + v9 alpha; affects every user upgrading from v8 — the hook is renamed (`useReactTable` + → `useTable`) AND new required options were introduced + - mistake: Calls `tableFeatures({})` inside the component body + mechanism: + A fresh `_features` object on each render destroys the table's stable reference for feature + registration, the same way unstable `columns`/`data` cause infinite re-renders. `_features` must + be hoisted to module scope or memoized. + wrong_pattern: | + function MyTable() { + // ❌ new object every render -> potential re-construct + state churn + const _features = tableFeatures({ rowSortingFeature }) + const table = useTable({ _features, _rowModels: {}, columns, data }) + } + correct_pattern: | + // ✅ module-scoped, stable reference + const _features = tableFeatures({ rowSortingFeature }) + + function MyTable() { + const table = useTable({ _features, _rowModels: {}, columns, data }) + } + source: + docs/guide/data.md:184-244; examples/react/basic-use-table/src/main.tsx:56 (defined outside + component) + priority: HIGH + status: active + version_context: v9 alpha; new in v9 because v8 had no `_features` option + skills: + - state-management + - mistake: Reaches for `stockFeatures` by default + mechanism: + Reaching for `stockFeatures` re-introduces v8 bundle size (~15–20kb), silently negating + v9's opt-in tree-shaking. The v9 default is hand-picked features; `stockFeatures` exists only as + a v8 escape hatch. + wrong_pattern: | + // ❌ ships every feature, even unused ones + import { useTable, stockFeatures } from '@tanstack/react-table' + const table = useTable({ + _features: stockFeatures, + _rowModels: {}, + columns, + data, + }) + correct_pattern: | + // ✅ only the features you use + import { + tableFeatures, + useTable, + rowSortingFeature, + rowPaginationFeature, + } from '@tanstack/react-table' + + const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + }) + source: docs/framework/react/guide/migrating.md:9-12,136-147; packages/table-core/src/features/stockFeatures.ts:38-53 + priority: MEDIUM + status: active + version_context: v9 alpha; `stockFeatures` is documented but discouraged for new code + - mistake: Adds a row model without registering its feature + mechanism: + Row model factories like `createSortedRowModel` only run if the matching feature (e.g. + `rowSortingFeature`) is also registered in `_features`. TypeScript flags this when state slices/APIs + are accessed, but runtime silently degrades — `table.atoms.sorting` is undefined and sort handlers + do nothing. + wrong_pattern: | + // ❌ rowSortingFeature missing from _features — sortedRowModel orphaned + const _features = tableFeatures({ rowPaginationFeature }) + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), // no-op + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + }) + correct_pattern: | + // ✅ feature + row model registered together + const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + }) + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + }) + source: docs/guide/row-models.md:46-78; examples/react/basic-external-atoms/src/main.tsx:27-30,81-92 + priority: HIGH + status: active + version_context: v9 alpha; new failure surface since v8 had no _features/feature-row-model pairing + - mistake: Passing empty array literal to data causes infinite rerenders + mechanism: | + `const data = items ?? []` creates a new `[]` reference on every render. The + table sees a fresh `data` prop and rebuilds row models; if any callback in the + same render also reads from the table, you get an infinite re-render loop. + wrong_pattern: | + // ❌ Fresh [] each render — infinite loop when items is undefined + const table = useReactTable({ + data: items ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }) + correct_pattern: | + // ✅ Hoist the empty fallback OR memoize + const EMPTY: MyRow[] = [] + const table = useReactTable({ + data: items ?? EMPTY, + columns, + getCoreRowModel: getCoreRowModel(), + }) + // OR + const data = useMemo(() => items ?? [], [items]) + source: + https://github.com/TanStack/table/issues/4566 (Empty data causes infinite looping), https://github.com/TanStack/table/issues/6002 + (if data is empty array, then rendering table is causing infinite rerenders) + priority: CRITICAL + status: active + version_context: + Affects every version. Top recurring beginner issue. Maintainer (KevinVandy) explicitly + says docs need to make stability obvious. + skills: + - setup + - getting-started + - state-management + - mistake: Defining columns inside the render body without useMemo + mechanism: | + Defining `columns` inline inside the component body creates a fresh column array + every render. Table internals see new column references and rebuild header + groups, recalculate sizes, and lose memo caches — leading to slow renders and + cells/components remounting on every parent state change. + wrong_pattern: | + // ❌ Columns recreated every render + function MyTable({ data }) { + const columns = [ + columnHelper.accessor('name', { header: 'Name' }), + columnHelper.accessor('email', { header: 'Email' }), + ] + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + ... + } + correct_pattern: | + // ✅ Hoist outside, or useMemo with stable deps + const columns = [ + columnHelper.accessor('name', { header: 'Name' }), + columnHelper.accessor('email', { header: 'Email' }), + ] + function MyTable({ data }) { + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + ... + } + source: + https://github.com/TanStack/table/issues/5141 (What props passed to useReactTable hook have + to be stable?), https://github.com/TanStack/table/issues/4794 (unnecessary rerenders) + priority: CRITICAL + status: active + version_context: + 'Top-priority pattern across v7, v8, v9. v9 makes columns/data readonly (PR #6183) + to nudge users.' + skills: + - setup + - state-management + - production-readiness + - mistake: Hallucinating react-table v7 / pre-v9 @tanstack/[framework]-table APIs + mechanism: + Every major release of TanStack Table (formerly react-table) has been a substantial upgrade. + Agents trained on older data confidently produce v7 or v8 API shapes that no longer exist in v9 + (e.g. `useReactTable`, inline `getCoreRowModel()` as an option, `useTable` from react-table v7). + wrong_pattern: |- + // ❌ v7 / v8 patterns the agent invents + import { useTable, useSortBy } from 'react-table' // v7 + const table = useTable({ columns, data }, useSortBy) + + // or v8 + import { useReactTable, getCoreRowModel } from '@tanstack/react-table' + const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }) + correct_pattern: |- + // ✅ v9 pattern — `_features` + `_rowModels` + import { useTable, tableFeatures, rowSortingFeature, createSortedRowModel, sortFns } from '@tanstack/react-table' + const _features = tableFeatures({ rowSortingFeature }) + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + priority: CRITICAL + version_context: + v7→v8 and v8→v9 both shifted the API substantially; agents trained on any pre-v9 + data will produce wrong shapes. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: API or state slice "missing" because the feature was not registered in `_features` + mechanism: + In v9, `_features` is a tree-shakeable registry. If a feature is not in `_features`, TypeScript + hides its APIs and the runtime atom is not created. Agents who copy a snippet for `table.setColumnFilters(...)` + without registering `columnFilteringFeature` see a TS error or `table.atoms.columnFilters` is undefined + — and may incorrectly conclude the feature is broken or removed in v9. + wrong_pattern: |- + // ❌ rowSortingFeature missing — table.setSorting / state.sorting unavailable + const _features = tableFeatures({}) // empty + const table = useTable({ _features, _rowModels: {}, columns, data }) + table.setSorting([{ id: 'age', desc: true }]) // ❌ does not exist on this table type + correct_pattern: |- + // ✅ Register every feature you intend to use; pair with its row model when applicable + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, data, + }) + priority: CRITICAL + version_context: + v9-specific. This is the first version of TanStack Table where features must be declared + for TypeScript and runtime to expose them. Expect heavy confusion from devs and agents trained on + v8. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: Bundling stockFeatures or all features when only a few are used + mechanism: + TanStack Table v9 is tree-shakeable specifically so you only pay for features you use. + Registering `stockFeatures` (or every feature "just in case") forfeits the bundle benefit that motivated + the v9 redesign. + wrong_pattern: |- + // ❌ Pulls in every feature even though only sorting+pagination are used + import { stockFeatures, tableFeatures } from '@tanstack/react-table' + const _features = tableFeatures(stockFeatures) + correct_pattern: |- + // ✅ Register only what this table uses + import { tableFeatures, rowSortingFeature, rowPaginationFeature } from '@tanstack/react-table' + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + priority: HIGH + version_context: v9. Tree-shaking via `_features` is one of the headline reasons for the rewrite. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - name: Column Definitions + slug: column-definitions + domain: core-foundations + description: + Define columns with `createColumnHelper()` to extract data and + render headers/cells/footers. + type: core + packages: + - '@tanstack/table-core' + covers: + - createColumnHelper + - columnHelper.accessor + - columnHelper.display + - columnHelper.group + - columnHelper.columns + - ColumnDef + - AccessorKeyColumnDef + - AccessorFnColumnDef + - DisplayColumnDef + - GroupColumnDef + - accessorKey + - accessorFn + - header + - cell + - footer + - aggregatedCell + - id + - getRowId + - DeepKeys + - flexRender + tasks: + - Create a typed `columnHelper` and define accessor/display/group columns with strong inference + - 'Pick between `accessorKey: "name.first"` (deep key with dots) and `accessorFn: row => row.name.first` + for nested / computed values' + - Use `getRowId` to derive stable row identifiers from data (instead of array index) so selection/expansion + survive data updates + - Render headers/cells/footers with `` (or `flexRender`) so string / JSX / function + forms all work + failure_modes: + - mistake: Passes only TData to `createColumnHelper` + mechanism: + 'v9 changed the generic order: `createColumnHelper`. v8 code passed only + ``, which now binds `TData` into the `TFeatures` slot and breaks every column type. The compiler + error is noisy and not obvious.' + wrong_pattern: | + // ❌ v8 signature — TData ends up in the TFeatures slot + const columnHelper = createColumnHelper() + correct_pattern: | + // ✅ v9 — TFeatures first, TData second; use typeof _features + const _features = tableFeatures({ rowSortingFeature }) + const columnHelper = createColumnHelper() + source: + packages/table-core/src/helpers/columnHelper.ts:99-103; docs/framework/react/guide/migrating.md:484-499; + examples/react/basic-external-atoms/src/main.tsx:32 + priority: CRITICAL + status: active + version_context: v9 alpha; breaking change vs v8 generic signature + - mistake: Accessor function returns an object/array + mechanism: + The accessed value is what the table uses for sorting, filtering, faceting, and grouping. + Returning a non-primitive value means the built-in sort/filter/aggregation functions silently misbehave + or throw — only `displayCell` ever sees the value formatted. A primitive `string`/`number`/`Date` + is expected unless you supply a matching `sortFn`/`filterFn`/`aggregationFn`. + wrong_pattern: | + // ❌ returns an object — built-in alphanumeric/text sort and includesString filter break + columnHelper.accessor((row) => row.name, { + id: 'name', + cell: (info) => `${info.getValue().first} ${info.getValue().last}`, + }) + correct_pattern: | + // ✅ accessor returns a primitive; cell can still format it + columnHelper.accessor((row) => `${row.name.first} ${row.name.last}`, { + id: 'fullName', + cell: (info) => info.getValue(), + }) + source: docs/guide/column-defs.md:231; examples/react/basic-subscribe/src/main.tsx:103-108 + priority: HIGH + status: active + version_context: always present + skills: + - customizing-feature-behavior + - mistake: Omits `id` on an accessorFn column + mechanism: + When a column uses `accessorFn` (not `accessorKey`), there is nothing to derive an id from. + The constructor throws "coreColumnsFeature require an id when using an accessorFn" in development. + The same applies to non-string `header` values — without `id` or a string header, construction fails. + wrong_pattern: | + // ❌ accessorFn + JSX header => no id can be derived + columnHelper.accessor((row) => row.lastName, { + header: () => Last Name, + cell: (info) => info.getValue(), + }) + correct_pattern: | + // ✅ explicit id whenever you use accessorFn + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: () => Last Name, + cell: (info) => info.getValue(), + }) + source: + packages/table-core/src/core/columns/constructColumn.ts:91-100; examples/react/basic-use-table/src/main.tsx:65-70; + docs/guide/column-defs.md:233-243 + priority: CRITICAL + status: active + version_context: always present (same rule as v8) + - mistake: Defines `columns` inside the component without `useMemo` + mechanism: + 'TanStack Table compares `columns` and `data` by reference. Re-creating either array on + each render triggers internal recomputation that, in React, causes an infinite loop (state change + -> render -> new `columns` -> state change). This is the #1 FAQ.' + wrong_pattern: | + function MyTable() { + // ❌ new array reference every render -> infinite render loop + const columns = [ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + ] + const table = useTable({ _features, _rowModels: {}, columns, data }) + } + correct_pattern: | + // ✅ memoized columns or module-scoped via columnHelper.columns([...]) + function MyTable() { + const columns = React.useMemo( + () => + columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + ]), + [], + ) + const table = useTable({ _features, _rowModels: {}, columns, data }) + } + source: docs/faq.md:5-128; examples/react/basic-subscribe/src/main.tsx:51-127 + priority: CRITICAL + status: active + version_context: 'always present; #1 reason for infinite re-renders' + skills: + - setup + - state-management + - mistake: Uses array index `row.id` and updates data + mechanism: + By default, `row.id` is the row's index in `data`. When `data` is reordered, filtered, + or items are removed, row-keyed state (selection, expansion, pinning) attaches to the wrong row. + `getRowId` derives a stable id from the row's own data. + wrong_pattern: | + // ❌ no getRowId -> rowSelection survives data updates but maps to wrong rows + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + enableRowSelection: true, + }) + correct_pattern: | + // ✅ stable id from the row's own identifier + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, + enableRowSelection: true, + }) + source: docs/guide/rows.md:48-59; examples/react/basic-subscribe/src/main.tsx:148; packages/table-core/src/core/rows/coreRowsFeature.utils.ts:215-228 + priority: HIGH + status: active + version_context: always present + - mistake: Column visibility toggle on column groups produces incorrect state + mechanism: | + Calling `column.toggleVisibility()` on a column GROUP writes `false` to + `columnVisibility[groupId]`, but `getIsVisible()` for a group always returns + `true` regardless. Users wire up a checkbox to the group header thinking it + hides children — it doesn't. + wrong_pattern: | + // ❌ Group-level toggle silently no-ops on visibility + + correct_pattern: | + // ✅ Iterate leaves explicitly + function toggleGroupVisibility(group) { + const targetVisible = !group.getLeafColumns().every(c => c.getIsVisible()) + group.getLeafColumns().forEach(c => c.toggleVisibility(targetVisible)) + } + source: + https://github.com/TanStack/table/issues/5497 (Column visibility APIs do not work with column + groups, 5 comments) + priority: MEDIUM + status: active + version_context: v8 / v9 + skills: + - column-definitions + - column-layout + - mistake: accessor with optional path strips undefined from getValue type + mechanism: | + The `DeepValue` generic in table-core walks a dotted path as `infer TBranch`/ + `infer TDeepProp`, but doesn't propagate `undefined` when an intermediate key + is optional. `getValue()` returns `number` when it should return `number | undefined`, + hiding real runtime errors. + wrong_pattern: | + // ❌ amount is inferred as `number` even though salary is optional + columnHelper.accessor('user.salary.amount', { + cell: (row) => { + const amount = row.getValue() // type: number (WRONG) + return amount.toFixed(2) // crashes when salary is undefined + }, + }) + correct_pattern: | + // ✅ Use accessorFn for paths with optional segments — type follows expression + columnHelper.accessor((row) => row.user.salary?.amount, { + id: 'salary', + cell: (info) => { + const amount = info.getValue() // type: number | undefined + return amount?.toFixed(2) ?? '-' + }, + }) + source: + https://github.com/TanStack/table/issues/6238 (accessor getValue() loses undefined for key + paths with optional keys) + priority: MEDIUM + status: active + version_context: v8 + v9 type bug + - mistake: columnHelper.accessor inside columnHelper.group loses getValue type inference + mechanism: | + When an accessor is nested inside a `columnHelper.group()`, the `info.getValue()` + return type degrades to `unknown` because the group helper's overloads don't + thread the row-data generic through correctly. + wrong_pattern: | + // ❌ info.getValue() inferred as unknown + columnHelper.group({ + id: 'name', + columns: [ + columnHelper.accessor('firstName', { + cell: (info) => info.getValue(), // unknown + }), + ], + }) + correct_pattern: | + // ✅ Hoist accessor definitions out of the group + const firstNameCol = columnHelper.accessor('firstName', { + cell: (info) => info.getValue(), // string + }) + columnHelper.group({ id: 'name', columns: [firstNameCol] }) + source: + https://github.com/TanStack/table/issues/5860 (getValue fails to infer correct type when columnHelper.accessor + defined within columnHelper.group), https://github.com/TanStack/table/issues/5065 + priority: MEDIUM + status: active + version_context: v8 / v9 + - mistake: getValue cache not invalidating when accessorFn changes + mechanism: | + `getValue` caches per-column by ID. If you swap `accessorFn` in response to a + state change (e.g. "Last, First" ↔ "First Last" toggle) without changing the + column ID, cached values are returned. The new accessor never runs. + wrong_pattern: | + // ❌ Stale values shown after accessor switch + const columns = useMemo(() => [ + columnHelper.accessor( + firstFormat ? (row) => row.firstName + ' ' + row.lastName : (row) => row.lastName + ', ' + row.firstName, + { id: 'name' } + ), + ], [firstFormat]) + correct_pattern: | + // ✅ Either change the column ID (loses sort/filter state) OR move the logic + // into cell() which is not cached: + columnHelper.accessor((row) => ({ first: row.firstName, last: row.lastName }), { + id: 'name', + cell: (info) => { + const { first, last } = info.getValue() + return firstFormat ? `${first} ${last}` : `${last}, ${first}` + }, + }) + source: + https://github.com/TanStack/table/issues/5363 (getValue cache not invalidating when accessorFn + is updated) + priority: MEDIUM + status: active + version_context: v8 / v9 — caching is fundamental, not a bug + skills: + - column-definitions + - column-layout + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + maintainer_notes: + - accessorKey + deep keys may be simplified in v10 (accessorFn + id is the maintainer's preferred long-term + shape). For v9, accessorKey remains fully supported and the more concise option for simple keys. + - name: State Management + slug: state-management + domain: core-foundations + description: + Coordinate table state slices (sorting, pagination, filters, selection, etc.) across `initialState`, + `state`+`on*Change`, and external `atoms`, with awareness of v9 atom precedence. + type: core + packages: + - '@tanstack/table-core' + covers: + - baseAtoms + - atoms + - store + - state + - initialState + - on[State]Change + - setOptions + - reset + - resetSorting + - resetPagination + - resetColumnFilters + - resetGlobalFilter + - resetRowSelection + - manualFiltering + - manualSorting + - manualPagination + - manualExpanding + - manualGrouping + - autoResetPageIndex + - autoResetAll + - TableState + - SortingState + - PaginationState + - RowSelectionState + - ColumnFiltersState + - GroupingState + - storeReactivityBindings + - createAtom + - useCreateAtom + - useSelector + - Subscribe + - table.Subscribe + - useTable selector (second argument) + tasks: + - Decide which state ownership pattern fits — internal (default) vs `state`+`on*Change` (v8 controlled) + vs external `atoms` (v9 preferred) vs `initialState` (starting value only) + - Promote a slice to external state when the app needs to share it with a server query, persistence, + or another component + - Toggle a feature to a "manual" server-side mode (`manualSorting`, `manualFiltering`, `manualPagination`) + — sort/filter/paginate happen server-side and the table just renders the page + - Reset state with feature reset APIs (`resetSorting()`, `resetPagination(true)`) and understand why + `table.reset()` is unsafe when slices are externally owned + failure_modes: + - mistake: Passes both `state.pagination` and `atoms.pagination` + mechanism: + When both are supplied for the same slice, the external atom wins silently. `state.pagination` + is then dead config — UI values written to React state never reach the table, leading to "I called + setPagination but nothing updated" bugs. + wrong_pattern: | + // ❌ both ownership paths for the same slice + const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) + const [pagination, setPagination] = React.useState(...) + + const table = useTable({ + _features, _rowModels: {...}, columns, data, + state: { pagination }, // ignored + onPaginationChange: setPagination, + atoms: { pagination: paginationAtom }, // wins + }) + correct_pattern: | + // ✅ pick one ownership path per slice — here, external atoms + const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) + + const table = useTable({ + _features, _rowModels: {...}, columns, data, + atoms: { pagination: paginationAtom }, + // no state.pagination, no onPaginationChange needed + }) + source: + docs/framework/react/guide/table-state.md:315-316; docs/framework/react/guide/migrating.md:465-481; + packages/table-core/src/core/table/constructTable.ts:93-103 (atom precedence) + priority: CRITICAL + status: active + version_context: v9 alpha; new ownership-conflict surface introduced by the `atoms` option + - mistake: Uses external `state` without the matching `on*Change` callback + mechanism: + External `state.sorting` syncs into the table's base atom, but without `onSortingChange` + the table has no way to update React state — every sort toggle appears to do nothing. v9 silently + keeps reading from `state` so the UI looks "stuck". + wrong_pattern: | + // ❌ state without callback — sort toggles never reach setSorting + const [sorting, setSorting] = React.useState([]) + const table = useTable({ + _features, _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + state: { sorting }, // no onSortingChange + }) + correct_pattern: | + // ✅ state + on*Change must be paired + const [sorting, setSorting] = React.useState([]) + const table = useTable({ + _features, _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + state: { sorting }, + onSortingChange: setSorting, + }) + source: docs/framework/react/guide/table-state.md:415-435; examples/react/basic-external-state/src/main.tsx:64-94 + priority: CRITICAL + status: active + version_context: always present (same v8 rule) + - mistake: Uses `initialState` to control or update state + mechanism: + '`initialState` is only read once at construction time to build base atoms; mutating it + later does NOT update table state. Developers expect a React-state-like contract and watch state + freeze in place.' + wrong_pattern: | + // ❌ updates to initialState are ignored after first render + function MyTable({ defaultSort }: { defaultSort: SortingState }) { + const table = useTable({ + _features, _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + initialState: { sorting: defaultSort }, // ❌ later changes to defaultSort never sync + }) + } + correct_pattern: | + // ✅ control with state + on*Change (or an external atom) + function MyTable({ defaultSort }: { defaultSort: SortingState }) { + const [sorting, setSorting] = React.useState(defaultSort) + const table = useTable({ + _features, _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + state: { sorting }, + onSortingChange: setSorting, + }) + } + source: docs/framework/vanilla/guide/table-state.md:142-175; docs/framework/react/guide/table-state.md:285-315 + priority: HIGH + status: active + version_context: always present (same v8 rule) + - mistake: Writes to `table.baseAtoms.x.set(...)` while `atoms.x` owns the slice + mechanism: + When an external atom is supplied for a slice, `table.atoms.x` derives from it, not from + `baseAtoms.x`. Writes to the base atom silently no-op for reads; the UI shows the external atom's + value, the base atom drifts, and reset behavior gets weird. + wrong_pattern: | + // ❌ direct baseAtoms write while external atom owns the slice + const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) + const table = useTable({ _features, _rowModels: {...}, columns, data, atoms: { pagination: paginationAtom } }) + + // later, somewhere + table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) + // ❌ baseAtom updated, but table.atoms.pagination still reads from paginationAtom + correct_pattern: | + // ✅ write to the external atom (or use the feature's setter API) + paginationAtom.set((old) => ({ ...old, pageIndex: 0 })) + // or + table.setPageIndex(0) + source: + docs/framework/vanilla/guide/table-state.md:138-141; docs/framework/react/guide/table-state.md:280-283; + packages/table-core/src/core/table/constructTable.ts:93-103 + priority: HIGH + status: active + version_context: v9 alpha; new pitfall introduced by atom architecture + - mistake: Forgets `manualSorting`/`manualFiltering`/`manualPagination` when serving server-side data + mechanism: + 'Without the `manual*` flag, the table re-applies its client-side row models on top of + already-sorted/filtered/paginated server data — rows get re-sorted, re-filtered, or sliced into + another page. Symptoms: wrong rows shown, broken page math, blank pages.' + wrong_pattern: | + // ❌ data is already paginated server-side, but table still slices it + const dataQuery = useQuery({ queryKey: ['data', pagination], queryFn: fetchPage }) + const table = useTable({ + _features, _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: dataQuery.data?.rows ?? [], + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, + // ❌ missing manualPagination: true + }) + correct_pattern: | + // ✅ tell the table the server handles pagination + const table = useTable({ + _features, _rowModels: {}, // can drop paginatedRowModel if fully server-side + columns, + data: dataQuery.data?.rows ?? [], + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, + }) + source: + docs/framework/vanilla/guide/table-state.md:197-228; docs/framework/react/guide/table-state.md:336-378; + packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts:20-23 + priority: CRITICAL + status: active + version_context: always present (same v8 rule) + - mistake: Uses `table.reset()` to clear externally owned state + mechanism: + '`table.reset()` only resets the internal `baseAtoms` to `initialState`; slices owned by + external atoms or external `state` are untouched. Developers expect "reset everything" and end up + with half-reset state (visible state from external atom, hidden state in baseAtom drifts).' + wrong_pattern: | + // ❌ external atom keeps its current value; only baseAtoms reset + const sortingAtom = useCreateAtom([]) + const table = useTable({ + _features, _rowModels: {...}, columns, data, + atoms: { sorting: sortingAtom }, + }) + // ... + table.reset() // sortingAtom is NOT cleared + correct_pattern: | + // ✅ feature-specific reset writes through the slice's updater (atom-aware) + table.resetSorting() // works whether sorting is internal or external + // or, if you specifically want to clear the external atom: + sortingAtom.set([]) + source: + docs/framework/vanilla/guide/table-state.md:177-188; docs/framework/react/guide/table-state.md:317-328; + packages/table-core/src/core/table/coreTablesFeature.utils.ts:42-65 + priority: HIGH + status: active + version_context: v9 alpha; the atom split makes `reset()` less safe than v8 + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: API or state slice "missing" because the feature was not registered in `_features` + mechanism: + In v9, `_features` is a tree-shakeable registry. If a feature is not in `_features`, TypeScript + hides its APIs and the runtime atom is not created. Agents who copy a snippet for `table.setColumnFilters(...)` + without registering `columnFilteringFeature` see a TS error or `table.atoms.columnFilters` is undefined + — and may incorrectly conclude the feature is broken or removed in v9. + wrong_pattern: |- + // ❌ rowSortingFeature missing — table.setSorting / state.sorting unavailable + const _features = tableFeatures({}) // empty + const table = useTable({ _features, _rowModels: {}, columns, data }) + table.setSorting([{ id: 'age', desc: true }]) // ❌ does not exist on this table type + correct_pattern: |- + // ✅ Register every feature you intend to use; pair with its row model when applicable + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, data, + }) + priority: CRITICAL + version_context: + v9-specific. This is the first version of TanStack Table where features must be declared + for TypeScript and runtime to expose them. Expect heavy confusion from devs and agents trained on + v8. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - name: Customizing Feature Behavior + slug: customizing-feature-behavior + domain: core-foundations + description: + Override per-column `sortFn`, `filterFn`, `aggregationFn`, and table-level `globalFilterFn` + — and chain filter→sort metadata via `addMeta`. + type: core + packages: + - '@tanstack/table-core' + covers: + - filterFn + - sortFn + - aggregationFn + - globalFilterFn + - FilterFn + - SortFn + - AggregationFn + - addMeta + - autoRemove + - resolveFilterValue + - columnFiltersMeta + - filterFns + - sortFns + - aggregationFns + - FilterFnOption + - SortFnOption + - AggregationFnOption + - BuiltInFilterFn + - BuiltInSortFn + - BuiltInAggregationFn + - invertSorting + - sortUndefined + - sortDescFirst + tasks: + - Author a custom `filterFn` (e.g. fuzzy filter) and reference it by name from a column's `filterFn` + option after registering it via `createFilteredRowModel({ ...filterFns, custom })` + - 'Use the `addMeta` argument inside a `filterFn` to stash ranking info on the row, then read it from + a custom `sortFn` via `row.columnFiltersMeta[columnId]` for filter→sort handoff (canonical: match-sorter-utils + fuzzy)' + - Pick a built-in `sortFn` by string name (`alphanumeric`, `text`, `datetime`, `basic`) for a column, + then layer `invertSorting`/`sortDescFirst`/`sortUndefined` for direction control + - Register a custom `aggregationFn` for a grouped column (e.g. weighted average) by passing it through + `createGroupedRowModel({ ...aggregationFns, custom })` + failure_modes: + - mistake: References a custom filterFn by string without registering it + mechanism: + "String values for `filterFn` are looked up in `table._rowModelFns.filterFns`. If the custom + fn is not passed to `createFilteredRowModel({ ...filterFns, custom: myFn })`, the lookup misses + and `column_getFilterFn` logs `Could not find a valid 'column.filterFn' for column with the ID: + X`. The column then falls back to a no-op and the filter silently fails." + wrong_pattern: | + // ❌ "fuzzy" string never registered + const table = useTable({ + _features, columns: [ + columnHelper.accessor('fullName', { filterFn: 'fuzzy' }), + ], + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), // ❌ no fuzzy in here + }, + data, + }) + correct_pattern: | + // ✅ register the custom fn AND module-augment for type safety + declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } + } + + const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed + } + + const table = useTable({ + _features, + columns: [columnHelper.accessor('fullName', { filterFn: 'fuzzy' })], + _rowModels: { + filteredRowModel: createFilteredRowModel({ ...filterFns, fuzzy: fuzzyFilter }), + }, + data, + }) + source: examples/react/filters-fuzzy/src/main.tsx:37-80,117-127; packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts:85-108 + priority: CRITICAL + status: active + version_context: + v9 alpha; v9 made built-in fns into explicit arguments to row-model factories so + unregistered string names fail loudly in dev but quietly in prod + - mistake: Uses v8 `sortingFn` / `sortingFns` names in v9 + mechanism: + 'v9 renamed every sorting API: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, `SortingFn` + type → `SortFn`, `column.getSortingFn()` → `column.getSortFn()`. The old names typecheck only if + you accidentally pull from the legacy export and silently ignore your config.' + wrong_pattern: | + // ❌ v8 names — ignored in v9 column defs + columnHelper.accessor('age', { + sortingFn: 'alphanumeric', + }) + correct_pattern: | + // ✅ v9 names + columnHelper.accessor('age', { + sortFn: 'alphanumeric', + }) + source: docs/framework/react/guide/migrating.md:877-908; packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts:71-77 + priority: HIGH + status: active + version_context: v9 alpha; affects users upgrading from v8 + - mistake: Custom sortFn reads meta from a different column-filter id + mechanism: + '`row.columnFiltersMeta` is keyed by the column id that produced the meta (or `"__global__"` + for the global filter). A sortFn must look up the SAME column id the filterFn used. The fuzzy filter→sort + pattern only works when both reference the same column.' + wrong_pattern: | + // ❌ filter on 'fullName', sort reads meta from 'firstName' + const fuzzySort: SortFn = (a, b, columnId) => { + const meta = a.columnFiltersMeta['firstName'] // ❌ wrong key + return meta ? compareItems(meta.itemRank, b.columnFiltersMeta['firstName'].itemRank) : 0 + } + columnHelper.accessor('fullName', { filterFn: 'fuzzy', sortFn: fuzzySort }) + correct_pattern: | + // ✅ sortFn uses the same columnId arg that the row's filterFn ran on + const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir + } + source: + examples/react/filters-fuzzy/src/main.tsx:56-70; packages/table-core/src/features/column-filtering/createFilteredRowModel.ts:127-156 + (addMeta wiring) + priority: HIGH + status: active + version_context: always present + - mistake: Returns a complex value from accessor and uses a built-in sortFn + mechanism: + Built-in sortFns (`alphanumeric`, `text`, `basic`) coerce values via `toString()`/comparison + operators. An accessor that returns `{first, last}` will compare `"[object Object]"` strings — every + row ties and the original index ordering leaks through. The fix is to either return a primitive + from the accessor OR supply a sortFn that handles the shape. + wrong_pattern: | + // ❌ accessor returns object; alphanumeric sort sees "[object Object]" + columnHelper.accessor((row) => row.name, { + id: 'name', + sortFn: 'alphanumeric', + }) + correct_pattern: | + // ✅ Option A: return a primitive + columnHelper.accessor((row) => `${row.name.first} ${row.name.last}`, { + id: 'fullName', + sortFn: 'alphanumeric', + }) + // ✅ Option B: custom sortFn that knows the shape + columnHelper.accessor((row) => row.name, { + id: 'name', + sortFn: (a, b, id) => { + const av = a.getValue<{ first: string }>(id).first + const bv = b.getValue<{ first: string }>(id).first + return av === bv ? 0 : av > bv ? 1 : -1 + }, + }) + source: packages/table-core/src/fns/sortFns.ts:121-149; docs/guide/column-defs.md:231 + priority: MEDIUM + status: active + version_context: always present + skills: + - column-definitions + - mistake: Confuses `aggregationFn` with `aggregatedCell` + mechanism: + '`aggregationFn` produces the value for a grouped row (e.g. sum, mean). `aggregatedCell` + renders that value. New users put rendering logic inside `aggregationFn`, breaking re-renders and + string output.' + wrong_pattern: | + // ❌ rendering JSX inside the aggregation function + columnHelper.accessor('revenue', { + aggregationFn: (id, leaves) => ${leaves.reduce((a, r) => a + r.getValue(id), 0)}, + }) + correct_pattern: | + // ✅ aggregationFn returns a value; aggregatedCell renders it + columnHelper.accessor('revenue', { + aggregationFn: 'sum', // or a custom (id, leaves, children) => number + aggregatedCell: (info) => ${info.getValue().toLocaleString()}, + }) + source: packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts:53-78; packages/table-core/src/fns/aggregationFns.ts:1-242 + priority: MEDIUM + status: active + version_context: always present + - slug: filtering + type: core + packages: + - '@tanstack/table-core' + domain: row-model-features + description: + Filter rows in TanStack Table v9 — column filters, a global filter, faceted facet values + for filter UIs, and fuzzy ranking — using the `filteredRowModel` row-model pipeline stage. + subsystems: + - column-filtering + - global-filtering + - column-faceting + - global-faceting + - fuzzy-filtering + covers: + - columnFilteringFeature + - globalFilteringFeature + - columnFacetingFeature + - createFilteredRowModel(filterFns) + - createFacetedRowModel() + - createFacetedUniqueValues() + - createFacetedMinMaxValues() + - state.columnFilters (ColumnFiltersState = Array<{ id, value }>) + - state.globalFilter (any) + - onColumnFiltersChange + - onGlobalFilterChange + - columnDef.filterFn (string name | function | "auto") + - columnDef.enableColumnFilter + - columnDef.enableGlobalFilter + - manualFiltering + - enableFilters + - enableColumnFilters + - enableGlobalFilter + - globalFilterFn + - filterFromLeafRows + - maxLeafRowFilterDepth + - getColumnCanGlobalFilter + - getFacetedUniqueValues (server-side override) + - getFacetedMinMaxValues (server-side override) + - getGlobalFacetedUniqueValues (server-side override) + - getGlobalFacetedMinMaxValues (server-side override) + - table.setColumnFilters + - table.resetColumnFilters + - table.setGlobalFilter + - table.resetGlobalFilter + - table.getGlobalAutoFilterFn + - table.getGlobalFilterFn + - column.getFilterValue / setFilterValue / getCanFilter / getIsFiltered / getFilterIndex / getAutoFilterFn + / getFilterFn + - column.getCanGlobalFilter + - column.getFacetedRowModel / getFacetedUniqueValues / getFacetedMinMaxValues + - table.getGlobalFacetedRowModel / getGlobalFacetedUniqueValues / getGlobalFacetedMinMaxValues + - filterFns built-in registry + - filterFn.autoRemove + - filterFn.resolveFilterValue + - '@tanstack/match-sorter-utils (rankItem, compareItems, RankingInfo)' + - row.columnFiltersMeta (FilterMeta extended via declare module) + - addMeta callback (4th argument to FilterFn) + tasks: + - Wire up text/range/select per-column filters with debounced inputs (see examples/react/filters/src/main.tsx). + - 'Add a faceted filter UI: autocomplete from `column.getFacetedUniqueValues()` and a range slider from + `column.getFacetedMinMaxValues()` (requires `createFacetedRowModel()` PLUS the unique/minMax row models).' + - Implement fuzzy global search with `@tanstack/match-sorter-utils` and a custom `fuzzySort` that reads + `row.columnFiltersMeta[columnId].itemRank`. + - 'Switch a table to manual server-side filtering: set `manualFiltering: true`, omit the `filteredRowModel` + from `_rowModels`, and pass pre-filtered `data` from the server.' + - 'Make a tree table filter sub-rows by enabling `filterFromLeafRows: true` so a parent stays visible + whenever any child matches.' + failure_modes: + - mistake: + Forgetting `createFacetedRowModel()` while still registering `createFacetedUniqueValues()` + / `createFacetedMinMaxValues()`. + mechanism: | + `createFacetedUniqueValues` and `createFacetedMinMaxValues` both compute their results from `column.getFacetedRowModel().flatRows`. Without the base `facetedRowModel` factory in `_rowModels.facetedRowModel`, `column_getFacetedRowModel` falls back to `table.getPreFilteredRowModel()` (see column-faceting/columnFacetingFeature.utils.ts lines 44-56), so facet values are still computed but **don't exclude the column's own active filter** — meaning a select dropdown showing options for a column will collapse to only the currently selected value once the user picks one. + wrong_pattern: | + ```tsx + const table = useTable({ + _features: tableFeatures({ columnFacetingFeature, columnFilteringFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + // BUG: missing facetedRowModel - the unique/minMax helpers will use + // the pre-filtered model so they include every filter EXCEPT none + facetedUniqueValues: createFacetedUniqueValues(), + facetedMinMaxValues: createFacetedMinMaxValues(), + }, + columns, data, + }) + ``` + correct_pattern: | + ```tsx + // From examples/react/filters-faceted/src/main.tsx + const table = useTable({ + _features: tableFeatures({ + columnFacetingFeature, + columnFilteringFeature, + rowPaginationFeature, + }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + facetedRowModel: createFacetedRowModel(), // REQUIRED base + facetedMinMaxValues: createFacetedMinMaxValues(), + facetedUniqueValues: createFacetedUniqueValues(), + }, + columns, data, + }) + ``` + source: packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - mistake: + 'Using `manualFiltering: true` without removing the `filteredRowModel` factory (works), or + expecting `manualFiltering` to filter the row model anyway.' + mechanism: | + When `manualFiltering: true`, `table_getFilteredRowModel` short-circuits and returns `getPreFilteredRowModel()` (the core row model). The filter state still exists and `onColumnFiltersChange` still fires — but rows are NOT filtered client-side. Agents commonly add the filter UI, hook up `setFilterValue`, and forget to refetch server data, leaving filter UI changes invisible. + wrong_pattern: | + ```tsx + // BUG: manualFiltering: true means the filteredRowModel is BYPASSED. + // Filter state changes do nothing visible unless `data` is refetched. + const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + }, + data, + columns, + manualFiltering: true, + // ...but no useEffect to refetch when columnFilters changes + }) + ``` + correct_pattern: | + ```tsx + // From docs/guide/column-filtering.md + const [columnFilters, setColumnFilters] = useState([]) + const { data } = useQuery(['rows', columnFilters], () => + fetch('/api/rows?' + serialize(columnFilters)).then(r => r.json()) + ) + const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: {}, // no filteredRowModel needed for manual filtering + data, + columns, + manualFiltering: true, + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + }) + ``` + source: packages/table-core/src/core/row-models/coreRowModelsFeature.utils.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - mistake: + 'Custom fuzzy filter without merging into `filterFns`: passing only `{ fuzzy: fuzzyFilter + }` to `createFilteredRowModel`, dropping the 10 built-in filters.' + mechanism: | + `createFilteredRowModel(filterFns)` accepts a `Record`. The argument is stored AS-IS at `table._rowModelFns.filterFns`, replacing the registry. If a developer writes `createFilteredRowModel({ fuzzy: fuzzyFilter })`, then any column with `filterFn: 'includesString'` will trigger `Could not find a valid 'column.filterFn' for column with the ID: ...` (warning at columnFilteringFeature.utils.ts:102) and the column won't filter. + wrong_pattern: | + ```tsx + // BUG: drops the built-in registry + filteredRowModel: createFilteredRowModel({ + fuzzy: fuzzyFilter, + }), + // Column with filterFn: 'includesString' now warns and never filters + ``` + correct_pattern: | + ```tsx + // From examples/react/filters-fuzzy/src/main.tsx + import { filterFns, sortFns } from '@tanstack/react-table' + import { rankItem, compareItems } from '@tanstack/match-sorter-utils' + + declare module '@tanstack/react-table' { + interface FilterFns { fuzzy: FilterFn } + interface FilterMeta { itemRank?: RankingInfo } + } + + const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed + } + + const table = useTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel({ + ...filterFns, // KEEP built-ins + fuzzy: fuzzyFilter, // ADD custom + }), + sortedRowModel: createSortedRowModel(sortFns), + }, + globalFilterFn: 'fuzzy', + columns, data, + }) + ``` + source: examples/react/filters-fuzzy/src/main.tsx + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - sorting + - mistake: + Using `state.columnFilters` AND `initialState.columnFilters` at the same time (or same for + globalFilter). + mechanism: | + When both are set, the controlled `state.columnFilters` value overrides the `initialState.columnFilters` on every render. The initial state is effectively ignored. This pattern usually signals confusion between controlled and uncontrolled state ownership — and developers often expect `initialState` to seed `state` on first render, which TanStack Table does NOT do. + wrong_pattern: | + ```tsx + const [columnFilters, setColumnFilters] = useState([]) + // ^^ empty + const table = useTable({ + _features, _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, data, + initialState: { + columnFilters: [{ id: 'name', value: 'John' }], // IGNORED + }, + state: { columnFilters }, // wins, starts empty + onColumnFiltersChange: setColumnFilters, + }) + ``` + correct_pattern: | + ```tsx + // Seed the controlled state at useState time, NOT in initialState. + // From docs/guide/column-filtering.md + const [columnFilters, setColumnFilters] = useState([ + { id: 'name', value: 'John' }, // seeded here + ]) + const table = useTable({ + _features, _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, data, + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + }) + ``` + source: docs/guide/column-filtering.md + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - mistake: + Globally filtering on columns whose values aren't strings or numbers and getting silently-ignored + search. + mechanism: | + `globalFilteringFeature` defaults `getColumnCanGlobalFilter` to a function that returns `typeof value === 'string' || typeof value === 'number'` from sampling the first row's cell value. Objects, arrays, booleans, dates, and undefined values fail this default check and the column is excluded from the global filter scan. There is no warning — the column just doesn't participate. + wrong_pattern: | + ```tsx + // BUG: createdAt is a Date object — global filter silently skips it. + const columns = [ + columnHelper.accessor('createdAt', { header: 'Created' }), + columnHelper.accessor('name', { header: 'Name' }), + ] + // table.setGlobalFilter('2024') will never find Date rows + ``` + correct_pattern: | + ```tsx + // Override getColumnCanGlobalFilter or set per-column. + const table = useTable({ + _features: tableFeatures({ globalFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, data, + globalFilterFn: 'includesString', + getColumnCanGlobalFilter: (column) => true, // include every column + }) + // Or per-column: + columnHelper.accessor('createdAt', { + header: 'Created', + enableGlobalFilter: true, + }) + ``` + source: packages/table-core/src/features/global-filtering/globalFilteringFeature.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - mistake: Expecting `filterFromLeafRows` to keep child rows visible when only the parent matches. + mechanism: | + `filterFromLeafRows: true` walks the tree bottom-up and keeps a parent if ANY descendant matches. The inverse — keeping all descendants when the parent matches — is the **default** root-down behavior (see filterRowsUtils.ts:103). The two modes are mutually exclusive. To preserve all sub-rows under a matching parent without checking children, use `maxLeafRowFilterDepth: 0` so only root-level rows are filtered. + wrong_pattern: | + ```tsx + // BUG: filterFromLeafRows hides ALL children that don't match, + // even though the parent does match. + const table = useTable({ + _features: tableFeatures({ columnFilteringFeature, rowExpandingFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns), expandedRowModel: createExpandedRowModel() }, + columns, data, + getSubRows: r => r.subRows, + filterFromLeafRows: true, + // expectation: parent matches "John" -> all children visible + // reality: only children that also match "John" stay visible + }) + ``` + correct_pattern: | + ```tsx + // Keep parent's whole sub-tree when parent matches: filter root-only. + const table = useTable({ + _features: tableFeatures({ columnFilteringFeature, rowExpandingFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, data, + getSubRows: r => r.subRows, + maxLeafRowFilterDepth: 0, // only filter root-level rows + }) + ``` + source: packages/table-core/src/features/column-filtering/filterRowsUtils.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - filtering + - row-expanding + - mistake: Column filter returns no matches when first row has null in that column + mechanism: | + The default `auto` filter type detects column type from the first row's value. + If row 0 has `null`/`undefined` for the column, type detection picks the wrong + filter and never matches. + wrong_pattern: | + // ❌ First row has null; column filter returns 0 results + const data = [ + { id: 1, name: null }, + { id: 2, name: 'Alice' }, + ] + correct_pattern: | + // ✅ Explicitly set filterFn on the column instead of relying on auto + columnHelper.accessor('name', { + filterFn: 'includesString', // or a custom fn + }) + source: + https://github.com/TanStack/table/issues/4711 (Column filter not working when first value + in data is null), https://github.com/TanStack/table/issues/4919 (Filter not working if accessorFn + returns null) + priority: MEDIUM + status: active + version_context: v8 behavior + skills: + - filtering + - column-definitions + - mistake: getFilteredRowModel retains memory after filter removal + mechanism: | + Heap snapshots show `PerformanceMeasure`, `FiberNode`, and `Error` objects + accumulating across filter-apply/remove cycles. The internal caching mechanism + in `getFilteredRowModel` doesn't release filtered row arrays even after filter + state is cleared. + wrong_pattern: | + // ❌ Memory grows with each filter toggle + const table = useReactTable({ + data, columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }) + correct_pattern: | + // ✅ Manual client-side filter as workaround + const filteredData = useMemo(() => { + if (!columnFilters.length) return data + return data.filter(row => + columnFilters.every(f => /* manual matching */ true) + ) + }, [data, columnFilters]) + const table = useReactTable({ data: filteredData, columns, getCoreRowModel: getCoreRowModel() }) + source: + https://github.com/TanStack/table/issues/6170 (Memory leak in getFilteredRowModel - heap not + released after filter removal) + priority: MEDIUM + status: active + version_context: v8.21.3 — relevant for long-running apps; v9 row-model API may behave differently + skills: + - filtering + - production-readiness + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + reference_candidates: + - name: built-in filterFns + kind: function-registry + count: 12 + source: packages/table-core/src/fns/filterFns.ts + documented_in_guide: + - includesString + - includesStringSensitive + - equalsString + - equalsStringSensitive + - arrIncludes + - arrIncludesAll + - arrIncludesSome + - equals + - weakEquals + - inNumberRange + also_exported: + - arrHas + - between + - betweenInclusive + - greaterThan + - greaterThanOrEqualTo + - lessThan + - lessThanOrEqualTo + note: + "Docs list the built-in filterFn registry but the registry actually exports 12 (adds `arrHas`, + `between`, `betweenInclusive`). `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo` + are NOT included in the registry export but are used by the range filters. Expanding example uses + `filterFn: 'between'` — which IS in the registry." + gaps: + - Docs say the built-in filterFn registry but the actual `filterFns` registry exports 12. Need authoritative + reference for the full set. + - '`column.getCanGlobalFilter` returns `false` for columns with first-row non-string/non-number values; + this is the SILENT failure case. Worth a dedicated reference table for which value types pass auto-detection.' + - "`addMeta` is optional in the FilterFn signature — fuzzy example uses `addMeta?.()`. Need clear rule + for when meta is/isn't passed (it IS passed by `createFilteredRowModel`)." + - No documented invariant on what happens when `filterFn` resolves to undefined; the warning is dev-mode + only and the column silently uses no filter. + - slug: sorting + type: core + packages: + - '@tanstack/table-core' + domain: row-model-features + description: + Sort rows in TanStack Table v9 with the `sortedRowModel` stage — built-in sortFns, custom + sortFns, multi-sort, sortUndefined placement, invertSorting for "lower-is-better" scales, and manual + server-side sorting. + covers: + - rowSortingFeature + - createSortedRowModel(sortFns) + - state.sorting (SortingState = Array<{ id, desc }>) + - onSortingChange + - 'columnDef.sortFn (string | function | "auto") # v9 renamed from sortingFn' + - columnDef.sortDescFirst + - columnDef.sortUndefined (false | -1 | 1 | "first" | "last") + - columnDef.invertSorting + - columnDef.enableSorting + - columnDef.enableMultiSort + - manualSorting + - enableSorting + - enableSortingRemoval + - enableMultiSort + - enableMultiRemove + - maxMultiSortColCount + - isMultiSortEvent + - sortDescFirst + - table.setSorting + - table.resetSorting + - column.getCanSort / getIsSorted / getSortIndex / getCanMultiSort / clearSorting + - column.getToggleSortingHandler / toggleSorting + - column.getFirstSortDir / getNextSortingOrder / getAutoSortDir / getAutoSortFn / getSortFn + - 'sortFns built-in registry # v9 renamed from sortingFns' + tasks: + - Add clickable column-header sorting with multi-sort on Shift+click (see examples/react/sorting/src/main.tsx). + - 'Wire a column-specific custom `sortFn` for an enum (e.g. status: single < complicated < relationship).' + - "Sort nullable values with explicit placement using `sortUndefined: 'last'` so undefined never confuses + the auto sortFn picker." + - "Use `invertSorting: true` on a `rank` column so 1st ranks above 2nd even when the user clicks 'descending'." + - 'Switch to server-side sorting: `manualSorting: true`, omit `sortedRowModel`, mirror `state.sorting` + to the fetch URL.' + failure_modes: + - mistake: Using v8's `sortingFn` / `sortingFns` after upgrading to v9. + mechanism: | + v9 renamed `columnDef.sortingFn` -> `columnDef.sortFn`, `tableOptions.sortingFns` -> `tableOptions.sortFns`, and the exported registry from `sortingFns` -> `sortFns`. The new column option `sortFn` defaults to `'auto'` and looks up `column.table._rowModelFns.sortFns` (see rowSortingFeature.utils.ts:160-173). A column with the v8 `sortingFn: 'alphanumeric'` will silently fall through to `sortFn_basic` (via the `?? sortFn_basic` fallback at line 172). + wrong_pattern: | + ```tsx + // v8-style column def — works without type error if `as any`, sorts wrong + { + accessorKey: 'fullName', + sortingFn: 'alphanumeric', // v8 name + } + // useTable({ sortingFns: { ...sortingFns, myFn } }) // v8 option name + ``` + correct_pattern: | + ```tsx + // v9 names. From examples/react/sorting/src/main.tsx + import { sortFns, createSortedRowModel } from '@tanstack/react-table' + + columnHelper.accessor('firstName', { + sortFn: 'alphanumeric', // v9 name + }) + + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { + sortedRowModel: createSortedRowModel({ // pass registry here + ...sortFns, + myCustom: (a, b, id) => a.original[id] - b.original[id], + }), + }, + columns, data, + }) + ``` + source: packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - sorting + - mistake: "Expecting `sortUndefined: 'first' | 'last'` to work in v8 (they only exist in v9)." + mechanism: | + v8 only had `sortUndefined: false | -1 | 1`. v9 added the `'first'` and `'last'` literal aliases. The createSortedRowModel switch at lines 100-110 has explicit branches: `if (sortUndefined === 'first') return aUndefined ? -1 : 1`. With `1` (the v9 default), undefined sorts ascending toward the END (lower priority); with `-1`, it sorts toward the START. The semantic difference: numeric `1`/`-1` flip when `desc: true`; `'first'`/`'last'` are absolute. + wrong_pattern: | + ```tsx + // BUG: agent assumes 'first' = ascending-start. But with desc: true, + // numeric 1 flips to "first" anyway. The literal forms are ABSOLUTE. + { accessorKey: 'lastName', sortUndefined: -1 } // ascending-first, descending-LAST + ``` + correct_pattern: | + ```tsx + // From examples/react/sorting/src/main.tsx + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + sortUndefined: 'last', // ABSOLUTE: always at end regardless of asc/desc + sortDescFirst: false, // nullable values can mess up auto detection + }), + ``` + source: packages/table-core/src/features/row-sorting/createSortedRowModel.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - sorting + - mistake: Custom `sortFn` returns `desc`-aware values (i.e. negating inside the function). + mechanism: | + From sorting.md: "The comparison function does not need to take whether or not the column is in descending or ascending order into account. The row models will take of that logic." `createSortedRowModel` multiplies the return by `-1` when `isDesc`, then again by `-1` if `invertSorting` is true (lines 119-126). A custom sortFn that returns -1 for "A before B" when descending will get doubly-flipped. + wrong_pattern: | + ```tsx + // BUG: takes sort direction into account, breaks toggle + const customSort: SortFn = (a, b, id, desc) => { + // desc isn't even a parameter — but agents try to detect via state + const cmp = a.original[id] - b.original[id] + return desc ? -cmp : cmp + } + ``` + correct_pattern: | + ```tsx + // From examples/react/sorting/src/main.tsx + // Always return ascending-order comparison; the row model handles desc. + const sortStatusFn: SortFn = (rowA, rowB, _columnId) => { + const statusOrder = ['single', 'complicated', 'relationship'] + return statusOrder.indexOf(rowA.original.status) - + statusOrder.indexOf(rowB.original.status) + } + ``` + source: packages/table-core/src/features/row-sorting/createSortedRowModel.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - sorting + - mistake: + Using fuzzy filter on a column but not pairing it with a fuzzy-aware sortFn that reads `itemRank` + from `columnFiltersMeta`. + mechanism: | + The fuzzy filter writes `{ itemRank }` into `row.columnFiltersMeta[columnId]` via the `addMeta` callback (see createFilteredRowModel.ts lines 132-136). To rank results by match quality, a custom sortFn must read this meta and call `compareItems` from `@tanstack/match-sorter-utils`. If the column uses `sortFn: 'alphanumeric'` (the default), rows are sorted alphabetically, NOT by closest match — defeating the whole point of fuzzy search. + wrong_pattern: | + ```tsx + columnHelper.accessor(...{ + filterFn: 'fuzzy', + // BUG: missing sortFn — rows sort alphabetically, not by rank + }) + ``` + correct_pattern: | + ```tsx + // From examples/react/filters-fuzzy/src/main.tsx + import { compareItems } from '@tanstack/match-sorter-utils' + import { sortFns } from '@tanstack/react-table' + + const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir + } + + columnHelper.accessor(..., { + filterFn: 'fuzzy', + sortFn: fuzzySort, + }) + ``` + source: examples/react/filters-fuzzy/src/main.tsx + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - sorting + - filtering + - mistake: getCanSort returns false with manualSorting when no accessor is set + mechanism: | + `getCanSort` checks for `accessorKey`/`accessorFn` even when `manualSorting: true`, + so display columns (no accessor) can't be sorted server-side. Users with virtual + "actions" columns or computed display-only columns hit this immediately. + wrong_pattern: | + // ❌ getCanSort() returns false even though manualSorting is true + const table = useReactTable({ + manualSorting: true, + columns: [ + { id: 'computed', header: 'Computed', cell: (info) => row.x + row.y }, + ], + ... + }) + correct_pattern: | + // ✅ Provide an accessorFn even if value is unused, OR force enableSorting on the column + columnHelper.display({ + id: 'computed', + header: 'Computed', + enableSorting: true, // force-enable for manualSorting + cell: (info) => info.row.original.x + info.row.original.y, + }) + source: + https://github.com/TanStack/table/issues/4136 (getCanSort returning false when manualSorting + enabled, 14 comments) + priority: MEDIUM + status: active + version_context: v8 / v9 + - mistake: Manual sorting bounces ascending→false→ascending when values tie on current page + mechanism: | + With `manualSorting: true`, when sort state cycles through + asc → desc → unsorted, the table compares `row.original` references for + determinism. If all visible values are equal (e.g. all empty strings), the + "desc" stage produces the same order as "asc" and looks like the sort isn't + changing — users see asc → unsorted → asc. + wrong_pattern: | + // ❌ User-visible: clicking sort goes asc → unsorted → asc when values tie + const table = useReactTable({ manualSorting: true, ... }) + correct_pattern: | + // ✅ Add a secondary stable tiebreaker server-side, and surface the actual sort + // direction from server-controlled state to the UI rather than relying on + // table.getState().sorting alone. + source: + https://github.com/TanStack/table/issues/5147 (Sorting direction not updating properly with + manual sorting, 10 comments) + priority: MEDIUM + status: active + version_context: v8 manual sorting + skills: + - sorting + - client-to-server + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + reference_candidates: + - name: built-in sortFns + kind: function-registry + count: 6 + source: packages/table-core/src/fns/sortFns.ts + entries: + - alphanumeric + - alphanumericCaseSensitive + - text + - textCaseSensitive + - datetime + - basic + note: + 'Default fallback when no sortFn matches is `sortFn_basic` (see rowSortingFeature.utils.ts:172). + `auto` infers from sampled values: date -> datetime, mixed alphanumeric -> alphanumeric, string + -> text, else basic.' + gaps: + - '`column_getAutoSortFn` samples `firstRows = getFilteredRowModel().flatRows.slice(10)` — this is `.slice(10)`, + NOT `.slice(0, 10)`! It skips the first 10 rows and uses everything after. Likely a bug; verify with + maintainer.' + - "`getSelectedRowModel`, `getFilteredSelectedRowModel`, and `getGroupedSelectedRowModel` in rowSelectionFeature.utils.ts + ALL currently call `selectRowsFn(rowModel)` against `table.getCoreRowModel()` — they appear identical. + Either they're stubs awaiting wiring or the dispatch is missing. Worth flagging." + - slug: pagination + type: core + packages: + - '@tanstack/table-core' + domain: row-model-features + description: + Paginate rows in TanStack Table v9 with the `paginatedRowModel` stage — page navigation + APIs, total row/page count, automatic page reset on data changes, and manual server-side pagination. + covers: + - rowPaginationFeature + - createPaginatedRowModel() + - 'state.pagination ({ pageIndex, pageSize }) # defaults: { pageIndex: 0, pageSize: 10 }' + - onPaginationChange + - manualPagination + - pageCount + - rowCount + - autoResetPageIndex + - paginateExpandedRows + - table.setPagination / resetPagination + - table.setPageIndex / resetPageIndex + - table.setPageSize / resetPageSize + - table.nextPage / previousPage / firstPage / lastPage + - table.getCanNextPage / getCanPreviousPage + - table.getPageCount / getRowCount / getPageOptions + - table.getPaginatedRowModel / getPrePaginatedRowModel + tasks: + - Add a pagination toolbar with `<<`, `<`, `>`, `>>` buttons, current page indicator, page size selector, + and 'go to page' input (see examples/react/pagination/src/main.tsx). + - 'Switch to server-side pagination: `manualPagination: true`, supply `rowCount` (or `pageCount`) from + the server, omit `paginatedRowModel`, refetch when `state.pagination` changes.' + - 'Stop the automatic page-index reset when filters change: `autoResetPageIndex: false` (e.g., to keep + page 3 visible while a user types into a search box).' + - 'Display total filtered rows even when paginated: `table.getPrePaginatedRowModel().rows.length` (see + filters example).' + failure_modes: + - mistake: 'Setting `manualPagination: true` without supplying `rowCount` (or `pageCount`).' + mechanism: | + With `manualPagination` set, `table_getPageCount` returns `options.pageCount ?? Math.ceil(getRowCount() / pageSize)`. `table_getRowCount` returns `options.rowCount ?? getPrePaginatedRowModel().rows.length`. In manual mode, the pre-paginated row model contains only the CURRENT page's rows — so `getRowCount()` becomes the page size (e.g., 10), `getPageCount()` becomes 1, and `getCanNextPage()` returns `false`. Pagination is effectively broken. + wrong_pattern: | + ```tsx + // BUG: getPageCount returns 1, next/prev buttons are disabled + const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, + data, // only 10 rows for the current page + columns, + manualPagination: true, + state: { pagination }, + onPaginationChange: setPagination, + // missing: rowCount or pageCount + }) + ``` + correct_pattern: | + ```tsx + // From docs/guide/pagination.md + const { data: dataQuery } = useQuery(['rows', pagination], () => + fetchPage(pagination.pageIndex, pagination.pageSize), + ) + const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, + data: dataQuery.rows, + columns, + manualPagination: true, + rowCount: dataQuery.rowCount, // server tells the table the total + // OR: pageCount: dataQuery.pageCount + // OR: pageCount: -1 if unknown (next button always enabled) + state: { pagination }, + onPaginationChange: setPagination, + }) + ``` + source: packages/table-core/src/features/row-pagination/rowPaginationFeature.utils.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - pagination + - mistake: Putting `pagination` in BOTH `state.pagination` and `initialState.pagination`. + mechanism: | + When both are set, the controlled `state.pagination` wins on every render. `initialState.pagination` is ignored. Agents often see "my pageSize default isn't working" because they wrote `useState({ pageIndex: 0, pageSize: 10 })` AND `initialState: { pagination: { pageSize: 25 } }`. The pageSize stays at 10. + wrong_pattern: | + ```tsx + // BUG: initialState ignored. + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + const table = useTable({ + initialState: { pagination: { pageSize: 25 } }, // IGNORED + state: { pagination }, // wins (pageSize 10) + onPaginationChange: setPagination, + // ... + }) + ``` + correct_pattern: | + ```tsx + // Seed in useState OR use initialState only — never both. + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }) + const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, data, + state: { pagination }, + onPaginationChange: setPagination, + }) + ``` + source: docs/guide/pagination.md + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - pagination + - mistake: + 'Disabling `autoResetPageIndex: false` and forgetting to clamp `pageIndex` when the data + shrinks below the current page.' + mechanism: | + `autoResetPageIndex` defaults to `!manualPagination` (so true client-side, false manual). When false and data changes, `pageIndex` stays put. `table_setPageIndex` only clamps against `options.pageCount` (max safe int when pageCount is unset/-1, see rowPaginationFeature.utils.ts:123-129) — it does NOT clamp against the current row model. Result: user sees an empty page after a destructive filter. + wrong_pattern: | + ```tsx + const table = useTable({ + _features, _rowModels: { paginatedRowModel: createPaginatedRowModel(), filteredRowModel: createFilteredRowModel(filterFns) }, + columns, data, + autoResetPageIndex: false, // user on page 5, then filters down to 2 pages + // -> page 5 is empty. No automatic clamp. + }) + ``` + correct_pattern: | + ```tsx + // Either leave autoResetPageIndex at default (true)... + const table = useTable({ /* ... */ }) // autoResetPageIndex undefined = on + + // ...or clamp manually after any data-altering effect: + useEffect(() => { + const lastPage = Math.max(0, table.getPageCount() - 1) + if (table.atoms.pagination.get().pageIndex > lastPage) { + table.setPageIndex(lastPage) + } + }, [data, columnFilters]) + ``` + source: packages/table-core/src/features/row-pagination/rowPaginationFeature.utils.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - pagination + - mistake: + Computing `getRowCount()` and expecting it to match `data.length` when grouping/filtering/expansion + is active. + mechanism: | + `table_getRowCount` returns `options.rowCount ?? getPrePaginatedRowModel().rows.length`. The pre-paginated row model is the END of the pipeline before the page slice — so it reflects filtering, grouping, sorting, AND expansion. With `paginateExpandedRows: false`, expanded children are NOT in `getPrePaginatedRowModel().rows` (they're appended to the parent's page in `createPaginatedRowModel`'s expandRows call). With `paginateExpandedRows: true` (default), they ARE counted as their own paginated rows. + wrong_pattern: | + ```tsx + // BUG: agent expects getRowCount() === data.length + console.log(table.getRowCount()) // count after all transforms + console.log(data.length) // count of raw input + // These will diverge under filtering, grouping, OR a flat input != tree input. + ``` + correct_pattern: | + ```tsx + // Use the right model for the question being asked. + table.getCoreRowModel().rows.length // raw row count (flat) + table.getPreFilteredRowModel().rows.length // before filtering + table.getFilteredRowModel().rows.length // after filtering + table.getRowCount() // pre-paginated count (server count if rowCount option set) + table.getRowModel().rows.length // current page only + ``` + source: docs/guide/row-models.md + priority: low + status: active + version_context: v9.0.0-alpha.47 + skills: + - pagination + - filtering + - grouping + - mistake: getToggleAllRowsSelected behaves differently with server pagination + getRowId + mechanism: | + With server-side pagination, you must supply `getRowId` (so selections survive + page changes). But once `getRowId` is set, `getToggleAllRowsSelectedHandler` only + selects the current page's rows (because the table only knows about current-page + rows — it can't enumerate all server-side rows). Users expect "select all" to + mean truly all, identical to client-side mode. + wrong_pattern: | + // ❌ Mismatched expectations — header checkbox only affects current page + + correct_pattern: | + // ✅ Use page-aware APIs explicitly with server pagination + + // For "select all server-side rows", implement an explicit out-of-band + // selection mode — track a boolean "all rows mode" alongside the row map. + source: https://github.com/TanStack/table/issues/4781 (Pagination and Row Selection, 10 comments) + priority: HIGH + status: active + version_context: + Fundamental UX consequence of server pagination; not a bug but a recurring confusion + point + skills: + - pagination + - row-selection + - client-to-server + - mistake: autoResetPageIndex resets to initialState.pageIndex instead of 0 + mechanism: | + `_autoResetPageIndex` calls `table.resetPageIndex()` without `true`, so when data + shrinks (e.g. after applying a filter) the page resets to whatever + `initialState.pagination.pageIndex` was — typically a deep-linked page restored + from a URL — leaving the user on an invalid page. + wrong_pattern: | + // ❌ Filtering data resets to the deep-linked pageIndex, not 0 + const table = useReactTable({ + data, columns, + initialState: { pagination: { pageIndex: 5, pageSize: 10 } }, + autoResetPageIndex: true, + ... + }) + correct_pattern: | + // ✅ Explicitly reset to 0 when filters change + useEffect(() => { + table.setPageIndex(0) + }, [columnFilters, globalFilter]) + // Or: don't rely on autoResetPageIndex when deep-linking pageIndex + source: + https://github.com/TanStack/table/issues/6207 (autoResetPageIndex resets to initialState pageIndex + instead of 0) + priority: MEDIUM + status: active + version_context: v8.21.3 bug, likely inherited in v9 alpha + skills: + - pagination + - state-management + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + reference_candidates: [] + gaps: + - '`autoResetPageIndex` is silently `false` when `manualPagination` is true (see rowPaginationFeature.utils.ts:43-50). + This is documented but easy to miss. Worth a dedicated note.' + - slug: grouping + type: core + packages: + - '@tanstack/table-core' + domain: row-model-features + description: + Group rows by column values in TanStack Table v9 with the `groupedRowModel` stage — the + built-in aggregationFn registry, custom aggregations, grouped-column placement modes, manual server-side + grouping, and expand-grouped-rows interaction with the expanding feature. + covers: + - columnGroupingFeature + - createGroupedRowModel(aggregationFns) + - state.grouping (GroupingState = Array) + - onGroupingChange + - columnDef.aggregationFn ("auto" | name | function) + - columnDef.aggregatedCell + - columnDef.getGroupingValue + - columnDef.enableGrouping + - manualGrouping + - enableGrouping + - 'groupedColumnMode (false | "reorder" | "remove") # default "reorder"' + - table.setGrouping / resetGrouping + - column.toggleGrouping / getCanGroup / getIsGrouped / getGroupedIndex + - column.getToggleGroupingHandler + - column.getAggregationFn / getAutoAggregationFn + - cell.getIsGrouped / getIsAggregated / getIsPlaceholder + - row.getIsGrouped / getGroupingValue / groupingColumnId / groupingValue / leafRows + - aggregationFns built-in registry (8 fns) + tasks: + - Add per-column 'group' toggles in headers and render grouped cells with subRow counts + expand icons + (see examples/react/grouping/src/main.tsx). + - 'Mix aggregation strategies across columns: `sum` for visits, `median` for age, `mean` for progress, + `count` for the implicit grouping column.' + - "Override a column's grouping key with `getGroupingValue: row => ${row.firstName} ${row.lastName}` + so rows group on a derived value, not the column accessor value." + - 'Pair grouping with expanding so users can drill into each group: register `rowExpandingFeature` + + `expandedRowModel` and call `row.getToggleExpandedHandler()` on grouped cells.' + - "Hide grouped columns from the visible column flow with `groupedColumnMode: 'remove'`, or leave + them in place with `groupedColumnMode: false`." + failure_modes: + - mistake: + Adding `columnGroupingFeature` without also registering `rowExpandingFeature` and expecting + grouped rows to expand. + mechanism: | + `createGroupedRowModel` produces grouped rows with `subRows`, but those rows aren't VISIBLE until the expanded row model flattens them in. Without `rowExpandingFeature` + `createExpandedRowModel()`, `row.getToggleExpandedHandler` doesn't exist (TypeScript error). Even if you skip the handler, the grouped row is collapsed by default and there's no way to drill down. Aggregated cells appear but child rows are unreachable. + wrong_pattern: | + ```tsx + // BUG: grouped rows show aggregates but can't be expanded. + const _features = tableFeatures({ columnGroupingFeature }) + const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, data, + }) + // row.getToggleExpandedHandler() -> TS error or undefined + ``` + correct_pattern: | + ```tsx + // From examples/react/grouping/src/main.tsx + import { + aggregationFns, + columnGroupingFeature, + createExpandedRowModel, + createGroupedRowModel, + rowExpandingFeature, + } from '@tanstack/react-table' + + const _features = tableFeatures({ + columnGroupingFeature, + rowExpandingFeature, + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + }) + + const table = useTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + // + sorted, filtered, paginated as needed + }, + columns, data, + }) + + // In cell renderer: + {cell.getIsGrouped() && ( + + )} + ``` + source: examples/react/grouping/src/main.tsx + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - grouping + - row-expanding + - mistake: + Customizing `aggregationFns` like the v8 `aggregationFns` option (passing the registry through + `tableOptions.aggregationFns` instead of `createGroupedRowModel`). + mechanism: | + In v9, the aggregation function registry is passed as the first argument to `createGroupedRowModel(aggregationFns)`, which stores it at `table._rowModelFns.aggregationFns`. There is NO top-level `tableOptions.aggregationFns` like sorting/filtering have via row model factory. Agents migrating from v8 commonly try `useTable({ aggregationFns: { ... } })` and it has no effect; columns with custom `aggregationFn: 'myCustom'` resolve to `undefined`. + wrong_pattern: | + ```tsx + // BUG: v8-style option that doesn't exist in v9 + const table = useTable({ + _features: tableFeatures({ columnGroupingFeature }), + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, data, + // @ts-ignore - this property doesn't exist on TableOptions in v9 + aggregationFns: { + myCustom: (id, leaf, child) => /* ... */, + }, + }) + ``` + correct_pattern: | + ```tsx + // From docs/guide/grouping.md - register via createGroupedRowModel + import { aggregationFns, createGroupedRowModel } from '@tanstack/react-table' + + const table = useTable({ + _features: tableFeatures({ columnGroupingFeature, rowExpandingFeature }), + _rowModels: { + groupedRowModel: createGroupedRowModel({ + ...aggregationFns, + myCustomAggregation: (columnId, leafRows, childRows) => { + return /* aggregated value */ + }, + }), + expandedRowModel: createExpandedRowModel(), + }, + columns, data, + }) + + // Then on a column: + { accessorKey: 'sales', aggregationFn: 'myCustomAggregation' } + ``` + source: packages/table-core/src/features/column-grouping/createGroupedRowModel.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - grouping + - mistake: 'Confusing `aggregationFn` signature: `(columnId, leafRows, childRows)` vs filter/sort signatures.' + mechanism: | + Aggregation functions have a DIFFERENT signature than filterFns and sortFns. They receive `(columnId: string, leafRows: Array, childRows: Array)`. `leafRows` = ALL descendant non-grouped rows (recursive flatten). `childRows` = immediate children of the current grouped row (which may themselves be grouped sub-aggregates at deeper levels). Built-ins use `childRows` (sum, min, max, extent) for nested aggregation reuse, but `leafRows` (mean, median, unique, uniqueCount, count) when the math requires raw values. + wrong_pattern: | + ```tsx + // BUG: wrong arg names - first arg is columnId, not row + aggregationFn: (rowA, rowB, columnId) => /* ... */ + // BUG: averaging via childRows includes already-aggregated sub-group sums + aggregationFn: (id, leaf, child) => child.reduce((a, r) => a + r.getValue(id), 0) / child.length + ``` + correct_pattern: | + ```tsx + // From packages/table-core/src/fns/aggregationFns.ts + // For pure leaf averages, use leafRows: + const aggregationFn_mean: AggregationFn = (columnId, leafRows) => { + let count = 0, sum = 0 + leafRows.forEach((row) => { + const value = row.getValue(columnId) + if (typeof value === 'number') { count++; sum += value } + }) + return count ? sum / count : undefined + } + + // For nestable sums (reuse sub-aggregates), use childRows: + const aggregationFn_sum: AggregationFn = (columnId, _leafRows, childRows) => { + return childRows.reduce((acc, next) => { + const v = next.getValue(columnId) + return acc + (typeof v === 'number' ? v : 0) + }, 0) + } + ``` + source: packages/table-core/src/fns/aggregationFns.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - grouping + - mistake: + "Expecting grouped columns to appear in their original position — and missing the default + `groupedColumnMode: 'reorder'`." + mechanism: | + `columnGroupingFeature.getDefaultTableOptions` sets `groupedColumnMode: 'reorder'`. When grouping is active, the column ordering function in `columnOrderingFeature.utils.ts::orderColumns` moves grouped columns to the START of the leaf-column list. Agents who manually set `columnOrder` and group by 'status' will see 'status' jump to position 0 with no warning. Set `groupedColumnMode: false` to disable this, or `'remove'` to drop grouped columns from the visible flow entirely. + wrong_pattern: | + ```tsx + // BUG: column order is overridden by reorder mode + const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, data, + initialState: { + columnOrder: ['firstName', 'lastName', 'age', 'status'], // explicit + grouping: ['status'], + }, + // status jumps to position 0, columnOrder is "overridden" + }) + ``` + correct_pattern: | + ```tsx + const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, data, + initialState: { + columnOrder: ['firstName', 'lastName', 'age', 'status'], + grouping: ['status'], + }, + groupedColumnMode: false, // keep columnOrder intact + }) + + // Or accept the reorder and let grouped columns lead: + groupedColumnMode: 'reorder', // (default) + + // Or hide grouped columns entirely: + groupedColumnMode: 'remove', + ``` + source: packages/table-core/src/features/column-ordering/columnOrderingFeature.utils.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - grouping + - column-ordering + - mistake: Calling `getSelectedRowModel()` on a grouped table and expecting it to include grouped rows. + mechanism: | + Three selected-row APIs exist: `table.getSelectedRowModel()` (built off core), `table.getFilteredSelectedRowModel()` (built off filtered), `table.getGroupedSelectedRowModel()` (built off grouped). They are distinct in the type system. Agents commonly want "selected rows that are currently visible after grouping" and call the wrong one — `getSelectedRowModel()` walks the core (flat) row model and won't reflect grouping changes. + wrong_pattern: | + ```tsx + // BUG: returns selection from core model, not the grouped projection + const selectedRows = table.getSelectedRowModel().rows + // Doesn't reflect grouping — leaf rows only + ``` + correct_pattern: | + ```tsx + // Pick the right model for the question: + table.getSelectedRowModel() // selection from raw data + table.getFilteredSelectedRowModel() // selection within current filters + table.getGroupedSelectedRowModel() // selection within current groups + ``` + source: packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + priority: low + status: warning + version_context: v9.0.0-alpha.47 + skills: + - grouping + - row-selection + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + reference_candidates: + - name: built-in aggregationFns + kind: function-registry + count: 8 + actual_count: 9 + source: packages/table-core/src/fns/aggregationFns.ts + entries: + - sum + - min + - max + - extent + - mean + - median + - unique + - uniqueCount + - count + note: + Docs say 'There are several built-in aggregation functions' and list 9. Phase 1+2 note said + 8 — actual count is 9 (count, extent both included). + gaps: + - Phase 1+2 anchor says 'the built-in aggregationFn registry' but the registry has 9 (count + 8 numeric). + Verify with maintainer. + - "`createGroupedRowModel` constructs new Row objects via `constructRow` for grouped rows; these rows + have a special `getValue` override that intercepts to either return cached grouping values or call + the column's aggregationFn (lines 139-175). The interaction with column accessors (which normally + return per-row data) is subtle. Worth a reference note." + - "`manualGrouping: true` exists but docs say 'There are not currently many known easy ways to do server-side + grouping with TanStack Table.' Real-world server-side grouping support is a gap." + - slug: row-expanding + type: core + packages: + - '@tanstack/table-core' + domain: row-model-features + description: + Expand and collapse rows in TanStack Table v9 with the `expandedRowModel` stage — tree + sub-rows via `getSubRows`, detail panels via `getRowCanExpand`, filter-from-leaf-rows interaction, + expanded rows in pagination, and manual server-side expansion. + covers: + - rowExpandingFeature + - createExpandedRowModel() + - state.expanded (ExpandedState = true | Record) + - onExpandedChange + - 'getSubRows # (row) => row.children | row.subRows | undefined' + - 'getRowCanExpand # override for detail-panel pattern' + - getIsRowExpanded + - 'enableExpanding # column/row gate' + - manualExpanding + - 'autoResetExpanded # auto reset when row structure changes' + - 'paginateExpandedRows # default true; false = expand on parent page' + - 'filterFromLeafRows # tree filtering interaction' + - 'maxLeafRowFilterDepth # default 100, set 0 to filter root-only' + - table.setExpanded / resetExpanded / toggleAllRowsExpanded + - table.getIsAllRowsExpanded / getIsSomeRowsExpanded / getCanSomeRowsExpand / getExpandedDepth + - table.getToggleAllRowsExpandedHandler + - row.toggleExpanded / getIsExpanded / getCanExpand / getIsAllParentsExpanded + - row.getToggleExpandedHandler + - 'row.depth # for paddingLeft indentation in tree UIs' + - 'row.subRows # populated via getSubRows' + tasks: + - 'Render a tree table: provide nested `subRows` data, set `getSubRows: row => row.subRows`, render + `paddingLeft: ${row.depth * 2}rem` on the first cell (see examples/react/expanding/src/main.tsx).' + - 'Render detail panels (sub-components) for flat data: set `getRowCanExpand: () => true`, render a + second `` with a `colSpan` cell when `row.getIsExpanded()` (see examples/react/sub-components/src/main.tsx).' + - 'Toggle ALL rows at once with a header button: `table.getToggleAllRowsExpandedHandler()`.' + - "Keep expanded children on their parent's page (so the page count doesn't expand): `paginateExpandedRows: + false`." + - 'Filter tree data and keep matching descendants visible under non-matching parents: `filterFromLeafRows: + true`.' + failure_modes: + - mistake: + 'Using `getRowCanExpand: () => true` with tree data and a custom expand button — but `row.getCanExpand()` + already auto-detects `subRows.length`, so the override may collide.' + mechanism: | + `row_getCanExpand` (rowExpandingFeature.utils.ts:319-327) returns `options.getRowCanExpand?.(row) ?? (enableExpanding ?? true) && !!row.subRows.length`. When `getRowCanExpand` is set, it WINS over the auto subRows check. If a developer wants both tree behavior AND detail panels (e.g., leaf rows expand to show extra info), they need `getRowCanExpand: row => true` AND `getSubRows: row => row.subRows`. But then EVERY row (including leaves with no subRows) will show an expand icon — the developer must check `row.subRows.length` themselves in the cell render. + wrong_pattern: | + ```tsx + // BUG: every row gets an expander icon, including leaves + const table = useTable({ + getRowCanExpand: () => true, + getSubRows: r => r.subRows, + // ... + }) + // In cell: row.getCanExpand() always true → leaf rows show 👉 with nothing to expand + ``` + correct_pattern: | + ```tsx + // For pure tree data, omit getRowCanExpand and let it auto-detect: + // From examples/react/expanding/src/main.tsx + const table = useTable({ + _features, + _rowModels: { expandedRowModel: createExpandedRowModel(), /* ... */ }, + columns, data, + getSubRows: row => row.subRows, + // no getRowCanExpand — row.getCanExpand() is true only when subRows.length > 0 + }) + + // For pure detail panels, override and skip getSubRows: + // From examples/react/sub-components/src/main.tsx + const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, data, + getRowCanExpand: (row) => true, // every row expands to show + }) + ``` + source: packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts + priority: high + status: active + version_context: v9.0.0-alpha.47 + skills: + - row-expanding + - mistake: + "Setting `paginateExpandedRows: false` and expecting the page size to stay 10 (it doesn't + — more rows render)." + mechanism: | + `paginateExpandedRows: true` (default) lets expanded children flow naturally into pagination — each child counts toward `pageSize`. `paginateExpandedRows: false` keeps expanded children stuck under their parent on whichever page the parent appears — meaning a page of 10 parents with 5 children each renders 60 rows. From the docs guide: "This also means more rows will be rendered than the set page size." `createPaginatedRowModel` calls `expandRows({ ... })` to inflate the page slice when `paginateExpandedRows: false` (see createPaginatedRowModel.ts:57-69). + wrong_pattern: | + ```tsx + // BUG: page renders 50 rows even though pageSize is 10 + const table = useTable({ + paginateExpandedRows: false, + initialState: { pagination: { pageSize: 10 } }, + // user expands all parents → 10 parents * 5 children = 60 visible rows + }) + ``` + correct_pattern: | + ```tsx + // From docs/guide/expanding.md + // Default behavior (children flow through pagination): + const table = useTable({ + _features, + _rowModels: { expandedRowModel: createExpandedRowModel(), paginatedRowModel: createPaginatedRowModel() }, + columns, data, + getSubRows: r => r.subRows, + // paginateExpandedRows defaults to true + }) + + // OR explicitly keep children with parent: + paginateExpandedRows: false, // accept that pageSize is a soft cap + ``` + source: packages/table-core/src/features/row-pagination/createPaginatedRowModel.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - row-expanding + - pagination + - mistake: Storing `expanded` as `true` then writing into it as if it were a Record. + mechanism: | + `ExpandedState = true | Record`. The `true` literal means "all rows expanded" — there's no need to enumerate row IDs. `row_toggleExpanded` (rowExpandingFeature.utils.ts:250-283) handles the transition: if `old === true`, it first MATERIALIZES the `true` into a real Record of all row IDs in the current row model, then applies the per-row toggle. Agents writing `setExpanded((old) => ({ ...old, [id]: true }))` against `old === true` get `{ 0: true, 1: true, ..., [id]: true }` which is a different state than they expected. + wrong_pattern: | + ```tsx + // BUG: spreading `true` (a boolean) into an object gives {} - all rows collapse except [id] + setExpanded((old) => ({ ...old, [row.id]: true })) + // When old === true, this becomes { [row.id]: true } -- everything else collapses! + ``` + correct_pattern: | + ```tsx + // Use row.toggleExpanded() / row.getToggleExpandedHandler() — they materialize properly: + // From examples/react/expanding/src/main.tsx + + + // Or handle the materialization yourself if you must: + table.setExpanded((old) => { + if (old === true) { + const map: Record = {} + Object.keys(table.getRowModel().rowsById).forEach(id => { map[id] = true }) + return { ...map, [row.id]: !map[row.id] } + } + return { ...old, [row.id]: !old[row.id] } + }) + ``` + source: packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - row-expanding + - mistake: + 'Setting `manualExpanding: true` and still passing `expandedRowModel: createExpandedRowModel()` + AND expecting client-side flattening.' + mechanism: | + With `manualExpanding: true`, `table_getExpandedRowModel` skips the registered `expandedRowModel` factory and returns `getPreExpandedRowModel()` (sorted rows). The expanded state still tracks which rows are "expanded" in terms of UI affordance, but the row model is NOT inflated. To render manually-expanded children, you must include them in the source `data` and `getSubRows` — at which point you can just NOT use manualExpanding. + wrong_pattern: | + ```tsx + // BUG: manualExpanding bypasses the expanded row model; + // sub-rows are never flattened into view. + const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, // ignored + columns, data, + getSubRows: r => r.subRows, + manualExpanding: true, + }) + ``` + correct_pattern: | + ```tsx + // Manual expanding is for server-side patterns where the server + // returns a pre-flattened view based on which rows are expanded. + // From docs/guide/expanding.md + const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: {}, // no expandedRowModel for manual mode + columns, + data: dataQuery.data, // server returns flattened rows when expanded + manualExpanding: true, + state: { expanded }, + onExpandedChange: setExpanded, + }) + + // For client-side tree, omit manualExpanding: + const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, data, + getSubRows: r => r.subRows, + }) + ``` + source: packages/table-core/src/core/row-models/coreRowModelsFeature.utils.ts + priority: medium + status: active + version_context: v9.0.0-alpha.47 + skills: + - row-expanding + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + reference_candidates: [] + gaps: + - "`row.subRows` is initially populated by `getSubRows`; for grouped rows it's overwritten by `createGroupedRowModel`. + The interaction between user-provided `getSubRows` and grouping-generated subRows is subtle — what + happens if a user has tree data AND groups it?" + - "`autoResetExpanded` exists but isn't documented in the expanding guide. The implementation schedules + a reset (`table._reactivity.schedule`) when grouping changes (it's called from `createGroupedRowModel.onAfterUpdate`). + Worth a note on reset interactions." + - "`row.getIsAllParentsExpanded()` exists but isn't used in any example file I read. Its main use case + is in conditional rendering of deeply nested rows." + - slug: column-layout + type: core + domain: ui-state-features + packages: + - '@tanstack/table-core' + description: | + The five UI-state-only column features that shape how columns render: visibility (show/hide), ordering (manual sequence), pinning (left/right regions), sizing (px widths with min/max), and resizing (drag handle that commits to columnSizing). All are opt-in via `tableFeatures({ ... })`, share the consumer's state-management story (`initialState` vs hoisted `state` + `on*Change`), and combine through a fixed reorder pipeline: Column Pinning -> Manual Column Ordering -> Grouping. None of these features require a row model — they only affect layout. + covers: + - columnVisibilityFeature (state, getCanHide, getIsVisible, getToggleVisibilityHandler, table.getVisibleLeafColumns, + row.getVisibleCells, getIsAllColumnsVisible) + - columnOrderingFeature (columnOrder state, column.getIndex, getIsFirstColumn, getIsLastColumn, setColumnOrder, + the Pinning/Ordering/Grouping reorder pipeline) + - columnPinningFeature (left/right ColumnPinningState, column.pin, column.getIsPinned, column.getStart, + column.getAfter, column.getIsLastColumn/'left'|'right', split-table APIs getLeftHeaderGroups/getCenterHeaderGroups/getRightHeaderGroups, + row.getLeftVisibleCells/getCenterVisibleCells/getRightVisibleCells) + - 'columnSizingFeature (defaultColumnSizing {size: 150, minSize: 20, maxSize: MAX_SAFE_INTEGER}, columnDef.size/minSize/maxSize, + column.getSize, header.getSize, table.getTotalSize, table.getCenterTotalSize/getLeftTotalSize/getRightTotalSize)' + - columnResizingFeature (columnResizeMode 'onEnd' vs 'onChange', columnResizeDirection 'ltr'|'rtl', + header.getResizeHandler with onMouseDown + onTouchStart, column.getIsResizing, columnResizing.deltaOffset/isResizingColumn + for resize-indicator UI, CSS-variable + memoized-tbody perf pattern) + - Stable `columns` reference requirement (React FAQ pitfall) when these features are driving re-renders + tasks: + - Add a column visibility toggle panel that hides/shows columns without breaking header or cell render + (`getVisibleLeafColumns`, `row.getVisibleCells`). + - Pin specific columns left or right (e.g. a select column on the left, an actions column on the right) + via `initialState.columnPinning` or an interactive pin button using `column.pin('left' | 'right' | + false)`. + - Wire up drag-and-drop column reordering with `@dnd-kit/core` and `setColumnOrder` (the canonical 2026 + stack — react-dnd is incompatible with React 18+ Strict Mode). + - Build draggable column resize handles that stay 60fps with large tables (CSS variables + memoized + TableBody during resize, per the `column-resizing-performant` example). + - "Render a sticky-CSS pinning layout (one ``, pinned columns positioned with `column.getStart('left')` + / `getAfter('right')` and `position: sticky`)." + subsystems: + - name: visibility + description: | + `columnVisibility: Record` where missing or `true` means visible. Driven by `enableHiding` (table-level) and `columnDef.enableHiding` (column-level), both defaulting to `true`. Rendering MUST go through `getVisibleLeafColumns` / `row.getVisibleCells` — the `getAll*` variants do not respect this state. + key_apis: + - table.getVisibleLeafColumns() / row.getVisibleCells() + - column.getIsVisible / getCanHide / getToggleVisibilityHandler + - table.getIsAllColumnsVisible / getToggleAllColumnsVisibilityHandler + - name: ordering + description: | + `columnOrder: string[]` of leaf column ids. Empty array means definition order. Ordering is **scoped to unpinned (center) columns** when pinning is also active — pinned columns are sequenced inside `columnPinning.left/right` arrays, not `columnOrder`. The reorder pipeline executes in this fixed order: (1) Column Pinning splits into left/center/right, (2) `columnOrder` reorders the center, then (3) `groupedColumnMode: 'reorder' | 'remove'` may move grouped columns to the front. + key_apis: + - table.setColumnOrder / onColumnOrderChange + - column.getIndex(position?) / getIsFirstColumn / getIsLastColumn + - name: pinning + description: | + `columnPinning: { left: string[]; right: string[] }`. Pinning a group column pins every leaf. Two render strategies are supported: (1) **split tables** using `getLeft*/getCenter*/getRight*` header, column, and cell APIs (`column-pinning-split` example), and (2) **single table + sticky CSS** using `column.getStart('left')` / `column.getAfter('right')` + `position: sticky` plus `getIsLastColumn('left')` / `getIsFirstColumn('right')` for the shadow-edge indicator (`column-pinning-sticky` example). IMPORTANT: in v9 the table-level option is `enableColumnPinning`, not the v8 `enablePinning` — that name is now exclusively the per-column `columnDef.enablePinning`. + key_apis: + - column.pin('left' | 'right' | false) / column.getIsPinned / column.getCanPin + - column.getStart(position) / column.getAfter(position) — sticky CSS offsets + - table.getLeftHeaderGroups / getCenterHeaderGroups / getRightHeaderGroups + - row.getLeftVisibleCells / getCenterVisibleCells / getRightVisibleCells + - name: sizing + description: | + `columnSizing: Record` (pixels). Static defaults live in `defaultColumnSizing` (size 150, minSize 20, maxSize Number.MAX_SAFE_INTEGER) and can be overridden globally via `tableOptions.defaultColumn` or per-column via `columnDef.size/minSize/maxSize`. `column.getSize()` clamps the committed state value (or column-def fallback) between min and max. Three layout styles work: semantic `
` with `width`, block divs with strict widths, and absolutely positioned cells using `getStart()` (no resize mode tied to layout — this is purely consumer choice). + key_apis: + - column.getSize() / header.getSize() — size lookup + - table.getTotalSize / getCenterTotalSize / getLeftTotalSize / getRightTotalSize + - column.resetSize() — drop committed override, fall back to column def + - tableOptions.defaultColumn for cross-column defaults + - name: resizing + description: | + Layered on top of sizing. Two modes via `columnResizeMode`: `'onEnd'` (default — commits the new size to `columnSizing` only when the drag releases; safer for large React tables) and `'onChange'` (commits live during drag; needs the CSS variable + memoized TableBody pattern). `header.getResizeHandler()` returns a single function wired to BOTH `onMouseDown` and `onTouchStart` for mobile support. The transient `columnResizing` state carries `isResizingColumn` and `deltaOffset` — the latter drives the resize-indicator translation in `onEnd` mode. `columnResizeDirection: 'rtl'` inverts the delta sign. + key_apis: + - header.getResizeHandler() — single handler for mouse + touch + - column.getIsResizing / column.getCanResize + - table.atoms.columnResizing.get() — deltaOffset, isResizingColumn + - "columnResizeMode: 'onEnd' (default) | 'onChange'" + - "columnResizeDirection: 'ltr' (default) | 'rtl'" + - column.resetSize() (double-click handle) — provided by sizing subsystem + failure_modes: + - mistake: | + Rendering header or body cells with `table.getAllLeafColumns()` and `row.getAllCells()` while `columnVisibilityFeature` is registered. Hidden columns still render. + mechanism: | + The `getAll*` accessors return every column/cell unconditionally — they do not consult `columnVisibility` state. Only the `Visible` variants (and the header-group APIs) filter by visibility. Users who copy a v8-era body loop or a non-visibility example into a visibility-enabled table will see no effect on the rendered table regardless of how they toggle the checkbox panel. + wrong_pattern: | + // ❌ Toggling visibility has no effect on rendered cells + {table.getAllLeafColumns().map(column => ( + + ))} + {row.getAllCells().map(cell => ( + + ))} + correct_pattern: | + // ✅ From examples/react/column-visibility/src/main.tsx and the guide: + // Header groups already respect visibility; use them for headers. + // For body cells, swap getAllCells -> getVisibleCells. + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( // <-- the fix + + ))} + + ))} + + source: + - docs/guide/column-visibility.md (Column Visibility Aware Table APIs) + - examples/react/column-visibility/src/main.tsx + priority: high + status: active + version_context: | + v9 names unchanged from v8 (`getVisibleLeafColumns`, `getVisibleCells`). Still the #1 column-visibility footgun. + skills: + - column-layout + - mistake: | + Setting `columnResizeMode: 'onChange'` in a React table with many rows or expensive cell renders, then complaining about jittery / sub-30fps resize. Users typically call `column.getSize()` inside every ` + correct_pattern: | + // ✅ From examples/react/column-resizing-performant/src/main.tsx + // 1. Compute every column width once, keyed on resize state + const columnSizeVars = React.useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: number } = {} + for (const header of headers) { + colSizes[`--header-${header.id}-size`] = header.getSize() + colSizes[`--col-${header.column.id}-size`] = header.column.getSize() + } + return colSizes + }, [table.state.columnResizing, table.state.columnSizing]) + + // 2. Spread widths onto the table element as CSS variables +
+
+ {/* header cells read width from var(--header-${id}-size) */} +
+ + {/* 3. Swap to memoized body only while a drag is in progress */} + {table.store.state.columnResizing.isResizingColumn && enableMemo + ? + : } +
+ + // Body cells use the CSS variable (no per-cell getSize() call) +
+ {cell.renderValue()} +
+ + export const MemoizedTableBody = React.memo( + TableBody, + (prev, next) => prev.table.options.data === next.table.options.data, + ) + source: + - docs/guide/column-resizing.md (Advanced Column Resizing Performance) + - examples/react/column-resizing-performant/src/main.tsx + priority: high + status: active + version_context: | + v9 surface unchanged from v8 for resize APIs. The performant pattern is now the canonical reference — Svelte/Vue/Solid adapters generally don't need it, but React does. + skills: + - column-layout + - mistake: | + Trying to control the order of pinned columns by mutating `columnOrder` state. The pinned region keeps rendering in pin-insertion order regardless of what `columnOrder` says. + mechanism: | + The fixed reorder pipeline is (1) Column Pinning split, (2) `columnOrder` applied, (3) Grouping. After step (1) the pinned columns are read **directly from `state.columnPinning.left` and `.right`** by `table_getLeftHeaderGroups` / `_getRightHeaderGroups`. `columnOrder` is only consulted by the unpinned center partition. To reorder a pinned column you must edit the array inside `columnPinning` itself (or unpin -> reorder -> repin). + wrong_pattern: | + // ❌ Won't move 'actions' relative to 'firstName' while it's pinned right + const [columnPinning] = useState({ + left: ['select'], + right: ['actions'], + }) + table.setColumnOrder(['actions', 'select', 'firstName', 'lastName']) + correct_pattern: | + // ✅ Reorder the pinning state itself + table.setColumnPinning(old => ({ + left: ['select'], + right: ['summary', 'actions'], // move 'summary' to render before 'actions' + })) + + // Or for the unpinned center region, columnOrder works normally: + table.setColumnOrder(['firstName', 'lastName']) // only affects center columns + source: + - "docs/guide/column-ordering.md ('Note: columnOrder state will only affect unpinned columns')" + - docs/guide/column-pinning.md (How Column Pinning Affects Column Order) + - packages/table-core/src/features/column-pinning/columnPinningFeature.utils.ts (table_getLeftHeaderGroups + reads from columnPinning.left directly) + priority: medium + status: active + version_context: | + Behavior unchanged from v8. The 3-stage pipeline is documented identically in both column-ordering.md and column-pinning.md in v9. + skills: + - column-layout + - mistake: | + Using the v8 table option `enablePinning` to disable column pinning in v9 — option is ignored, columns remain pinnable. + mechanism: | + In v9 the umbrella `enablePinning` option was split into two distinct options: `enableColumnPinning` (table level) and `enableRowPinning` (table level). The string `enablePinning` is still valid but now refers ONLY to the per-column `columnDef.enablePinning` opt-out. Any agent or human ported from v8 that writes `enablePinning: false` at the table level will see no effect — `column_getCanPin` reads `column.table.options.enableColumnPinning ?? true`. + wrong_pattern: | + // ❌ v8 syntax — no longer disables pinning in v9 + const table = useTable({ + _features: tableFeatures({ columnPinningFeature }), + enablePinning: false, // ignored at table level in v9 + //... + }) + correct_pattern: | + // ✅ v9: split into two table-level options + const table = useTable({ + _features: tableFeatures({ columnPinningFeature, rowPinningFeature }), + enableColumnPinning: false, // turns off column pinning + enableRowPinning: false, // turns off row pinning (or row => row.original.locked) + //... + }) + + // Per-column opt-out is still spelled `enablePinning`: + columnHelper.accessor('id', { + enablePinning: false, // this column can't be pinned + }) + source: + - packages/table-core/src/features/column-pinning/columnPinningFeature.types.ts (TableOptions_ColumnPinning.enableColumnPinning) + - packages/table-core/src/features/column-pinning/columnPinningFeature.utils.ts (column_getCanPin + reads enableColumnPinning) + - packages/table-core/src/features/row-pinning/rowPinningFeature.types.ts (TableOptions_RowPinning.enableRowPinning) + priority: high + status: active + version_context: | + v9 breaking change vs v8. Agents trained on the v8 surface will consistently produce `enablePinning` at the table level. + skills: + - column-layout + - row-pinning + - mistake: | + Using `react-dnd` or `react-beautiful-dnd` for column reordering in a React 18+/Strict Mode app, or trying to wrap dnd-kit's `DndContext` directly inside a `
...
+ {header.isPlaceholder ? null : ( + + )} +
` and re-render the whole body on every move event. + mechanism: | + In `'onChange'` mode, the resize handler commits a new `columnSizing` map on every pointer move. Each commit re-renders the entire body. With `column.getSize()` evaluated on every cell, you re-run an O(columns × rows) traversal per move event — far beyond a 16ms frame budget. The performant pattern (a) caches column widths once in a `useMemo` keyed on `columnResizing` + `columnSizing`, (b) passes those widths down as CSS variables on the table root so cells don't read from JS, and (c) wraps the TableBody in `React.memo` and uses the memoized version only while `state.columnResizing.isResizingColumn` is truthy. The guide also recommends `'onEnd'` as a sane default when this perf budget can't be met. + wrong_pattern: | + // ❌ Live resize + getSize() on every cell + un-memoized body + const table = useTable({ + _features: tableFeatures({ columnSizingFeature, columnResizingFeature }), + columnResizeMode: 'onChange', + //... + }) + // ... + + {cell.renderValue()} +
` element. + mechanism: | + `react-dnd` has documented incompatibilities with React 18 Strict Mode (its Provider creates effects that re-fire unpredictably) and is no longer actively maintained against the latest React internals. `react-beautiful-dnd` is in maintenance mode and has known issues with `
` markup. The official TanStack examples for column-dnd and row-dnd both use `@dnd-kit/core` + `@dnd-kit/sortable` because dnd-kit's provider renders `
` wrappers — which violates HTML if nested inside `
` / ``. The `DndContext` MUST wrap the entire table from outside. + wrong_pattern: | + // ❌ react-dnd in React 18 Strict Mode — flicker and stale drags + import { DndProvider } from 'react-dnd' + import { HTML5Backend } from 'react-dnd-html5-backend' + // ... + + // ❌ Nesting DndContext inside
+
+ + ... + +
+ correct_pattern: | + // ✅ From examples/react/column-dnd/src/main.tsx + import { + DndContext, KeyboardSensor, MouseSensor, TouchSensor, + closestCenter, useSensor, useSensors, + } from '@dnd-kit/core' + import { restrictToHorizontalAxis } from '@dnd-kit/modifiers' + import { + SortableContext, arrayMove, + horizontalListSortingStrategy, useSortable, + } from '@dnd-kit/sortable' + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (over && active.id !== over.id) { + table.setColumnOrder(prev => { + const oldIndex = prev.indexOf(active.id as string) + const newIndex = prev.indexOf(over.id as string) + return arrayMove(prev, oldIndex, newIndex) + }) + } + } + + // NOTE: DndContext renders divs, so it MUST wrap from outside + +
+ + {table.getHeaderGroups().map(hg => ( + + + {hg.headers.map(h => )} + + + ))} + + {/* ... */} +
+ + source: + - docs/guide/column-ordering.md (Drag and Drop Column Reordering Suggestions) + - "examples/react/column-dnd/src/main.tsx (the comment `// NOTE: This provider creates div elements, + so don't nest inside of elements`)" + priority: medium + status: active + version_context: | + The recommendation has been stable since v8.10. dnd-kit is the recommended stack in v9 docs. + skills: + - column-layout + - mistake: | + Forgetting to wire `header.getResizeHandler()` to BOTH `onMouseDown` AND `onTouchStart` — desktop drag resize works, mobile users tap-and-drag but the column never resizes. + mechanism: | + `header_getResizeHandler` returns a single function that internally branches on `isTouchStartEvent(event)`. The same function is meant to be installed on both DOM event types so the handler picks the correct branch. If a developer only wires `onMouseDown` (the common copy-paste from a desktop-only example), touch events never fire the handler at all — touch users can grab the resizer visually but no `mousedown` event is synthesized for a long drag on iOS Safari/Android Chrome. The handler installs `touchmove` / `touchend` listeners on `document` only after it receives the initial `touchstart`. + wrong_pattern: | + // ❌ Desktop only — mobile users can't resize +
header.column.resetSize()} + className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`} + /> + correct_pattern: | + // ✅ From every column-resizing example: wire BOTH events +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`} + /> + source: + - docs/guide/column-resizing.md (Column Resize APIs section) + - examples/react/column-resizing/src/main.tsx + - examples/react/column-pinning-sticky/src/main.tsx + - packages/table-core/src/features/column-resizing/columnResizingFeature.utils.ts (header_getResizeHandler + branches on isTouchStartEvent) + priority: medium + status: active + version_context: | + v9 surface unchanged. + skills: + - column-layout + - mistake: | + Defining `columns` (or the column-helper array) inline in the render body instead of stabilizing with `useMemo`/`useState`/module scope. Triggers an infinite render loop once any of these layout features re-render on state change. + mechanism: | + TanStack Table treats `columns` (and `data` and `state`) as inputs that, when their reference changes, signal that the underlying table model must be rebuilt. With visibility/pinning/ ordering/sizing/resizing registered, every interaction commits a state slice; React re-runs the component; an inline `columns` literal gets a new identity; the table rebuilds; that triggers another render. The `column-pinning-split` and `column-dnd` examples both use `useMemo(() => columnHelper.columns([...]), [])` or define `defaultColumns` outside the component for exactly this reason. The FAQ flags this as Pitfall 1. + wrong_pattern: | + // ❌ Infinite loop once column visibility / pinning / resizing fires + function App() { + const columns = [ + columnHelper.accessor('firstName', { ... }), + columnHelper.accessor('lastName', { ... }), + ] + const table = useTable({ + _features: tableFeatures({ columnPinningFeature, columnResizingFeature }), + columns, + data, + }) + } + correct_pattern: | + // ✅ Stable reference via module scope (column-pinning-split pattern) + const defaultColumns = columnHelper.columns([ + columnHelper.accessor('firstName', { ... }), + columnHelper.accessor('lastName', { ... }), + ]) + function App() { + const [columns] = React.useState(() => [...defaultColumns]) + //... + } + + // ✅ Or useMemo (column-dnd pattern) + function App() { + const columns = React.useMemo( + () => columnHelper.columns([ + columnHelper.accessor('firstName', { ... }), + columnHelper.accessor('lastName', { ... }), + ]), + [], + ) + //... + } + source: + - 'docs/faq.md (Pitfall 1: Creating new columns or data on every render)' + - examples/react/column-pinning-split/src/main.tsx (defaultColumns + useState) + - examples/react/column-dnd/src/main.tsx (useMemo) + priority: high + status: active + version_context: | + React-specific. Same FAQ entry applies across all of cluster C — listed here because layout features are usually where users first hit it. + skills: + - column-layout + - row-selection + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + gaps: + - | + Is `column-layout` too broad as a single skill? Five subsystems with distinct config surfaces but a common pattern. Alternative: split into `column-visibility-ordering` (state-only) + `column-pinning` + `column-sizing-resizing` (interaction). Kevin approved combining — keep unless Phase 4 reveals retrieval issues. + - | + No Angular-specific failure mode for column resizing perf. The `angular/column-resizing-performant` example exists but I didn't deep-read it; if signals make the React pattern unnecessary, a clarifying note may belong on the resize subsystem. + - | + The `Table_ColumnResizing.setcolumnResizing` API name has a deliberately lowercase `c` ("matches the current generated v9 table API for the `columnResizing` state slice" per the JSDoc). This is an oddity that might warrant a typo-tolerance failure mode if it's stable enough to ship. + name: Column Layout Features + - slug: row-pinning + type: core + domain: ui-state-features + packages: + - '@tanstack/table-core' + description: | + Pin specific rows to a top region or bottom region that stays visible across pagination and filtering. State shape is `{ top: string[]; bottom: string[] }` keyed by `row.id`. Simpler pipeline than column pinning — only one reorder step happens after row pinning (sorting) and pinned rows are sorted within their region. + covers: + - rowPinningFeature (RowPinningState, row.pin, row.getIsPinned, row.getPinnedIndex, row.getCanPin, table.getTopRows, + table.getBottomRows, table.getCenterRows, table.getIsSomeRowsPinned) + - enableRowPinning option (bool or row predicate) + - keepPinnedRows option (default true — pinned rows persist across pagination/filtering even if they'd + otherwise be filtered out; false hides them when filtered/paginated away) + - row.pin(position, includeLeafRows?, includeParentRows?) signature — the extra args matter for grouped/expanded + data + - The reorder pipeline: Row Pinning -> Sorting + tasks: + - Add pin-top / pin-bottom buttons to a row that persist the pinned rows even when the user paginates + or filters the data. + - Render pinned rows in a sticky `
` with calculated `top`/`bottom` offsets based on `row.getPinnedIndex()` + (see the example pattern). + - Choose between (a) splitting pinned vs center rows in render (`getTopRows + getCenterRows + getBottomRows`) + versus (b) leaving pinned rows duplicated in the main flow (the `copyPinnedRows` toggle in the example). + failure_modes: + - mistake: | + Setting `getRowId: (row) => row.id` (or omitting it), pinning a row, then refetching/reordering the data — the wrong row stays pinned because `row.id` defaulted to `row.index` and indices shifted. + mechanism: | + `row.id` defaults to the row's positional `index` in the data array. `rowPinning.top` and `rowPinning.bottom` are arrays of string row ids. After a refetch that reorders the data, index 3 might now be a different record entirely, but the pinning state still pins index 3. This is the same root cause as the row selection ID pitfall — fix by supplying a stable `getRowId: (row) => row.uuid` (or any unique primary key from the domain data). The row-pinning example deliberately uses `makeData(1_000, 2, 2)` with `subRows` and demonstrates the `includeLeafRows`/`includeParentRows` args of `row.pin` to handle grouped data. + wrong_pattern: | + // ❌ row.id defaults to row.index; pin survives wrong rows after refetch + const table = useTable({ + _features: tableFeatures({ rowPinningFeature, rowPaginationFeature }), + data, // refetched periodically + //... + }) + correct_pattern: | + // ✅ Stable row identity + const table = useTable({ + _features: tableFeatures({ rowPinningFeature, rowPaginationFeature }), + data, + getRowId: row => row.userId, // or row.uuid, row.id from API, etc. + //... + }) + + // For grouped/expanded data, pass the include flags too: + row.pin('top', includeLeafRows, includeParentRows) + source: + - docs/guide/row-selection.md (Useful Row Ids — same principle) + - examples/react/row-pinning/src/main.tsx (uses makeData with subRows, demonstrates include flags) + priority: high + status: active + version_context: | + v9 surface unchanged. + skills: + - row-pinning + - row-selection + - mistake: | + Leaving `keepPinnedRows: true` (the default) and being surprised that pinned rows appear on every page during pagination — or conversely, setting it to `false` and being surprised that pinned rows disappear when filtered out. + mechanism: | + `keepPinnedRows` controls whether `table.getTopRows()` / `table.getBottomRows()` use `table.getRow(id, true)` (which searches the full pre-pagination row set) or `visibleRows.find(...)` (which only finds rows currently in the row model). Default is `true`, so pinned rows stick across pagination and filtering and may render on every page. With `false`, pinned rows can disappear if their underlying row is filtered or paginated out. The row-pinning example exposes both via the "Keep/Persist Pinned Rows" checkbox to make the contrast visible. The behavior is deliberate — pick the one that matches the UX you want. + wrong_pattern: | + // ❌ Expecting pinned rows to vanish on filter, but they don't (default) + const table = useTable({ + _features: tableFeatures({ rowPinningFeature, columnFilteringFeature }), + //... + // keepPinnedRows defaults to true; pinned rows survive filtering + }) + correct_pattern: | + // ✅ Be explicit about which UX you want + const table = useTable({ + _features: tableFeatures({ rowPinningFeature, columnFilteringFeature }), + keepPinnedRows: false, // pinned rows disappear when filtered/paginated out + //... + }) + + // Or to keep the default but render pinned rows separately from the body: + + {table.getTopRows().map(row => )} + {table.getCenterRows().map(row => )} + {table.getBottomRows().map(row => )} + + source: + - docs/guide/row-pinning.md + - 'packages/table-core/src/features/row-pinning/rowPinningFeature.utils.ts (table_getPinnedRows: const + keepPinnedRows = table.options.keepPinnedRows ?? true)' + - examples/react/row-pinning/src/main.tsx (keepPinnedRows toggle) + priority: medium + status: active + version_context: | + v9 default matches v8 (`true`). Same option name. + skills: + - row-pinning + - mistake: | + Iterating `table.getRowModel().rows` for the main tbody AND rendering `getTopRows()`/`getBottomRows()` separately — pinned rows render twice (once at top/bottom, once where they originally appeared in the row model). + mechanism: | + `getRowModel()` returns the complete current row model with pinned rows still in it (pinning doesn't remove rows from the model, it just exposes a partition). The intended pattern is to render either (a) `getTopRows + getCenterRows + getBottomRows` (three loops, pinned rows appear once) or (b) `getTopRows + getRowModel + getBottomRows` only if you explicitly want pinned rows duplicated. The example toggles between these via `copyPinnedRows` to make the difference explicit. The default choice should be `getCenterRows`. + wrong_pattern: | + // ❌ Pinned rows render twice + + {table.getTopRows().map(row => )} + {table.getRowModel().rows.map(row => ( // <-- still includes pinned rows + ... + ))} + {table.getBottomRows().map(row => )} + + correct_pattern: | + // ✅ From examples/react/row-pinning/src/main.tsx + + {table.getTopRows().map(row => ( + + ))} + {table.getCenterRows().map(row => ( + + {row.getAllCells().map(cell => ( + + ))} + + ))} + {table.getBottomRows().map(row => ( + + ))} + + source: + - examples/react/row-pinning/src/main.tsx (the copyPinnedRows demo toggle) + priority: medium + status: active + version_context: | + v9 surface unchanged. + skills: + - row-pinning + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + gaps: + - | + Could fold into `column-layout` as a sixth subsystem given how small the dedicated guide is (the row-pinning.md guide is *2 sentences* long after the deprecated `pinning.md` redirect). Kept separate because Kevin approved it as its own bullet AND the failure modes are genuinely different from column-pinning (id-stability matters more, `keepPinnedRows` has no column-side analogue, render order differs). **Flag for Phase 4 review.** + - | + Sticky-CSS row pinning pattern (the `PinnedRow` component in the example with `position: sticky` + `top: row.getPinnedIndex() * 26 + 48px`) is non-trivial — magic numbers depend on row height + header height. Worth deciding if this becomes its own reference pattern or a callout in the existing failure mode. + - slug: row-selection + type: core + domain: ui-state-features + packages: + - '@tanstack/table-core' + description: | + Track which rows are selected via `rowSelection: Record` and derive selected-row models scoped to the current data (`getSelectedRowModel`), to filtered data (`getFilteredSelectedRowModel`), or to grouped data (`getGroupedSelectedRowModel`). Supports single-select via `enableMultiRowSelection: false`, conditional row-by-row enabling via predicates, and parent->child propagation via `enableSubRowSelection`. The row id used in the selection map defaults to `row.index` — supplying a stable `getRowId` is essentially mandatory for any real-world use. + covers: + - rowSelectionFeature (RowSelectionState, table.getSelectedRowModel, getFilteredSelectedRowModel, getGroupedSelectedRowModel, + getPreSelectedRowModel, getIsAllRowsSelected, getIsSomeRowsSelected, getIsAllPageRowsSelected, getIsSomePageRowsSelected, + toggleAllRowsSelected, toggleAllPageRowsSelected) + - row.toggleSelected, row.getIsSelected, row.getIsSomeSelected (indeterminate), row.getIsAllSubRowsSelected, + row.getCanSelect, row.getCanMultiSelect, row.getCanSelectSubRows, row.getToggleSelectedHandler + - enableRowSelection (bool or row predicate) — default true + - enableMultiRowSelection (bool or row predicate) — default true; setting to false makes it radio-style + (single-row at a time) + - enableSubRowSelection (bool or row predicate) — default true; controls whether parent row toggle propagates + to subRows + - The interaction with `getRowId`, `manualPagination`, atoms (via `useCreateAtom`), and the indeterminate-checkbox + UI pattern + tasks: + - Add a select-column with header "select all" + per-row checkbox wired through `getToggleAllRowsSelectedHandler` + / `getToggleSelectedHandler`, including the indeterminate (`getIsSomeRowsSelected`) state. + - Hoist selection state outside the table (via `useState` or an atom from `useCreateAtom`) so the selected + ids can be sent to an API call or used in other components. + - 'Build single-select / radio-style selection by setting `enableMultiRowSelection: false`.' + - 'Restrict selection conditionally (e.g. only adult records, non-archived rows) via `enableRowSelection: + row => row.original.age > 18`.' + failure_modes: + - mistake: | + Omitting `getRowId` and using `manualPagination` (server-side paging). The user pages forward, selects a row, pages back, and the wrong row appears selected — or selections evaporate on page navigation. + mechanism: | + With no `getRowId` provided, `row.id` defaults to the row's `index` within the data array. Under `manualPagination`, each page receives only that page's rows, so `data[0]` is always row.id "0", `data[1]` is always "1", etc. Selecting row 5 on page 1 and paging to page 2 sets `rowSelection["5"] = true`, but page 2's data also has a "5" — so a different record now appears selected. The fix is universal: supply `getRowId: row => row.someStablePrimaryKey`. The row-selection guide flags this as the "Useful Row Ids" section and a separate note covers the `manualPagination` interaction: `getSelectedRowModel` only returns rows in the current `data` array, so for cross-page totals you must read `rowSelection` state directly instead of relying on the row model. + wrong_pattern: | + // ❌ Server-side pagination + default row.id = row.index + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowPaginationFeature }), + data, // only the current page from the server + manualPagination: true, + rowCount, // server-provided total + //... + }) + // After paging, `rowSelection: { '5': true }` is ambiguous — + // which page's row 5? Selection appears to move with the user. + correct_pattern: | + // ✅ Stable row id keyed to the primary key the server uses + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowPaginationFeature }), + data, + manualPagination: true, + rowCount, + getRowId: row => row.uuid, // or row.id from your API + //... + }) + + // ✅ For "X of Y selected" with server-side pagination, read the + // rowSelection state directly — getSelectedRowModel only knows + // about rows in the current page's `data`: + const totalSelected = Object.keys(table.state.rowSelection).length + + // (The selection state can hold ids that aren't in `data` — + // that's by design.) + source: + - docs/guide/row-selection.md (Useful Row Ids section; manualPagination note above Access Row Selection + State) + - packages/table-core/src/features/row-selection/rowSelectionFeature.types.ts (RowSelectionState = + Record) + - 'examples/react/row-selection/src/main.tsx (uses getRowId: row => row.id)' + priority: high + status: active + version_context: | + v9 surface unchanged from v8. Still the #1 row-selection footgun, especially in any server-driven table. + skills: + - row-selection + - mistake: | + Setting `enableMultiRowSelection: false` to get radio-style single selection, but rendering checkboxes via `getToggleAllRowsSelectedHandler` and `getIsSomeRowsSelected` for an indeterminate header — leaves a non-functional "select all" UI that contradicts the single-select intent. + mechanism: | + When `enableMultiRowSelection` is false, the internal `mutateRowIsSelected` helper clears all other ids from the selection map before adding the new one — so "select all" is effectively no-op (it sets one row, deselects all others, then checks the next, in a fast loop that leaves only the last row selected). The header indeterminate state is meaningless because only zero or one rows can ever be selected. Use radio inputs instead, drop the toggle-all header, and treat the selection map as `{ [singleId]: true }`. + wrong_pattern: | + // ❌ Checkbox "select all" + single-select mode + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + enableMultiRowSelection: false, // radio-like + //... + }) + + // Header still renders a checkbox + indeterminate + + correct_pattern: | + // ✅ Radio inputs, no toggle-all header + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + enableMultiRowSelection: false, + getRowId: row => row.id, + //... + }) + + { + id: 'select', + header: '', // no "select all" makes sense in single-select mode + cell: ({ row }) => ( + + ), + } + + // Or conditional single-select for only the sub-rows of certain + // parents (the guide's predicate form): + enableMultiRowSelection: row => row.original.age > 18, + source: + - docs/guide/row-selection.md (Single Row Selection section) + - 'packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts (mutateRowIsSelected: + if (!row_getCanMultiSelect(row)) Object.keys(selectedRowIds).forEach(key => delete selectedRowIds[key]))' + priority: medium + status: active + version_context: | + v9 surface unchanged. + skills: + - row-selection + - mistake: | + Calling `table.getSelectedRowModel().flatRows` on a paginated server-side table to count or list "all selected rows" — gets only the selected rows visible on the current page, missing selections from previously visited pages. + mechanism: | + `getSelectedRowModel` is derived from `table.getCoreRowModel()` via `selectRowsFn`, which recurses the core row tree filtering by `isRowSelected`. Under `manualPagination`, the core row model only knows about the rows in the current `data` prop — previously-paged rows aren't materialized. The `rowSelection` state map, however, can hold ids that aren't in the current `data`. The guide says explicitly: "Row selection state, however, can contain row ids that are not present in the `data` array just fine." For server-side selection bookkeeping, read `state.rowSelection` directly; for a count UI, use `Object.keys(rowSelection).length` instead of `.getSelectedRowModel().rows.length`. + wrong_pattern: | + // ❌ Under manualPagination, only counts the visible page's selected rows + const selectedCount = table.getSelectedRowModel().flatRows.length + // ^ this stays small because flatRows is built from data, which + // only contains the current page. + + const handleBulkAction = () => { + const ids = table.getSelectedRowModel().flatRows + .map(row => row.original.id) + api.archive(ids) // missing all selections from other pages! + } + correct_pattern: | + // ✅ For counts and id lists under manualPagination, read state directly + const selectedCount = Object.keys(table.state.rowSelection).length + + const handleBulkAction = () => { + // rowSelection keys ARE the stable ids (you set getRowId: row => row.id) + const ids = Object.keys(table.state.rowSelection) + api.archive(ids) + } + + // (Client-side / no manualPagination: getSelectedRowModel is fine, + // because data contains every row.) + source: + - "docs/guide/row-selection.md ('Note: If you are using manualPagination ...')" + - packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts (selectRowsFn walks + the supplied rowModel, can only materialize rows present in core data) + - examples/react/row-selection/src/main.tsx (uses Object.keys(table.state.rowSelection).length for + the count UI) + priority: high + status: active + version_context: | + v9 surface unchanged from v8. This is the most common bug after shipping a working "select rows" feature to a server-paginated table. + skills: + - row-selection + - mistake: | + Toggling a parent row in a grouped/expanded table and being surprised that all subRows also flip — or conversely, disabling the propagation but having `getIsSomeSelected` return false when only some children are selected. + mechanism: | + `enableSubRowSelection` defaults to `true`. The internal `mutateRowIsSelected` recurses into `row.subRows` when this option is truthy, applying the same `value` to each. This enables the "select parent => select all children" UX. To opt out, set `enableSubRowSelection: false` (or pass a row predicate). The indeterminate state on a parent comes from `row.getIsSomeSelected()` which calls `isSubRowSelected(row)` and returns `'some'` when not all selectable descendants are selected — but this only looks at subRows, so if you've disabled propagation AND have separately selected mixed subRows, the parent will correctly read "some". The UX decision is yours; pick deliberately. + wrong_pattern: | + // ❌ Default behavior: clicking the parent selects all children + // (which is correct for most cases but surprising if you wanted + // parent-only selection for a "select this group as a whole" UX) + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowExpandingFeature }), + getSubRows: row => row.subRows, + // enableSubRowSelection unset — defaults to true + }) + correct_pattern: | + // ✅ Opt out of parent->child propagation explicitly + const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowExpandingFeature }), + getSubRows: row => row.subRows, + enableSubRowSelection: false, // toggling parent doesn't touch subRows + }) + + // ✅ Or selectively disable per row + enableSubRowSelection: row => row.depth > 0, // top-level rows still propagate + + // ✅ Drive an indeterminate parent checkbox from descendant state + + source: + - docs/guide/row-selection.md (Sub-Row Selection section) + - packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts (mutateRowIsSelected + recursion gated by row_getCanSelectSubRows) + - "packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts (isSubRowSelected: + returns 'all' | 'some' | false)" + priority: medium + status: active + version_context: | + v9 surface unchanged. + skills: + - row-selection + - mistake: Parent row checkbox state stuck on "unchecked" with all sub-rows selected + mechanism: | + `getIsAllSubRowsSelected` on a grouped parent only counts direct children, not + nested descendants. With multi-level grouping, the parent reports "all selected" + based on a partial count. Combined with `enableSubRowSelection: true`, + `row.toggleSelected(false)` on a parent shortcuts to a no-op because + `getIsSelected()` returns false even when all descendants are checked. + wrong_pattern: | + // ❌ Returns false even when all leaf descendants are selected + const isParentChecked = row.getIsAllSubRowsSelected() + correct_pattern: | + // ✅ Walk leaves directly + const allLeafs = row.getLeafRows() + const allSelected = allLeafs.length > 0 && allLeafs.every(r => r.getIsSelected()) + const someSelected = allLeafs.some(r => r.getIsSelected()) + source: + https://github.com/TanStack/table/issues/4878 (row selection not working for nested rows, + 20 comments), https://github.com/TanStack/table/issues/4759 (Cannot toggle selection of grouped + row to deselect its children, 8 comments) + priority: HIGH + status: active + version_context: v8 / v9 — deeply confusing for tree data + skills: + - row-selection + - grouping + - mistake: Row selection state persists after data is removed/refreshed + mechanism: | + v8 removed v7's `autoResetSelectedRows`. With server-side data updates (websockets, + refetch), row IDs that no longer exist remain in `rowSelection` state and + `getIsAllRowsSelected()` returns true based on stale state. + wrong_pattern: | + // ❌ Selection state goes stale after data refresh + useEffect(() => { + refreshData() // selection still references deleted IDs + }, [trigger]) + correct_pattern: | + // ✅ Prune stale IDs on data change + useEffect(() => { + setRowSelection(prev => { + const validIds = new Set(data.map(row => row.id)) + const next = {} + for (const id in prev) if (validIds.has(id)) next[id] = prev[id] + return next + }) + }, [data]) + source: + https://github.com/TanStack/table/issues/5850 (Row selection is not cleaned up when table + data is removed), https://github.com/TanStack/table/issues/4498 (Row selection does not reset after + upgrading from v7 to v8) + priority: HIGH + status: active + version_context: v7 → v8 migration regression — autoResetSelectedRows was removed + skills: + - row-selection + - state-management + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + gaps: + - | + The `examples/angular/row-selection-signal/` example uses a signal-aware integration (`signal({})` + manual propagation through `onRowSelectionChange`). React's `useCreateAtom` (from `@tanstack/react-store`) is shown in the React example as the modern alternative to `useState` for selection state. Worth flagging in Phase 4 whether a per-adapter atom/signal pattern variant belongs in this skill or in a framework-specific overlay. + - | + `getPreSelectedRowModel` exists but is rarely used in practice — it returns the same thing as `getCoreRowModel` per the source. No failure mode currently flags this as a noise API; left in `covers` only. + - name: React Table State, Subscribe & createTableHook + slug: table-state + domain: framework-adapters + description: + Reactivity, atom subscription, useTable selector, table.Subscribe rendering, FlexRender, + and createTableHook composition for @tanstack/react-table. + type: framework + packages: + - '@tanstack/react-table' + covers: + - useTable + - useTable selector second argument + - table.state (selected state) + - table.atoms (per-slice readonly derived atoms) + - table.baseAtoms (writable internal atoms) + - table.store (flat readonly store) + - table.Subscribe with selector + - table.Subscribe with source (atom or store) + - Subscribe (standalone) + - table.FlexRender + - useCreateAtom from @tanstack/react-store + - atoms option for external ownership + - state plus on[State]Change option + - initialState + - createTableHook + - createAppColumnHelper / useAppTable / useTableContext / useCellContext / useHeaderContext + - table.AppTable / table.AppHeader / table.AppCell / table.AppFooter + tasks: + - Wire up a React table with default reactivity and decide between initialState, atoms, and external + state. + - Optimize re-renders with a useTable selector or table.Subscribe at a specific subtree. + - Hoist a pagination or filter slice into an external atom that other parts of the app subscribe to. + - Build a reusable createTableHook with shared features, row models, and cellComponents / headerComponents + / tableComponents. + failure_modes: + - mistake: Reading state via table.atoms.pagination.get() inside a component and expecting it to re-render + mechanism: + Atom .get() and table.store.state are current-value reads, not React subscriptions. They + do not cause the component to re-render when the atom changes. + wrong_pattern: | + function Pager({ table }) { + const pagination = table.atoms.pagination.get() + return Page {pagination.pageIndex + 1} + } + correct_pattern: | + function Pager({ table }) { + return ( + p.pageIndex} + > + {(pageIndex) => Page {pageIndex + 1}} + + ) + } + source: docs/framework/react/guide/table-state.md (Reading State Without Subscribing); examples/react/basic-subscribe/src/main.tsx + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Passing both atoms.pagination and state.pagination for the same slice + mechanism: + When a slice is owned both ways, external atoms take precedence over external state. The + on[State]Change callback may stop firing or write into a base atom whose value is shadowed by the + external atom, producing surprising divergence. + wrong_pattern: | + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + state: { pagination }, + onPaginationChange: setPagination, + }) + correct_pattern: | + // Pick exactly one source of truth per slice. + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + }) + source: docs/framework/react/guide/table-state.md (Custom Initial State Note); examples/react/basic-external-atoms + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Using table.Subscribe inside a column cell / header definition + mechanism: + In cell and header render contexts, the table field is typed as the core Table, not the React-extended ReactTable, so table.Subscribe is not on the object. Calling it crashes + or fails to compile. + wrong_pattern: | + columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => } + + ), + }) + correct_pattern: | + import { Subscribe } from '@tanstack/react-table' + + columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + + ), + }) + source: docs/framework/react/guide/table-state.md (Tips section); examples/react/basic-subscribe/src/main.tsx + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + skills: + - react-subscribe-compiler-compat + - &id001 + mistake: Quadratic slowdown from nested mergeObjects on every Svelte reactive tick + mechanism: | + `@tanstack/svelte-table` adapter wraps `setOptions` in `$effect.pre`, calling + `setOptions(prev => mergeObjects(prev, mergedOptions))` on every reactive tick. + `mergeObjects` builds a getter chain referencing the previous result; after N + ticks `table.options` is N-deep. Reads of feature-default-only keys + (`onExpandedChange`, etc.) walk the entire chain on every row-model evaluation, + producing O(N²) work and visible freezes while typing into a filter input. + wrong_pattern: | + // ❌ Default svelte-table integration — the chain grows quadratically with keystrokes + const table = createSvelteTable({ + data, columns, state: { globalFilter }, + getCoreRowModel: getCoreRowModel(), + }) + correct_pattern: | + // ✅ Override mergeOptions to flatten on each tick (workaround until upstream fix) + const table = createSvelteTable({ + data, columns, + mergeOptions: (prev, next) => { + const result = {} + const allKeys = new Set([...Reflect.ownKeys(prev), ...Reflect.ownKeys(next)]) + for (const key of allKeys) { + const fromNext = next[key] + result[key] = fromNext !== undefined ? fromNext : prev[key] + } + return result + }, + }) + source: + 'https://github.com/TanStack/table/issues/6235 (svelte-table: nested mergeObjects in setOptions + produces quadratic slowdown)' + priority: CRITICAL + status: active + version_context: + v9.0.0-alpha.32 (svelte). Same structural risk exists in other adapters if they re-call + setOptions per tick. + skills: + - table-state + - production-readiness + - name: Vue Table State, Subscribe & createTableHook + slug: table-state + domain: framework-adapters + description: + Vue reactivity binding, useTable selector, table.Subscribe, FlexRender, and createTableHook + composition for @tanstack/vue-table. + type: framework + packages: + - '@tanstack/vue-table' + covers: + - useTable + - useTable selector second argument + - table.state (selected reactive state) + - table.atoms / table.baseAtoms / table.store + - table.Subscribe with selector + - table.Subscribe with source + - FlexRender component + - vueReactivity (shallowRef + computed + watch flush sync) + - createAtom from @tanstack/vue-store + - useSelector from @tanstack/vue-store + - reactive data / columns (ref or computed) + - atoms / state / on[State]Change options + - createTableHook + tasks: + - Pass a Vue ref or computed as data and let useTable re-sync via setOptions. + - Choose between atoms ownership and state-plus-on[State]Change with getter wrappers for Vue refs. + - Subscribe a small piece of the template to a single atom with table.Subscribe. + failure_modes: + - mistake: Passing a Vue ref directly to state.pagination without a getter + mechanism: + useTable reads state.pagination once when options resolve. Without a getter, the raw ref + object is captured and the table never sees subsequent ref.value changes. + wrong_pattern: | + const pagination = ref({ pageIndex: 0, pageSize: 10 }) + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, + onPaginationChange: (u) => { + pagination.value = u instanceof Function ? u(pagination.value) : u + }, + }) + correct_pattern: | + const pagination = ref({ pageIndex: 0, pageSize: 10 }) + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { + get pagination() { return pagination.value }, + }, + onPaginationChange: (u) => { + pagination.value = u instanceof Function ? u(pagination.value) : u + }, + }) + source: docs/framework/vue/guide/table-state.md (External State); examples/vue/basic-external-state + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Reading table.atoms.pagination.get() outside a reactive context and expecting Vue updates + mechanism: + Atom .get() is a current-value read. It only participates in Vue tracking when called inside + a reactive scope such as a template, computed, or watch. From plain script setup top-level code + it is a one-shot read. + wrong_pattern: | + const pagination = table.atoms.pagination.get() // read once, never updates + correct_pattern: | + const pagination = computed(() => table.atoms.pagination.get()) + // or + // table.state.pagination via useTable selector + source: docs/framework/vue/guide/table-state.md (Reading State Without Subscribing) + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Using the v8 useVueTable name + mechanism: + v9 renamed the adapter creator to useTable for every framework. useVueTable is no longer + exported. + wrong_pattern: | + import { useVueTable } from '@tanstack/vue-table' + correct_pattern: | + import { useTable } from '@tanstack/vue-table' + source: docs/framework/vue/vue-table.md; packages/vue-table/src/useTable.ts + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - *id001 + - name: Solid Table State, Subscribe & createTableHook + slug: table-state + domain: framework-adapters + description: + Solid reactivity binding, createTable selector, table.state() accessor, table.Subscribe, + FlexRender, and createTableHook composition for @tanstack/solid-table. + type: framework + packages: + - '@tanstack/solid-table' + covers: + - createTable + - createTable selector second argument + - table.state() (Solid accessor) + - table.atoms / table.baseAtoms / table.store + - table.Subscribe with selector + - table.Subscribe with source (accessor children) + - FlexRender component + - solidReactivity (createSignal + createMemo) + - createAtom from @tanstack/solid-store + - useSelector from @tanstack/solid-store + - reactive get data() getter pattern + - atoms / state / on[State]Change options + - createTableHook + tasks: + - Wire reactive Solid data via a get data() getter on the options object. + - Read table.state() as an accessor inside JSX and computations. + - Subscribe per-row UI to a single atom with table.Subscribe and an accessor child. + failure_modes: + - mistake: Reading table.state as a value instead of calling table.state() as an accessor + mechanism: + The Solid adapter returns table.state as a function accessor so reads can participate in + Solid tracking. table.state.pagination accesses a property on the function and is undefined. + wrong_pattern: | + const page = table.state.pagination.pageIndex // undefined + correct_pattern: | + const page = table.state().pagination.pageIndex + source: docs/framework/solid/guide/table-state.md (Reading Reactive State with createTable) + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Passing data as a plain reactive accessor value instead of a getter + mechanism: + Solid loses fine-grained reactivity once a signal value is destructured into a plain object. + Without a get data() getter, createTable never re-syncs setOptions when the underlying signal changes. + wrong_pattern: | + const [data] = createSignal(makeData(100)) + const table = createTable({ + _features, + _rowModels: {}, + columns, + data: data(), + }) + correct_pattern: | + const [data] = createSignal(makeData(100)) + const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { return data() }, + }) + source: docs/framework/solid/guide/table-state.md (Reading Reactive State with createTable); examples/solid/basic-use-table/src/App.tsx + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Using table.Subscribe child callback as a raw value + mechanism: + In Solid, table.Subscribe child receives an accessor function. Treating it as a value renders + the function reference, not the selected state. + wrong_pattern: | + s[row.id]}> + {(isSelected) => } + + correct_pattern: | + s[row.id]}> + {(isSelected) => ( + + )} + + source: docs/framework/solid/guide/table-state.md (Fine-grained Updates with table.Subscribe) + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - *id001 + maintainer_notes: + - 'Signal-based adapter: TanStack Store atoms and native framework state are equally supported. Most + developers prefer native state (Solid signals, Angular signals, Svelte runes). The skill should lead + with native-state patterns and treat atoms as the alternative for cross-app state sharing.' + - name: Svelte Table State, subscribeTable & createTableHook + slug: table-state + domain: framework-adapters + description: + Svelte 5 rune reactivity, createTable selector, table.state, subscribeTable .current pattern, + FlexRender, and createTableHook composition for @tanstack/svelte-table. + type: framework + packages: + - '@tanstack/svelte-table' + covers: + - createTable + - createTable selector second argument + - table.state (selected rune-tracked state) + - table.atoms / table.baseAtoms / table.store + - subscribeTable (atom or store source) + - subscribeTable .current accessor + - FlexRender Svelte component + - svelteReactivity ($state + $derived.by + $effect.pre) + - createAtom / useSelector from @tanstack/svelte-store + - reactive get data() getter pattern + - atoms / state / on[State]Change options with $state getters + - createTableHook + tasks: + - Bind table data to a Svelte 5 $state variable through a get data() getter. + - Use subscribeTable to expose a per-slice rune-friendly reactive value with .current. + - Control sorting or pagination via $state + getter + on[State]Change updater pattern. + failure_modes: + - mistake: Using subscribeTable result as a value instead of reading .current + mechanism: + subscribeTable returns a rune-friendly wrapper whose selected value lives behind .current. + Treating the wrapper itself as the value renders an object, not the page index. + wrong_pattern: | + const pageIndex = subscribeTable( + table.atoms.pagination, + (p) => p.pageIndex, + ) + // template + // Page {pageIndex + 1} + correct_pattern: | + const pageIndex = subscribeTable( + table.atoms.pagination, + (p) => p.pageIndex, + ) + // template + // Page {pageIndex.current + 1} + source: docs/framework/svelte/guide/table-state.md (Fine-grained Updates with subscribeTable) + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47; Svelte 5+ only + - mistake: Passing $state values directly to options.state without getters + mechanism: + createTable resolves options once and reads state.pagination as a snapshot. Without a getter, + runes do not surface the latest value to the table, and the controlled slice falls out of sync. + wrong_pattern: | + let pagination = $state({ pageIndex: 0, pageSize: 10 }) + const table = createTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, + onPaginationChange: (u) => { + pagination = u instanceof Function ? u(pagination) : u + }, + }) + correct_pattern: | + let pagination = $state({ pageIndex: 0, pageSize: 10 }) + const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { return data }, + state: { + get pagination() { return pagination }, + }, + onPaginationChange: (u) => { + pagination = u instanceof Function ? u(pagination) : u + }, + }) + source: docs/framework/svelte/guide/table-state.md (External State); examples/svelte/basic-external-state + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47; Svelte 5+ only + - mistake: Using the legacy createSvelteTable name from Svelte 4 era + mechanism: + v9 is Svelte 5+ only and exports createTable. Older codebases referencing createSvelteTable + will fail to import. + wrong_pattern: | + import { createSvelteTable } from '@tanstack/svelte-table' + correct_pattern: | + import { createTable } from '@tanstack/svelte-table' + source: packages/svelte-table/src/createTable.svelte.ts; docs/framework/svelte/svelte-table.md + priority: HIGH + status: active + version_context: v9.0.0-alpha.47; Svelte 5+ only + - *id001 + maintainer_notes: + - 'Signal-based adapter: TanStack Store atoms and native framework state are equally supported. Most + developers prefer native state (Solid signals, Angular signals, Svelte runes). The skill should lead + with native-state patterns and treat atoms as the alternative for cross-app state sharing.' + - name: Angular Table State & injectTable + slug: table-state + domain: framework-adapters + description: + Angular signal reactivity binding, injectTable lazy initializer, atom-as-signal reads, + computed selection with shallow equality, and createTableHook composition for @tanstack/angular-table. + type: framework + packages: + - '@tanstack/angular-table' + covers: + - injectTable + - injectTable initializer function form + - table.atoms / table.baseAtoms / table.store + - angularReactivity(injector) (signal + computed + toObservable) + - shallow equality helper + - computed selectors with equal option + - signal-based state ownership via state plus on[State]Change + - atoms option (core re-export, advanced) + - initialState + - createTableHook + tasks: + - Convert v8 createAngularTable usage to injectTable lazy initializer with signal reads. + - Select a state slice with computed(...) and the shallow equality helper to avoid extra change detection. + - Control sorting and pagination via signal + state + on[State]Change update/set pattern. + failure_modes: + - mistake: Calling injectTable with a plain options object instead of an initializer function + mechanism: + injectTable expects a () => options factory and reruns it when read signals change. Passing + a plain object skips signal tracking and the table never resyncs when source signals update. + wrong_pattern: | + readonly table = injectTable({ + _features, + _rowModels: {}, + columns, + data: this.data(), + }) + correct_pattern: | + readonly table = injectTable(() => ({ + _features, + _rowModels: {}, + columns, + data: this.data(), + })) + source: + docs/framework/angular/guide/table-state.md (Feature-based State); packages/angular-table/src/injectTable.ts; + examples/angular/basic-inject-table + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47; Angular >=19 with signals + - mistake: Using the v8 createAngularTable name + mechanism: + v9 renamed the adapter creator to injectTable to match Angular DI conventions. createAngularTable + is no longer exported. + wrong_pattern: | + import { createAngularTable } from '@tanstack/angular-table' + correct_pattern: | + import { injectTable } from '@tanstack/angular-table' + source: docs/framework/angular/angular-table.md; packages/angular-table/src/injectTable.ts + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Selecting object slices with computed without shallow equality + mechanism: + 'Object/array slice references change on every store update even when their contents are + unchanged. Without { equal: shallow }, downstream computeds and templates re-run unnecessarily, + defeating fine-grained signal performance.' + wrong_pattern: | + readonly pagination = computed(() => this.table.store.state.pagination) + correct_pattern: | + import { shallow } from '@tanstack/angular-table' + + readonly pagination = computed( + () => this.table.store.state.pagination, + { equal: shallow }, + ) + source: docs/framework/angular/guide/table-state.md (Selecting State with Angular computed) + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - *id001 + maintainer_notes: + - 'Signal-based adapter: TanStack Store atoms and native framework state are equally supported. Most + developers prefer native state (Solid signals, Angular signals, Svelte runes). The skill should lead + with native-state patterns and treat atoms as the alternative for cross-app state sharing.' + - name: Lit Table State & createTableHook + slug: table-state + domain: framework-adapters + description: + Lit TableController reactive controller, atom subscription, table.state selector, table.Subscribe + render, FlexRender, and createTableHook composition for @tanstack/lit-table. + type: framework + packages: + - '@tanstack/lit-table' + covers: + - TableController + - tableController.table(options, selector?) + - table.state (selected state) + - table.atoms / table.baseAtoms / table.store + - table.Subscribe with selector or source + - table.FlexRender + - litReactivity + - createAtom from @tanstack/store + - atoms / state / on[State]Change options + - ReactiveControllerHost integration with requestUpdate + - createTableHook + tasks: + - Instantiate one TableController per LitElement host and call .table(...) inside render. + - Use table.state with a selector to keep render output focused on needed slices. + - Control sorting via Lit @state and pass state + onSortingChange updater. + failure_modes: + - mistake: Creating a new TableController inside render() on every update + mechanism: + TableController owns the underlying table instance and subscriptions. Constructing it in + render creates fresh subscriptions and discards prior state every cycle, causing data loss and double-subscribed + host updates. + wrong_pattern: | + protected render() { + const tableController = new TableController(this) + const table = tableController.table({ + _features, + _rowModels: {}, + columns, + data: this._data, + }) + return html`...` + } + correct_pattern: | + private tableController = new TableController(this) + + protected render() { + const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data: this._data, + }) + return html`...` + } + source: packages/lit-table/src/TableController.ts; docs/framework/lit/guide/table-state.md; examples/lit/basic-table-controller/src/main.ts + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Expecting source-only host invalidation from table.Subscribe with a source prop + mechanism: + TableController currently wires host requestUpdate through the full table.store and optionsStore + subscriptions. table.Subscribe with a source is render-time selection sugar, not a per-source invalidation + guarantee. + wrong_pattern: | + // assuming this avoids host invalidation when other slices change + ${table.Subscribe({ + source: table.atoms.rowSelection, + selector: (s) => s[row.id], + children: (isSelected) => html``, + })} + correct_pattern: | + // accept that the host updates on any store change; use Subscribe for cleaner render code + ${table.Subscribe({ + selector: (state) => state.pagination, + children: (pagination) => html`Page ${pagination.pageIndex + 1}`, + })} + source: packages/lit-table/src/TableController.ts (LitTable Subscribe note); docs/framework/lit/guide/table-state.md + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47 + - mistake: Reading atom.get() in render and never seeing UI update + mechanism: + Atom .get() reads a value but does not register with the controller. Host invalidation + is wired through the full table.store subscription, so updates that change the read slice still + trigger render, but state from a non-controller-bound atom that is not part of table.store will + not invalidate the host. + wrong_pattern: | + // separate atom not passed via atoms option + const externalAtom = createAtom({ count: 0 }) + + protected render() { + return html`${externalAtom.get().count}` + } + correct_pattern: | + // wire external atoms through table.atoms or maintain a separate subscription + private tableController = new TableController(this) + private _externalAtom = createAtom({ count: 0 }) + private _unsub?: () => void + + connectedCallback() { + super.connectedCallback() + this._unsub = this._externalAtom.subscribe(() => this.requestUpdate()) + } + + disconnectedCallback() { + super.disconnectedCallback() + this._unsub?.() + } + source: docs/framework/lit/guide/table-state.md (Reading State Without Subscribing); packages/lit-table/src/TableController.ts + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47 + - *id001 + maintainer_notes: + - Lit adapter is scheduled for a rewrite with TanStack Lit Store during beta. Treat the current API + as v9-alpha; expect refinements in beta. + - name: Preact Table State, Subscribe & createTableHook + slug: table-state + domain: framework-adapters + description: + Preact reactivity binding mirroring React, useTable selector, table.Subscribe rendering, + FlexRender, and createTableHook composition for @tanstack/preact-table. + type: framework + packages: + - '@tanstack/preact-table' + covers: + - useTable + - useTable selector second argument + - table.state (selected state) + - table.atoms / table.baseAtoms / table.store + - table.Subscribe with selector or source + - Subscribe (standalone) + - table.FlexRender + - useCreateAtom from @tanstack/preact-store + - useSelector from @tanstack/preact-store + - atoms / state / on[State]Change options + - initialState + - createTableHook (createAppColumnHelper, useAppTable, useTableContext, useCellContext, useHeaderContext) + tasks: + - Set up useTable in Preact with stable references for data, columns, and _features. + - Optimize a large Preact table by passing () => null as the selector and subscribing lower in the tree. + - Build reusable component registries with createTableHook in Preact. + failure_modes: + - mistake: Recreating columns or _features on every render + mechanism: + Preact relies on stable references for table options. New columns or _features arrays each + render force useTable to call setOptions and rebuild derived state, undoing the work selectors and + Subscribe are meant to save. + wrong_pattern: | + function App({ data }) { + const columns = [ + { accessorKey: 'firstName', header: 'First name' }, + ] + const _features = tableFeatures({}) + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + }) + return null + } + correct_pattern: | + const _features = tableFeatures({}) + const columns = [ + { accessorKey: 'firstName', header: 'First name' }, + ] + + function App({ data }) { + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + }) + return null + } + source: docs/framework/preact/preact-table.md; examples/preact/basic-use-table/src/main.tsx + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Using table.Subscribe inside a cell or header render context + mechanism: + The cell and header table field is typed as the core Table and does not + expose Subscribe. Import the standalone Subscribe and pass source={table.store}. + wrong_pattern: | + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => } + + ) + correct_pattern: | + import { Subscribe } from '@tanstack/preact-table' + + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + + ) + source: + docs/framework/preact/guide/table-state.md (Optimizing Re-renders with Selectors and table.Subscribe); + packages/preact-table/src/Subscribe.ts + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Passing useTable both atoms.pagination and state.pagination for the same slice + mechanism: + External atoms take precedence over external state. Mixing them silently hides on[State]Change-driven + setState writes and leaves React-style state stale. + wrong_pattern: | + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + state: { pagination }, + onPaginationChange: setPagination, + }) + correct_pattern: | + // Pick exactly one source of truth per slice. + const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + }) + source: docs/framework/preact/guide/table-state.md (Custom Initial State Note); examples/preact/basic-external-atoms + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - *id001 + - name: React Subscribe for React Compiler Compatibility + slug: react-subscribe-compiler-compat + domain: framework-adapters + description: + Use Subscribe / table.Subscribe to keep nested header and cell components correct under + React Compiler memoization. + type: framework + packages: + - '@tanstack/react-table' + covers: + - Subscribe (standalone import) + - table.Subscribe (from useTable result) + - source prop (atom or store) + - selector prop + - render-prop children + - useSelector under the hood + - table.store + - table.atoms.rowSelection / columnPinning / etc. + tasks: + - Wrap row-selection or pin-button JSX inside nested header/cell components in Subscribe to prevent + React Compiler from memoizing stale state reads. + - Choose between subscribing to table.store with a selector vs subscribing to a single atom for per-row + UI. + - Audit a table for builder-pattern reads inside custom component boundaries that are likely memoization + risks. + failure_modes: + - mistake: + Reading column.getIsPinned() / row.getIsSelected() / cell.getIsAggregated() inside a custom + child component and not wrapping in Subscribe + mechanism: + React Compiler memoizes the child's JSX against the stable column / row / cell reference + it receives. The state-dependent builder method hides its atom dependency from the compiler, so + the memoized JSX never re-runs when the underlying atom changes. + wrong_pattern: | + function DraggableHeader({ header }) { + const isPinned = header.column.getIsPinned() + return + } + correct_pattern: | + import { Subscribe } from '@tanstack/react-table' + + function DraggableHeader({ header, table }) { + return ( + s.columnPinning} + > + {() => { + const isPinned = header.column.getIsPinned() + return + }} + + ) + } + source: + docs/framework/react/guide/table-state.md (Subscribe for React Compiler Compatibility); examples/react/basic-subscribe/src/main.tsx; + packages/react-table/src/Subscribe.ts + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47; React Compiler enabled + - mistake: + Using table.Subscribe from inside a cell or header definition where table is the core Table + type + mechanism: + Cell and header render contexts type table as Table, not ReactTable. + table.Subscribe is undefined. The fix is to import the standalone Subscribe and pass source={table.store} + or source={table.atoms.X}. + wrong_pattern: | + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => } + + ) + correct_pattern: | + import { Subscribe } from '@tanstack/react-table' + + cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + + ) + source: docs/framework/react/guide/table-state.md (Tips); packages/react-table/src/Subscribe.ts + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Wrapping every cell in Subscribe by default + mechanism: + Subscribe is overhead. For inline JSX in the parent component, the compiler always re-evaluates + when the parent re-renders, so wrapping is unnecessary. Over-wrapping adds subscription churn without + correctness benefit. + wrong_pattern: | + // Inline cell that already re-runs each parent render + {row.getVisibleCells().map((cell) => ( + s.rowSelection}> + {() => } + + ))} + correct_pattern: | + {row.getVisibleCells().map((cell) => ( + + ))} + source: docs/framework/react/guide/table-state.md (Subscribe for React Compiler Compatibility) + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47 + - mistake: Subscribing to the whole table.store for one row's selection checkbox + mechanism: + Every store change re-renders the Subscribe child. Subscribing to table.atoms.rowSelection + (or a per-row selector returning s[row.id]) limits re-renders to actual selection changes for that + row. + wrong_pattern: | + s.rowSelection[row.id]}> + {(isSelected) => } + + correct_pattern: | + s[row.id]}> + {(isSelected) => ( + + )} + + source: docs/framework/react/guide/table-state.md (Tips bullets); examples/react/basic-subscribe/src/main.tsx + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47 + - mistake: Reading raw v8 state on v9 ReactTable instead of using Subscribe + mechanism: | + v9 ReactTable returns an object whose state slices live on `table.atoms.*` / `table.store`. + Reading `table.getState().rowSelection` directly during render does not subscribe the + component — the table only re-renders when its top-level state ref changes, so checkbox + visuals, sort indicators, and pagination text go stale unless you use `` + or a state selector. + wrong_pattern: | + // ❌ Reads state during render — does NOT subscribe in v9 alpha + function Toolbar({ table }) { + const { rowSelection, sorting } = table.getState() + return
{Object.keys(rowSelection).length} selected
+ } + correct_pattern: | + // ✅ Use table.Subscribe to opt into reactive updates + function Toolbar({ table }) { + return ( + ({ count: Object.keys(s.rowSelection).length })} + > + {({ count }) =>
{count} selected
} +
+ ) + } + source: + 'PR #6246 "feat: change default state selectors to all"; PR #6244 (options store refactor); + useTable.ts JSDoc examples' + priority: CRITICAL + status: active + version_context: + v9 alpha pattern. Agents trained on v8 will reach for table.getState() and never + subscribe. Default selector now subscribes to ALL state slices since alpha.47, but selectors are + the documented pattern. + skills: + - react-subscribe-compiler-compat + - table-state + - customizing-feature-behavior + - mistake: setOptions in render body causing SubscribeBound warning + mechanism: | + Since alpha.20, `useTable` calls `table.setOptions()` during render instead of in + `useEffect`. That synchronously flushes store subscribers — including `SubscribeBound` + — so React warns "Cannot update a component (SubscribeBound) while rendering a + different component (Table)". The warning trips even on a single state change to a + parent prop because `optionsStore.setState() → atom.set() → propagate() → flush()` + all run synchronously inside the parent's render phase. + wrong_pattern: | + // ❌ Common v9 alpha setup — surfaces SubscribeBound warning on every parent rerender + function MyTable({ globalFilter }) { + const table = useTable({ _features, data, columns, state: { globalFilter } }) + return ( + ({ globalFilter: s.globalFilter })}> + {() => ...} + + ) + } + correct_pattern: | + // ✅ Until the upstream fix lands, drive controlled state through onChange handlers + // so `state` doesn't change on every parent render, or wait for the post-alpha.47 fix. + // See PR #6244 / #6246 — KevinVandy's WIP refactor of the optionsStore for react/preact. + function MyTable() { + const [globalFilter, setGlobalFilter] = useState('') + const table = useTable({ + _features, data, columns, + state: { globalFilter }, + onGlobalFilterChange: setGlobalFilter, + }) + return ({ globalFilter: s.globalFilter })}> + {() => ...} + + } + source: + 'https://github.com/TanStack/table/issues/6224 (alpha: useTable causes "Cannot update a component + (SubscribeBound)")' + priority: CRITICAL + status: active + version_context: + 'v9.0.0-alpha.20+ regression. Owner discussion in comments shows PR #6244 attempts + a fix by removing optionsStore from react/preact adapters.' + skills: + - react-subscribe-compiler-compat + - composable-tables + - mistake: Trying to subscribe to table.options.meta + mechanism: | + `table.Subscribe` subscribes to `table.store` / atoms — not to `table.options`. + Putting external state into `meta` and trying to subscribe to it via a selector + will never fire updates because options aren't part of the reactive state graph. + wrong_pattern: | + // ❌ meta lives on options, not store — Subscribe will never re-fire + const table = useTable({ ..., meta: { foo: someState } }) + s.options?.meta?.foo}> + {(foo) => {foo}} + + correct_pattern: | + // ✅ Keep external reactive data outside the table or push it into a real state slice. + // For values needed inside cells, close over them in the columns array (recreate + // columns when the value changes) or pass them via React context. + const ExternalContext = React.createContext(null) + + + + source: https://github.com/TanStack/table/issues/6236 (Unable to subscribe to table.options.meta) + priority: HIGH + status: active + version_context: + v9 alpha; subtle because meta worked as a stash bucket in v8 and people assume Subscribe + covers it + skills: + - react-subscribe-compiler-compat + - table-state + - mistake: Input loses focus inside composable AppTable on every keystroke + mechanism: | + In v9 alpha.12 (and via store v0.9 update in PR #6180), keying off a state-driven + selector inside `` caused inputs nested in toolbars/cells to lose + focus after each keypress because the entire subtree re-mounted. The fix landed + between alpha.12 → alpha.32 via the atoms refactor (PR #6234), but the underlying + lesson — Subscribe-driven children must NOT cause input remounts — is still load-bearing. + wrong_pattern: | + // ❌ Selecting a frequently-changing slice from the top-level table.AppTable boundary + // can remount everything under it. + ({ globalFilter: s.globalFilter })}> + + {/* loses focus */} + + + correct_pattern: | + // ✅ Push the Subscribe boundary as close to the consumer as possible + + + {/* keep input outside Subscribe; subscribe only inside the bit that needs it */} + + s.globalFilter}> + {(filter) => filter: {filter}} + + + + source: + 'https://github.com/TanStack/table/issues/6199 (Bug[v9]: broken inputs inside a composable + table)' + priority: HIGH + status: fixed-but-legacy-risk + version_context: v9 alpha.12 regression, fixed by alpha.32. Pattern lesson still applies. + skills: + - react-subscribe-compiler-compat + - composable-tables + - mistake: Using v8 useReactTable with React Compiler skips memoization + mechanism: | + React Compiler emits "Use of incompatible library" warning when it sees + `useReactTable()` because the returned table instance has mutable methods whose + references change. Compiler bails out and skips memoization for that component, + defeating the optimization. The v9 alpha Subscribe pattern is the answer — agents + should NOT add `'use no memo'` pragmas as a workaround. + wrong_pattern: | + // ❌ React Compiler skips this entire component + 'use client' + import { useReactTable, getCoreRowModel } from '@tanstack/react-table' + export function DataTable({ columns, data }) { + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + return
{header.column.id}{header.column.id}{flexRender(cell.column.columnDef.cell, cell.getContext())} + +
+ } + correct_pattern: | + // ✅ Move to v9 alpha useTable + Subscribe; opt-in subscription model is React-Compiler-friendly + 'use client' + import { useTable, tableFeatures, rowSelectionFeature } from '@tanstack/react-table' + const _features = tableFeatures({ rowSelectionFeature }) + export function DataTable({ columns, data }) { + const table = useTable({ _features, data, columns }) + return ( + ({ rowSelection: s.rowSelection })}> + {() =>
} + + ) + } + source: + https://github.com/TanStack/table/issues/6137 (React Compiler skips memoization), https://github.com/TanStack/table/issues/5567 + (Table doesn’t re-render with new React Compiler + React 19) + priority: CRITICAL + status: active + version_context: + v8 fundamental limitation. The whole v9 alpha redesign exists to fix this. Agents + trained on v8 will keep producing the broken pattern. + - mistake: Table does not re-render when data changes under React Compiler + mechanism: | + With React Compiler enabled and React 19, mutating an array used as `data` and + then setting the state doesn't cause the table body to re-render because the + compiler memoizes `table.getRowModel().rows` based on the (unchanged) table + reference. The v8 row-model output is a method call, so the compiler can't tell + it depends on `data` identity. + wrong_pattern: | + // ❌ data updates but rows don't refresh + const [data, setData] = useState([]) + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + function addRow() { + setData([...data, newRow]) // table.getRowModel().rows is memoized stale + } + correct_pattern: | + // ✅ Use v9 alpha — table.Subscribe drives re-renders explicitly + const table = useTable({ _features, data, columns }) + s.rowSelection}> + {() => ( + + {table.getRowModel().rows.map(row => )} + + )} + + source: https://github.com/TanStack/table/issues/5567 (29 comments) + priority: CRITICAL + status: active + version_context: + v8 + React Compiler — top-engagement open issue with 29 comments; v9 alpha designed + specifically to address this + skills: + - react-subscribe-compiler-compat + - state-management + - mistake: Premature Subscribe / custom selector optimization on small tables + mechanism: + Subscribe and narrow selectors are designed for large or expensive tables where full re-renders + measurably hurt. On small tables (a few hundred rows, simple features), default selector + inline + rendering is fine and the Subscribe boundaries add complexity without measurable benefit. + wrong_pattern: |- + // ❌ Wraps every header and cell in Subscribe on a 50-row table + header: ({ table }) => ( + s.sorting}> + {() => } + + ) + correct_pattern: |- + // ✅ Default useTable selector, inline rendering — Subscribe only when measured perf demands it + const table = useTable({ _features, _rowModels, columns, data }) + // reach for Subscribe later, scoped to the actual hotspot + priority: MEDIUM + version_context: 'Maintainer guidance: advanced state-management patterns are for advanced cases.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - name: Angular Rendering Directives & DI Context + slug: angular-rendering-directives + domain: framework-adapters + description: + Structural directives flexRender / flexRenderCell / flexRenderHeader / flexRenderFooter + plus context tokens for prop-drill-free Angular rendering. + type: framework + packages: + - '@tanstack/angular-table' + covers: + - FlexRender (tuple export of FlexRenderDirective and FlexRenderCell) + - '*flexRender structural directive' + - '*flexRenderCell shorthand' + - '*flexRenderHeader shorthand' + - '*flexRenderFooter shorthand' + - flexRenderProps input + - flexRenderComponent(component, options) + - inputs / outputs / bindings / directives / injector options + - inputBinding / outputBinding / twoWayBinding (Angular v20+) + - injectFlexRenderContext + - TanStackTable directive + injectTableContext / TanStackTableToken + - TanStackTableHeader directive + injectTableHeaderContext / TanStackTableHeaderToken + - TanStackTableCell directive + injectTableCellContext / TanStackTableCellToken + - TemplateRef rendering with implicit context + tasks: + - Render cells and headers with the shorthand structural directives, falling back to *flexRender for + custom props. + - Pass dynamic inputs and outputs to a cell component with flexRenderComponent. + - Eliminate input drilling by applying tanStackTableCell / tanStackTableHeader / tanStackTable directives + and injecting context tokens in descendants. + failure_modes: + - mistake: Forgetting to import FlexRender in a standalone component + mechanism: + FlexRender is exported as a tuple of directives. Angular standalone components only see + directives that are listed in imports, so without the import, *flexRender and *flexRenderCell silently + do nothing or fail template compilation. + wrong_pattern: | + @Component({ + imports: [], + templateUrl: './app.html', + }) + export class AppComponent {} + correct_pattern: | + import { FlexRender } from '@tanstack/angular-table' + + @Component({ + imports: [FlexRender], + templateUrl: './app.html', + }) + export class AppComponent {} + source: docs/framework/angular/guide/rendering.md (FlexRender import) + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47; Angular >=19 + - mistake: + Returning a component class but expecting all CellContext properties to be visible without + input signals + mechanism: + FlexRender wires render context onto the component via ComponentRef.setInput, but only + declared input() / input.required() properties receive values. Undeclared properties are silently + ignored. + wrong_pattern: | + @Component({ template: `{{ cell.getValue() }}` }) + export class MyCellComponent { + cell: any // not declared as input + } + correct_pattern: | + import { Component, input } from '@angular/core' + import type { CellContext } from '@tanstack/angular-table' + + @Component({ template: `{{ cell().getValue() }}` }) + export class MyCellComponent { + readonly cell = input.required>() + } + source: docs/framework/angular/guide/rendering.md (Returning a component class) + priority: HIGH + status: active + version_context: v9.0.0-alpha.47; Angular >=19 + - mistake: Mixing bindings with inputs / outputs on the same property in flexRenderComponent + mechanism: + bindings are applied at component creation, inputs / outputs are applied post-creation. + Combining them on the same property causes double initialization or conflicting values. + wrong_pattern: | + flexRenderComponent(EditableCell, { + inputs: { value: 'a' }, + bindings: [inputBinding('value', this.name)], + }) + correct_pattern: | + flexRenderComponent(EditableCell, { + bindings: [ + inputBinding('value', this.name), + outputBinding('valueChange', (v) => console.log(v)), + ], + }) + source: docs/framework/angular/guide/rendering.md (bindings API note) + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47; Angular v20+ for bindings + - mistake: Drilling cell or header through input() in nested presentational components + mechanism: + Using FlexRender + context directives makes injectTableCellContext / injectTableHeaderContext + / injectTableContext available to any descendant of the [tanStackTableCell] / [tanStackTableHeader] + / [tanStackTable] host. Drilling defeats the DI pattern and complicates refactors. + wrong_pattern: | + + correct_pattern: | + + + // app-cell-actions.ts + import { injectTableCellContext } from '@tanstack/angular-table' + + @Component({ template: `` }) + export class CellActionsComponent { + readonly cell = injectTableCellContext() + onAction() { console.log(this.cell()) } + } + source: docs/framework/angular/guide/rendering.md (Context directives) + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47; Angular >=19 + - mistake: + Calling injectFlexRenderContext outside a *flexRender rendered subtree without applying the + corresponding context directive + mechanism: + Tokens are only provided in the child injector that FlexRender creates, or where the [tanStackTable] + / [tanStackTableHeader] / [tanStackTableCell] directives are applied. Components outside both scopes + throw a NullInjectorError. + wrong_pattern: | + // Sibling component in the same + correct_pattern: | + + source: docs/framework/angular/guide/rendering.md (Automatic token injection in FlexRender) + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47; Angular >=19 + - mistake: Defining columns inside createAngularTable signal causes flexRender remount on every tick + mechanism: | + When the `columns` array is defined inside the `createAngularTable` options + function, Angular recreates the array on every reactive update. `FlexRenderComponent` + sees new references and remounts components, running `ngOnInit` every time — + tanking performance. + wrong_pattern: | + // ❌ Columns recreated inside the signal scope + table = createAngularTable(() => ({ + data: this.data(), + columns: [ + { id: 'name', header: 'Name', cell: flexRenderComponent(NameCell) }, + ], + getCoreRowModel: getCoreRowModel(), + })) + correct_pattern: | + // ✅ Hoist columns out of the options function + private columns = [ + { id: 'name', header: 'Name', cell: flexRenderComponent(NameCell) }, + ] + table = createAngularTable(() => ({ + data: this.data(), + columns: this.columns, + getCoreRowModel: getCoreRowModel(), + })) + source: https://github.com/TanStack/table/issues/6159 ([Angular Table] poor performance of flexRenderComponent) + priority: CRITICAL + status: active + version_context: + v8.21.3 angular, also affects v9. Doc gap acknowledged by maintainers in the closed-as-docs + label. + skills: + - angular-rendering-directives + - rendering + - mistake: createAngularTable in library project emits ɵSIGNAL TS4023 + mechanism: | + When `createAngularTable` is used in a buildable Angular library, the inferred + return type references internal `@angular/core` symbols (`SIGNAL`, `ɵSIGNAL`) + that can't be named from outside. Build fails with TS4023. + wrong_pattern: | + // ❌ In a library file; build fails + @Component(...) + export class MyTable { + table = createAngularTable(() => ({ data, columns, getCoreRowModel: getCoreRowModel() })) + } + correct_pattern: | + // ✅ Explicitly type or wrap with helper to break the inferred reference + import type { AngularTable } from '@tanstack/angular-table' + @Component(...) + export class MyTable { + table: AngularTable = createAngularTable(() => ({ ... })) + } + source: + https://github.com/TanStack/table/issues/5774 (createAngularTable in monorepo causes ɵSIGNAL + error) + priority: HIGH + status: fixed-but-legacy-risk + version_context: + v8.20.5 angular + Angular 18; closed after Angular update but library pattern is + common + - name: Lit TableController Pattern + slug: lit-table-controller + domain: framework-adapters + description: + TableController ReactiveController pattern for hosting a TanStack Table instance inside + a LitElement. + type: framework + packages: + - '@tanstack/lit-table' + covers: + - TableController class + - TableController constructor(host) addController side effect + - tableController.table(options, selector?) + - table.state (selected state) + - table.Subscribe ({ source, selector, children }) + - table.FlexRender (cell / header / footer rendering) + - litReactivity + - hostConnected / hostDisconnected lifecycle + - table.store and table.optionsStore subscriptions driving requestUpdate + tasks: + - Create a single TableController per LitElement and call .table(...) each render. + - Pass a selector to .table(...) to reduce work in the render method. + - Render headers and cells via table.FlexRender or table.Subscribe in lit-html templates. + failure_modes: + - mistake: Instantiating TableController inside render() instead of as a class field + mechanism: + TableController.addController and store subscriptions run in the constructor. Recreating + it per render leaks subscriptions, drops the prior table instance (losing state), and double-triggers + host updates. + wrong_pattern: | + protected render() { + const controller = new TableController(this) + const table = controller.table({ _features, _rowModels: {}, columns, data: this._data }) + return html`...` + } + correct_pattern: | + private tableController = new TableController(this) + + protected render() { + const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data: this._data, + }) + return html`...` + } + source: packages/lit-table/src/TableController.ts; examples/lit/basic-table-controller/src/main.ts + priority: CRITICAL + status: active + version_context: v9.0.0-alpha.47 + - mistake: Using table.FlexRender as a Lit directive instead of a function call + mechanism: + table.FlexRender is a function that returns a TemplateResult. It is invoked inside ${...} + interpolation with a header / cell / footer argument, not used as a custom element. + wrong_pattern: | + html`` + correct_pattern: | + html`` + source: packages/lit-table/src/TableController.ts (FlexRender attached); docs/framework/lit/lit-table.md + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: Calling table.Subscribe children outside a template interpolation + mechanism: + table.Subscribe returns a TemplateResult or string. It must be embedded with ${...} inside + an html`` template. Calling it as a statement produces a value that the renderer never sees. + wrong_pattern: | + protected render() { + table.Subscribe({ + selector: (s) => s.pagination, + children: (p) => html`${p.pageIndex + 1}`, + }) + return html`
` + } + correct_pattern: | + protected render() { + return html` +
+ ${table.Subscribe({ + selector: (s) => s.pagination, + children: (p) => html`Page ${p.pageIndex + 1}`, + })} +
+ ` + } + source: packages/lit-table/src/TableController.ts (Subscribe shape); docs/framework/lit/guide/table-state.md + priority: HIGH + status: active + version_context: v9.0.0-alpha.47 + - mistake: + Forgetting that host update is wired through table.store, so source-only Subscribe does not + skip host updates + mechanism: + TableController subscribes the host to the full table.store and optionsStore. table.Subscribe + with a source prop reads source.get() at render time but does not currently install an independent + host subscription, so the host still updates on any store change. + wrong_pattern: | + // expecting only rowSelection changes to invalidate the host + ${table.Subscribe({ + source: table.atoms.rowSelection, + selector: (s) => s[row.id], + children: (isSelected) => html``, + })} + correct_pattern: | + // accept host updates on any store change; reach for an explicit manual subscription only if you measure a real bottleneck + ${table.Subscribe({ + source: table.atoms.rowSelection, + selector: (s) => s[row.id], + children: (isSelected) => html` + + `, + })} + source: + packages/lit-table/src/TableController.ts (LitTable Subscribe note); docs/framework/lit/guide/table-state.md + (Selecting State with table.Subscribe) + priority: MEDIUM + status: active + version_context: v9.0.0-alpha.47 + maintainer_notes: + - The Lit adapter is scheduled to be rewritten with TanStack Lit Store during the v9 beta cycle. Current + contracts (TableController, store-level invalidation) may change. Skill content should describe the + current shape but note that beta may bring an API revision. + - slug: getting-started + type: lifecycle + domain: lifecycle + status: ready + description: | + End-to-end first-table journey: install the framework adapter, declare `_features` via `tableFeatures()`, declare `_rowModels` factories with their *Fns parameters, create a column helper with both `TFeatures` and `TData` generics, instantiate `useTable` / `injectTable` / `createTable`, and render headers + cells with `table.FlexRender` (or `*flexRender*` directives on Angular). New users land here, not on `useLegacyTable`. + covers: + - Picking the right adapter package (`@tanstack/react-table`, `@tanstack/vue-table`, `@tanstack/angular-table`, + `@tanstack/solid-table`, `@tanstack/svelte-table` (Svelte 5+), `@tanstack/preact-table`, `@tanstack/lit-table`). + - 'Declaring the minimum-viable v9 table (no features) — must still pass `_features: tableFeatures({})` + and `_rowModels: {}`. Core row model is automatic; you only register filtered/sorted/paginated/etc. + when you use them.' + - "`createColumnHelper()` and the new `columnHelper.columns([...])` variadic + wrapper for preserving each column's `TValue` in nested/group columns." + - Rendering with `` / `` (preferred) + or the standalone `` import. `flexRender(def, ctx)` still works. + - 'Angular variant: `injectTable(() => ({ ... }))` returning a signal- backed instance; render via `*flexRenderHeader`, + `*flexRenderCell`, `*flexRenderFooter`. Cell/header render fns run inside an Angular injection context + — `inject()` is safe.' + - Default `useTable` selector is `(state) => state` — full re-render on any state change, mirroring + v8 ergonomics. Narrow it later only when you measure a problem. + - 'Where to go next: `migrate-v8-to-v9` if upgrading, `production- readiness` once it works.' + packages: + - '@tanstack/react-table' + - '@tanstack/vue-table' + - '@tanstack/angular-table' + - '@tanstack/solid-table' + - '@tanstack/svelte-table' + - '@tanstack/preact-table' + - '@tanstack/lit-table' + tasks: + - Render a basic v9 table with 3 columns and an in-memory array. + - 'Add sorting: register `rowSortingFeature` in `_features` and `sortedRowModel: createSortedRowModel(sortFns)` + in `_rowModels`.' + - Add pagination + filtering on top of sorting without touching the rest of the setup. + - Type a `ColumnDef[]` array outside the component for stable references. + correct_pattern: | + // React — minimum viable v9 table + import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + createColumnHelper, + } from '@tanstack/react-table' + + type Person = { firstName: string; lastName: string; age: number } + + // Define OUTSIDE the component for stable identity. + const _features = tableFeatures({ rowSortingFeature }) + const columnHelper = createColumnHelper() + + const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + columnHelper.accessor('age', { header: 'Age' }), + ]) + + function PeopleTable({ data }: { data: Person[] }) { + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }) + + return ( +
+ + + + as a *flexRenderCell-rendered cell + + + + + + + ${table.FlexRender({ cell })}
+ {table.getHeaderGroups().map(hg => ( + {hg.headers.map(h => ( + + ))} + ))} + {table.getRowModel().rows.map(row => ( + {row.getAllCells().map(cell => ( + + ))} + ))} +
+ +
+ ) + } + wrong_pattern: | + // BAD: agents trained on v8 will produce this and it will not compile. + // (1) `useReactTable` was removed in favor of `useTable`. + // (2) `getCoreRowModel: getCoreRowModel()` is no longer accepted — + // core is implicit; everything else moved to `_rowModels`. + // (3) `createColumnHelper()` is missing the TFeatures generic. + // (4) No `_features` => "Property '_features' is missing in type". + import { useReactTable, getCoreRowModel, createColumnHelper, flexRender } + from '@tanstack/react-table' + + const columnHelper = createColumnHelper() // wrong arity + const table = useReactTable({ // wrong hook + columns, + data, + getCoreRowModel: getCoreRowModel(), // removed option + }) + failure_modes: + - "Forgetting `_features: tableFeatures({})` — the option is required even for a 'no features' table. + Type error: '_features' is missing." + - 'Skipping `_rowModels: {}` — same as above. Core is automatic but the option key itself must be present.' + - Calling `createColumnHelper()` instead of `createColumnHelper()`. + The generic order changed in v9 and the old shape doesn't compile. + - Defining `_features` / `columns` / `data` literals inside the render body — every render produces + a new reference and busts table-internal memoization. Always declare these outside the component or + wrap in `useMemo`. + - Importing `useReactTable` (v8) or `createAngularTable` (v8 Angular) — both were renamed. v9 uses `useTable` + / `injectTable` / `createTable` consistently across adapters. + - Reaching for `useLegacyTable` for a new project. It's deprecated and ships every feature in the bundle. + New code must use `useTable` with explicit `_features`. + - mistake: Reimplementing what TanStack Table's built-in APIs already provide + mechanism: + TanStack Table IS a state-management coordinator with built-in APIs for nearly every state + transition (`table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, + `column.toggleVisibility`, …). Agents often write their own setState logic, click handlers, or sort/filter + loops rather than using the built-ins, producing more code that is also less correct (skips internal + invariants, breaks reset APIs). + wrong_pattern: |- + // ❌ Reimplements sorting state manually instead of using the API + const [sorting, setSorting] = useState([]) + const sortedData = useMemo(() => [...data].sort((a,b) => /* …custom… */), [data, sorting]) + // then uses sortedData directly, bypassing the table + correct_pattern: |- + // ✅ Use the built-in APIs — table handles state, reset, multi-sort, etc. + const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + // then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() + priority: CRITICAL + version_context: + 'Always present. The maintainer flags this as the #1 tell that "an AI wrote this." + See `setSorting`/`setColumnFilters`/`toggleSelected`/`nextPage`/etc.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: API or state slice "missing" because the feature was not registered in `_features` + mechanism: + In v9, `_features` is a tree-shakeable registry. If a feature is not in `_features`, TypeScript + hides its APIs and the runtime atom is not created. Agents who copy a snippet for `table.setColumnFilters(...)` + without registering `columnFilteringFeature` see a TS error or `table.atoms.columnFilters` is undefined + — and may incorrectly conclude the feature is broken or removed in v9. + wrong_pattern: |- + // ❌ rowSortingFeature missing — table.setSorting / state.sorting unavailable + const _features = tableFeatures({}) // empty + const table = useTable({ _features, _rowModels: {}, columns, data }) + table.setSorting([{ id: 'age', desc: true }]) // ❌ does not exist on this table type + correct_pattern: |- + // ✅ Register every feature you intend to use; pair with its row model when applicable + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, data, + }) + priority: CRITICAL + version_context: + v9-specific. This is the first version of TanStack Table where features must be declared + for TypeScript and runtime to expose them. Expect heavy confusion from devs and agents trained on + v8. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - slug: migrate-v8-to-v9 + type: lifecycle + domain: lifecycle + status: ready + description: | + Mechanical breaking-change migration from TanStack Table v8 to v9 across every adapter. Every old-shaped option, type, or method an agent will try to reproduce from v8 muscle memory has a v9 equivalent enumerated below. For React projects that cannot migrate every table at once, `useLegacyTable` from `@tanstack/react-table/legacy` accepts the v8 API shape on top of the v9 engine — deprecated, ships every feature, no `table.Subscribe`, but lets you upgrade incrementally. + covers: + - Hook rename per adapter (React, Angular, Solid, Vue, Preact, Svelte 5+, Lit, vanilla). + - '`_features` + `_rowModels` becoming required options; row-model factories now take their *Fns set + as a parameter.' + - '`createColumnHelper()` → `createColumnHelper()` (generic order + + arity change).' + - 'State surface: `table.getState()` → `table.store.state` (full) or `table.state` (selector output) + or `table.atoms..get()`.' + - '`flexRender(def, ctx)` still works; new preferred forms are `` and the standalone ``.' + - 'Sorting renames: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, `getSortingFn()` → `getSortFn()`, + etc.' + - '`enablePinning` split into `enableColumnPinning` + `enableRowPinning`.' + - '`ColumnSizing` feature split into `columnSizingFeature` + `columnResizingFeature`; `columnSizingInfo` + → `columnResizing`; `onColumnSizingInfoChange` → `onColumnResizingChange`.' + - '`_`-prefixed internal APIs removed (`row._getAllCellsByColumnId()` → `row.getAllCellsByColumnId()`, + `table._getFacetedRowModel()` → public form, etc.).' + - 'Type generics now carry `TFeatures` first: `Column`, `Table`, `Row`, `ColumnMeta`.' + - '`RowData` tightened from `unknown | object | any[]` to `Record | Array`.' + - Angular has no legacy adapter — Angular projects must migrate directly. Svelte v9 supports Svelte + 5+ only; stay on v8 for Svelte 3/4. + - 'The escape hatch: `useLegacyTable` (React) + `legacyCreateColumnHelper` + `getCoreRowModel`/`getSortedRowModel`/etc. + from `@tanstack/react-table/legacy` — same call shape as v8, no fine-grained subscriptions, larger + bundle.' + - 'The other escape hatch: `stockFeatures` to opt into "everything" without listing features explicitly.' + packages: + - '@tanstack/react-table' + - '@tanstack/vue-table' + - '@tanstack/angular-table' + - '@tanstack/solid-table' + - '@tanstack/svelte-table' + - '@tanstack/preact-table' + - '@tanstack/lit-table' + - '@tanstack/table-core' + tasks: + - 'Upgrade a v8 React table to v9 by renaming `useReactTable` → `useTable`, moving `getCoreRowModel`/`getFilteredRowModel`/`getSortedRowModel`/`getPaginationRowModel` + options into `_rowModels`, and adding `_features: tableFeatures({...})`.' + - Bridge a v8 React table to v9 via `useLegacyTable` from `@tanstack/react-table/legacy` so the rest + of the app can upgrade incrementally. + - Rename `table.getState()` reads throughout the codebase to `table.store.state` (full read) or `table.state` + (typed selector output) or `table.atoms..get()` (single-slice atom read). + - Update all `createColumnHelper()` call sites to `createColumnHelper()` + and adjust `ColumnDef` / `Column` / `Row` / `Cell` type annotations + to the new `TFeatures`-first generic order. + correct_pattern: | + // === v9 (correct) === + import { + useTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnSizingFeature, + columnResizingFeature, + createColumnHelper, + createSortedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + sortFns, + filterFns, + } from '@tanstack/react-table' + import type { ColumnDef } from '@tanstack/react-table' + + const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnSizingFeature, + columnResizingFeature, // explicit — formerly part of ColumnSizing + }) + + const columnHelper = createColumnHelper() + + const columns: ColumnDef[] = columnHelper.columns([ + columnHelper.accessor('name', { + header: 'Name', + sortFn: 'alphanumeric', // renamed from sortingFn + }), + ]) + + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + enableColumnPinning: true, // split from enablePinning + enableRowPinning: true, + }) + + // State reads: + const all = table.store.state // full snapshot + const sorting = table.atoms.sorting.get() // per-slice atom + const cells = row.getAllCellsByColumnId() // no underscore + + // Rendering: + // + // + wrong_pattern: | + // === v8 muscle memory — an agent will produce all of this, and ALL of it + // is broken in v9. Each line is a specific failure mode. === + + import { + useReactTable, // (1) renamed → useTable + getCoreRowModel, // (2) no longer accepted as an option; + getFilteredRowModel, // move to `_rowModels` as factories + getSortedRowModel, // created via `createSortedRowModel(sortFns)` etc. + getPaginationRowModel, + createColumnHelper, // (3) needs now + sortingFns, // (4) renamed → sortFns + filterFns, + flexRender, // still exists, but prefer table.FlexRender + } from '@tanstack/react-table' + import type { ColumnDef, Row } from '@tanstack/react-table' + + const columnHelper = createColumnHelper() // wrong generic arity + + const columns: ColumnDef[] = [ // (5) ColumnDef now + { + accessorKey: 'name', + header: 'Name', + sortingFn: 'alphanumeric', // (6) renamed → sortFn + }, + ] + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), // (2) move into `_rowModels` + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + filterFns, // (7) no longer a root option — passed to factories + sortingFns, // (4)+(7) renamed AND no longer a root option + enablePinning: true, // (8) split → enableColumnPinning / enableRowPinning + onColumnSizingInfoChange: setInfo, // (9) renamed → onColumnResizingChange + }) + + // (10) BAD reads: + const all = table.getState() // → table.store.state (or table.state with a selector) + const cells = row._getAllCellsByColumnId() // underscore removed → row.getAllCellsByColumnId() + + // (11) Module augmentation + declare module '@tanstack/react-table' { + interface ColumnMeta { // (11) v9 adds TFeatures as first generic + customProp: string + } + } + + // === Quick-bridge alternative (DEPRECATED, but compiles): === + // Move just THIS file to `useLegacyTable` and keep the v8 shape; migrate + // it to the modern v9 shape later. Imports come from `/legacy`. + import { useLegacyTable, getCoreRowModel, legacyCreateColumnHelper } + from '@tanstack/react-table/legacy' + const legacyHelper = legacyCreateColumnHelper() + const legacyTable = useLegacyTable({ + columns, data, + getCoreRowModel: getCoreRowModel(), + }) + failure_modes: + - Importing `useReactTable` / `createAngularTable` / `createSolidTable` (v8 names). All adapters consolidated + to `useTable` / `injectTable` / `createTable` in v9. The v8 imports will not exist and will fail at + module-resolution time. + - 'Passing `getCoreRowModel: getCoreRowModel()` / `getSortedRowModel: getSortedRowModel()` as root options. + These were removed — sortedRowModel/filteredRowModel/etc. now live under `_rowModels` and are produced + by `createSortedRowModel(sortFns)`-style factories. Core is automatic.' + - Calling `createSortedRowModel()` without passing `sortFns`. The factories now REQUIRE their *Fns parameter + for tree-shaking — `createFilteredRowModel(filterFns)`, `createSortedRowModel(sortFns)`, `createGroupedRowModel(aggregationFns)`. + - "Omitting `_features` entirely. The option is required even for a no-features table — pass `_features: + tableFeatures({})` or `_features: stockFeatures` if you want v8-like 'everything on'." + - Calling `createColumnHelper()` (v8 arity). v9 requires `createColumnHelper()`. `typeof _features` is the standard idiom — declare features once at module scope and reuse + the type. + - Reading state via `table.getState()`. The method was removed. Use `table.store.state` for a flat snapshot, + `table.state` if you passed a `useTable` selector, or `table.atoms..get()` for a single slice. + - 'Renaming misses: `sortingFn` (column def) → `sortFn`, `sortingFns` → `sortFns`, `getSortingFn()` + → `getSortFn()`, `getAutoSortingFn()` → `getAutoSortFn()`, `SortingFn`/`SortingFns` types → `SortFn`/`SortFns`. + TypeScript will surface these but the agent will try the v8 names first.' + - 'Using `enablePinning: true`. It was split into `enableColumnPinning` and `enableRowPinning`. Pick + one (or both).' + - 'Treating column resizing as part of column sizing. v9 splits them: pass `columnSizingFeature` for + fixed widths and ALSO `columnResizingFeature` for drag-to-resize. State key `columnSizingInfo` is + now `columnResizing`; option `onColumnSizingInfoChange` is `onColumnResizingChange`.' + - 'Calling underscore-prefixed APIs: `row._getAllCellsByColumnId()`, `table._getFacetedRowModel()`, + `table._getFacetedMinMaxValues()`, `table._getFacetedUniqueValues()`, `table._getPinnedRows()`. All + became public — drop the underscore.' + - "`declare module '@tanstack/react-table' { interface ColumnMeta }` — v9 added `TFeatures` + as the first generic, so this becomes `ColumnMeta`. Module augmentation + silently widens types if the generic arity is wrong." + - Reaching for `useLegacyTable` on NEW code or in Angular. It's React-only, deprecated, bundles every + feature, and doesn't support `table.Subscribe`. It exists to unblock incremental migration, not to + be a long-term API. + - mistake: TS error "Property filterFns is missing" using useReactTable + mechanism: | + The `useReactTable` generic signature requires the filter functions registry to + be declared via `declare module` augmentation, otherwise `filterFns` appears + "required" in the props object. Without the augmentation in their project, + users with strict TS get red squigglies on a basic 3-prop call. + wrong_pattern: | + // ❌ TS error: Property 'filterFns' is missing in type ... + const table = useReactTable({ + data, columns, + getCoreRowModel: getCoreRowModel(), + }) + correct_pattern: | + // ✅ Either (a) cast the column types so the inferred constraint relaxes: + const columns: ColumnDef[] = [/* ... */] + // OR (b) augment the module to register custom filterFns: + declare module '@tanstack/react-table' { + interface FilterFns { myFn: FilterFn } + } + source: + https://github.com/TanStack/table/issues/5005 (Property filterFns is missing using useReactTable, + 14 comments) + priority: HIGH + status: active + version_context: v8 — long-standing top question; v9 cleans this up via tableFeatures registry + skills: + - migrate-v8-to-v9 + - setup + - getting-started + - mistake: Using useReactTable in v9 alpha when useTable is the new entry point + mechanism: | + v9 alpha renames `useReactTable` → `useTable` (and adds a `useLegacyTable` shim). + Agents trained on v8 will reach for `useReactTable`. The signature also changes — + `_features` and `_rowModels` are now mandatory configuration, not pre-bundled + defaults. `useReactTable` still exists for back-compat but produces v8-style + tables that don't have `.Subscribe`, `.atoms`, or feature composition. + wrong_pattern: | + // ❌ v8 entry point — won't have v9 Subscribe / atoms APIs + import { useReactTable, getCoreRowModel } from '@tanstack/react-table' + const table = useReactTable({ + data, columns, + getCoreRowModel: getCoreRowModel(), + }) + correct_pattern: | + // ✅ v9 entry — explicit features + row models + import { + useTable, tableFeatures, + sortingFeature, columnFilteringFeature, rowPaginationFeature, + } from '@tanstack/react-table' + const _features = tableFeatures({ + sortingFeature, columnFilteringFeature, rowPaginationFeature, + }) + const table = useTable({ _features, data, columns }) + source: 'PR #6202 (fix useLegacyTable infinite rerender), useTable.ts source, CHANGELOG history' + priority: CRITICAL + status: active + version_context: v9 alpha rename; v8 patterns dominate training data + - mistake: Importing pre-bundled getCoreRowModel etc. in v9 + mechanism: | + In v8, `getCoreRowModel()`, `getSortedRowModel()`, `getFilteredRowModel()` etc. + were standalone factories. In v9, row models are opt-in through `_rowModels` on + the table options or auto-installed by features in `tableFeatures()`. Agents + will keep emitting v8 imports that either don't exist or aren't wired correctly. + wrong_pattern: | + // ❌ v8 pattern — won't drive v9 row models + const table = useTable({ + _features, + data, columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + correct_pattern: | + // ✅ v9 — row models opt-in via _rowModels or feature inclusion + import { + useTable, tableFeatures, + sortingFeature, coreRowModelsFeature, sortedRowModelsFeature, + } from '@tanstack/react-table' + const _features = tableFeatures({ + sortingFeature, + coreRowModelsFeature, + sortedRowModelsFeature, + }) + const table = useTable({ _features, data, columns }) + source: 'PR #6234 (atoms refactor), v9 alpha package exports' + priority: CRITICAL + status: active + version_context: v9 alpha — fundamental restructure of the API surface + skills: + - migrate-v8-to-v9 + - customizing-feature-behavior + - mistake: data/columns are mutable in v8 but readonly in v9 + mechanism: | + PR #6183 makes `data` and `columns` `readonly` in v9 to force users to flow + changes through React state rather than mutating arrays in place. Code that + pushes to `data.push(...)` or splices columns will break at the TypeScript + level on v9, but agents will produce v8-style mutation patterns. + wrong_pattern: | + // ❌ v8 pattern, breaks at TS layer in v9 + const data = [] + function addRow(row) { data.push(row); rerender() } + correct_pattern: | + // ✅ Use React state / setState + const [data, setData] = useState([]) + function addRow(row) { setData(prev => [...prev, row]) } + source: 'PR #6183 (make data and columns readonly)' + priority: MEDIUM + status: active + version_context: v9 alpha + skills: + - migrate-v8-to-v9 + - setup + - mistake: Hallucinating react-table v7 / pre-v9 @tanstack/[framework]-table APIs + mechanism: + Every major release of TanStack Table (formerly react-table) has been a substantial upgrade. + Agents trained on older data confidently produce v7 or v8 API shapes that no longer exist in v9 + (e.g. `useReactTable`, inline `getCoreRowModel()` as an option, `useTable` from react-table v7). + wrong_pattern: |- + // ❌ v7 / v8 patterns the agent invents + import { useTable, useSortBy } from 'react-table' // v7 + const table = useTable({ columns, data }, useSortBy) + + // or v8 + import { useReactTable, getCoreRowModel } from '@tanstack/react-table' + const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }) + correct_pattern: |- + // ✅ v9 pattern — `_features` + `_rowModels` + import { useTable, tableFeatures, rowSortingFeature, createSortedRowModel, sortFns } from '@tanstack/react-table' + const _features = tableFeatures({ rowSortingFeature }) + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, data, + }) + priority: CRITICAL + version_context: + v7→v8 and v8→v9 both shifted the API substantially; agents trained on any pre-v9 + data will produce wrong shapes. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - slug: client-to-server + type: lifecycle + domain: lifecycle + status: ready + description: | + Convert a client-side table to server-side (a.k.a. manual modes). Pattern: pass server data, set `manualSorting` / `manualFiltering` / `manualPagination` / `manualGrouping` / `manualExpanding` for whatever the server now owns, supply `rowCount` (and/or `pageCount`) so the table can paginate without seeing all rows, and remove the corresponding `_rowModels` entries (you don't need `paginatedRowModel` if pagination is server-side). State for the server-managed slices is best owned EITHER via external TanStack Store atoms (passed via `options.atoms`) OR via classic `state` + `on*Change` controlled state — both work. With Query, the atom pattern lets you key your query on the atom and let the table write directly to it without `on*Change` plumbing. + covers: + - Setting `manualSorting`, `manualFiltering`, `manualPagination`, `manualGrouping`, `manualExpanding` + so the table stops trying to slice rows it doesn't have. + - Removing the matching `_rowModels` keys so unused factories don't ship (don't register `paginatedRowModel` + if the server paginates). + - Providing `rowCount` (and optionally `pageCount`) so `table.getPageCount()` / `getCanNextPage()` work + without local rows. + - 'State ownership: external atoms via `useCreateAtom` + `options.atoms` (cleanest with Query, no `on*Change` + needed) OR classic `state` + `on*Change` controlled state. Both are documented patterns.' + - 'Cache key strategy: include the relevant atom value (or controlled state) in `queryKey`. Use `placeholderData: + keepPreviousData` to avoid the "0 rows flash" while paging.' + - "Invalidation: call `queryClient.invalidateQueries({ queryKey: [...] })` on writes — TanStack Table + doesn't invalidate for you." + packages: + - '@tanstack/react-table' + - '@tanstack/vue-table' + - '@tanstack/angular-table' + - '@tanstack/solid-table' + - '@tanstack/svelte-table' + - '@tanstack/preact-table' + tasks: + - Convert a sorting+pagination table that operates on a 50k local array to one that requests `/api/people?pageIndex=&pageSize=&sort=` + and renders the response. + - Wire pagination via an external TanStack Store atom (`useCreateAtom(...)`) + the + `atoms` option so the query auto-refetches without an explicit `onPaginationChange` handler. + - Wire the same conversion via classic `state` + `onPaginationChange` controlled state when you already + have signal/useState plumbing. + - Combine server-side sort + pagination + filter while keeping client-side column visibility / ordering + / pinning — those features still work locally because they don't depend on the row model. + correct_pattern: | + // React + TanStack Query + external pagination atom + import { useTable, tableFeatures, rowPaginationFeature, createColumnHelper } + from '@tanstack/react-table' + import { useQuery, keepPreviousData } from '@tanstack/react-query' + import { useCreateAtom, useSelector } from '@tanstack/react-store' + import type { PaginationState } from '@tanstack/react-table' + + const _features = tableFeatures({ rowPaginationFeature }) + + function ServerTable({ columns }) { + // 1) Own pagination in an external atom. + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + const pagination = useSelector(paginationAtom) + + // 2) Key the query on the atom value. Query refetches automatically + // when the table calls table.setPageIndex(...) / setPageSize(...) + // because those writes flow into the atom. + const dataQuery = useQuery({ + queryKey: ['people', pagination], + queryFn: () => fetchPeople(pagination), + placeholderData: keepPreviousData, + }) + + // 3) Manual pagination + `rowCount` for getPageCount(). Note: no + // `paginatedRowModel` in `_rowModels` — server paginates. + const table = useTable({ + _features, + _rowModels: {}, // core only + columns, + data: dataQuery.data?.rows ?? [], + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, // own the slice externally + manualPagination: true, + }) + // No onPaginationChange needed — writes go to paginationAtom. + // ... + } + wrong_pattern: | + // BAD #1: kept `paginatedRowModel` even though the server paginates. + // This makes the table try to slice the (already-sliced) `data` array + // a second time, producing rows that don't exist. + const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: serverPage.rows, + // missing: manualPagination + rowCount + }) + + // BAD #2: forgot `rowCount`. `table.getPageCount()` returns + // `Math.ceil(rows.length / pageSize)` which is the page size, not the + // total — the pager shows "Page 1 of 1" forever. + const table = useTable({ + _features, + _rowModels: {}, + columns, + data: serverPage.rows, + manualPagination: true, + // missing: rowCount: serverPage.totalRowCount + }) + + // BAD #3: keyed Query on the wrong thing. `queryKey: ['people']` never + // changes, so pagination button clicks update the atom but Query + // doesn't refetch — the table stays on page 1's data. + useQuery({ queryKey: ['people'], queryFn: () => fetch(pagination) }) + + // BAD #4: classic controlled state but the table writes go nowhere + // because there's no `on*Change`. Either pass `atoms` OR pass + // `state + on*Change`. Don't pass `state` without `on*Change`. + const [pagination, setPagination] = useState({...}) + useTable({ + _features, _rowModels: {}, columns, data, + state: { pagination }, + // missing: onPaginationChange: setPagination + manualPagination: true, + }) + failure_modes: + - 'Forgetting `manualPagination: true` (or `manualSorting` / `manualFiltering`). The table will re-sort/re-filter/re-paginate + the already-server-processed rows, producing visually incorrect or duplicated results.' + - 'Leaving `paginatedRowModel: createPaginatedRowModel()` registered when the server paginates. The + factory ships in your bundle for no reason AND the table slices server-sliced data. Drop it from `_rowModels`.' + - Omitting `rowCount`. Without it, `getPageCount()` is computed from `data.length / pageSize` — which + equals 1 if the server returned a single page. Pager UIs lock at 'Page 1 of 1'. + - Passing `state.pagination` without `onPaginationChange`. The library treats `state` as controlled; + without a writeback handler, `table.setPageIndex(2)` is a no-op. Either pass both, or pass `atoms.pagination` + (which is its own writeback). + - Mixing `state.pagination` AND `atoms.pagination` for the SAME slice. Precedence is `options.atoms[key]` + > `options.state[key]` > internal — `state` is silently ignored. Pick one mechanism per slice. + - Not including pagination/sort/filter in the Query `queryKey`. The query won't refetch when the user + pages or sorts because Query has no way to know its inputs changed. + - 'Forgetting `placeholderData: keepPreviousData` (or equivalent) when paging. The table renders zero + rows during the fetch, which collapses the height and jumps the scroll position.' + - slug: production-readiness + type: lifecycle + domain: lifecycle + status: ready + description: | + Ship-ready optimizations for v9: tree-shake the bundle by registering ONLY the `_features` you actually use; memoize `_features`, `data`, and `columns` for stable identity; replace `(state) => state` with narrow selectors or per-slice `useSelector(table.atoms.)` subscriptions; and push state-driven re-renders down the tree with `` (or the standalone `` import) so the expensive table body doesn't re-render every time you toggle a sort indicator. Production builds of the table devtools are also a `/production` import flip — see `compose-with-tanstack-devtools`. + covers: + - "Tree-shaking: list features explicitly in `tableFeatures({...})` rather than `stockFeatures`. A sort-only + table is ~6–7kb vs ~15–20kb for v8's all-in approach." + - 'Reference stability: `_features`, `_rowModels`, `columns`, and `data` should NOT be redefined on + every render. Declare at module scope or memoize with `useMemo` / Angular `signal()` / Solid memo.' + - "Default selector trade-off: `(state) => state` matches v8 behavior but forces a re-render on any + state change. Narrow it (`(state) => ({ sorting: state.sorting })`) once you've profiled." + - '`` (or ``) for moving the re-render + boundary down to a sub-tree.' + - 'Per-slice atom subscriptions: `useSelector(table.atoms.pagination)` in a leaf component re-renders + only when pagination changes — narrower than `table.Subscribe`, which still selects from the full + state object.' + - "React Compiler interaction: `` is the React-Compiler-safe way to read table state in nested + components — the compiler can't see through the table closure on its own." + - 'Angular: prefer `computed(() => table.atoms..get(), { equal: shallow })` to debounce object-identity + re-runs.' + - "Stress patterns: virtualize rows/columns with TanStack Virtual (see `compose-with-tanstack-virtual`), + keep the row virtualizer in the deepest possible component, and use `Subscribe` walls around column + DnD / pinning UIs that don't need to participate in row updates." + packages: + - '@tanstack/react-table' + - '@tanstack/vue-table' + - '@tanstack/angular-table' + - '@tanstack/solid-table' + - '@tanstack/svelte-table' + - '@tanstack/preact-table' + tasks: + - Audit a project's `_features` declarations and remove any feature whose imports aren't actually wired + up in column defs / state. + - Replace `useTable(opts, (state) => state)` with a narrowed selector when only a subset of state is + rendered at the table level. + - 'Wrap a noisy pagination footer (re-renders on every keystroke in a filter) in ` ({ pagination: s.pagination })}>` so it only re-renders on pagination changes.' + - Move a `RowSelectionCount` component from reading `table.state.rowSelection` to `useSelector(table.atoms.rowSelection)` + for the narrowest subscription surface. + correct_pattern: | + // === Tree-shake by feature === + import { + useTable, + tableFeatures, + rowSortingFeature, // ✓ ship sort code + rowPaginationFeature, // ✓ ship pagination code + createSortedRowModel, + createPaginatedRowModel, + sortFns, + } from '@tanstack/react-table' + + // Module-scope, stable identity: + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + const _rowModels = { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + } + + function MyTable({ data, columns }) { + // ✓ narrow selector — re-renders only when these slices change + const table = useTable( + { _features, _rowModels, columns, data }, + (state) => ({ + sorting: state.sorting, + pagination: state.pagination, + }), + ) + + return ( + <> + {/* heavy — keep stable */} + {/* ✓ Re-render wall: footer only re-renders on pagination changes, + not on sort changes that affect TableBody */} + ({ pagination: s.pagination })}> + {({ pagination }) => } + + + ) + } + + // === Narrowest subscription — per-slice atom === + import { useSelector } from '@tanstack/react-store' + + function SelectedCount({ table }) { + // re-renders ONLY when rowSelection changes, not sorting/pagination/etc. + const selection = useSelector(table.atoms.rowSelection) + return {Object.keys(selection).length} selected + } + wrong_pattern: | + // BAD #1: stockFeatures in a sort-only table. + // Ships filtering, faceting, grouping, pinning, expanding, sizing, + // resizing, visibility, ordering, row-selection, row-pinning code + // none of which is used. ~3x the necessary bundle for `_features`. + import { useTable, stockFeatures } from '@tanstack/react-table' + const table = useTable({ _features: stockFeatures, /* ... */ }) + + // BAD #2: unstable references — every render is a new identity. + function MyTable({ data, columns }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const opts = { // new every render + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, // hopefully stable + data, + } + const table = useTable(opts) + // ... + } + + // BAD #3: default selector + deep re-render tree. + function MyTable() { + // (state) => state means every keystroke in a filter input re-renders + // every cell in the body unless you memo manually. + const table = useTable(opts) // implicit (state) => state + return + } + + // BAD #4: reading state in a way React Compiler can't track. + function PageFooter({ table }) { + // The compiler can't reason through the table closure — it may either + // bail out of optimization OR cache a stale read. + const { pagination } = table.state + return
{pagination.pageIndex + 1}
+ // ✓ Fix: s.pagination}> + // or useSelector(table.atoms.pagination). + } + failure_modes: + - Using `stockFeatures` in production without auditing which features are actually rendered. Defeats + the whole reason tree-shaking exists in v9. Replace with an explicit `tableFeatures({...})` listing + only what your UI uses. + - Declaring `_features`, `_rowModels`, or `columns` inside the component body without `useMemo`. Internal + memoization keys off identity, so a new object every render forces full recomputation. + - Putting `data` in `useState` and never memoizing the array literal that produces it. `setData(rows.filter(...))` + returns a new array each call — that's fine; the FAQ pitfall is `data={[]}` or `data={rows ?? []}` + in JSX, which creates a new identity each render. + - Leaving `(state) => state` as the selector when only one component cares about one slice. The whole + tree re-renders on every state change. Narrow the selector and/or use `` boundaries. + - Forgetting that `` still selects from `table.store.state` (the full state). For a + single-slice subscription, `useSelector(table.atoms.)` is narrower and skips even constructing + a state snapshot. + - Hoisting heavy table state reads above virtualizers. The TanStack Virtual rule of thumb is to keep + `useVirtualizer` in the deepest component possible; pulling `table.state` into a parent re-renders + the virtualizer and kills scroll perf. + - Reading `table.state` in deeply-nested React-Compiler-compiled components instead of `` + or `useSelector`. The compiler doesn't see through the table closure, so it can't determine the dependency + edges correctly. + - mistake: Next.js warns about Date.now() in client component + mechanism: | + `table-core` uses `Date.now()` inside `getMemoOptions` for cache freshness tracking. + Next.js (v16+) flags this in client components as a hydration risk because server + and client times differ. The warning is inert at runtime but noisy. Workaround is + pending a switch to `performance.now()`. + wrong_pattern: | + // ❌ Next.js dev warnings on basic v8 setup + 'use client' + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + correct_pattern: | + // ✅ No code-level fix yet — track issue #6127 / wait for upstream patch. + // Workaround: silence the rule for the table file or move table construction + // into a custom hook to localize. + source: + 'https://github.com/TanStack/table/issues/6127 (DX: Next.JS complain about using Date.now() + inside a Client Component)' + priority: LOW + status: active + version_context: v8.21.3 + Next 16 + skills: + - production-readiness + - setup + - mistake: Bundling stockFeatures or all features when only a few are used + mechanism: + TanStack Table v9 is tree-shakeable specifically so you only pay for features you use. + Registering `stockFeatures` (or every feature "just in case") forfeits the bundle benefit that motivated + the v9 redesign. + wrong_pattern: |- + // ❌ Pulls in every feature even though only sorting+pagination are used + import { stockFeatures, tableFeatures } from '@tanstack/react-table' + const _features = tableFeatures(stockFeatures) + correct_pattern: |- + // ✅ Register only what this table uses + import { tableFeatures, rowSortingFeature, rowPaginationFeature } from '@tanstack/react-table' + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + priority: HIGH + version_context: v9. Tree-shaking via `_features` is one of the headline reasons for the rewrite. + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - mistake: Premature Subscribe / custom selector optimization on small tables + mechanism: + Subscribe and narrow selectors are designed for large or expensive tables where full re-renders + measurably hurt. On small tables (a few hundred rows, simple features), default selector + inline + rendering is fine and the Subscribe boundaries add complexity without measurable benefit. + wrong_pattern: |- + // ❌ Wraps every header and cell in Subscribe on a 50-row table + header: ({ table }) => ( + s.sorting}> + {() => } + + ) + correct_pattern: |- + // ✅ Default useTable selector, inline rendering — Subscribe only when measured perf demands it + const table = useTable({ _features, _rowModels, columns, data }) + // reach for Subscribe later, scoped to the actual hotspot + priority: MEDIUM + version_context: 'Maintainer guidance: advanced state-management patterns are for advanced cases.' + source: maintainer interview (Phase 4, 2026-05-17) + status: active + - slug: compose-with-tanstack-store + type: composition + domain: composition + status: ready + description: | + v9 is built on TanStack Store. Each state slice (sorting, pagination, rowSelection, columnFilters, ...) is a separate atom. The table exposes three read surfaces — `table.atoms.` (per-slice readonly), `table.store` (flat readonly view), and `table.state` (selector output from `useTable`) — and two write paths — internal `table.baseAtoms.` OR YOUR `options.atoms[slice]` if you opt to own the slice. The `atoms` option lets a slice be shared across components, persisted, or wired to a non-table subscriber. Use `useCreateAtom` to create the atom in React; `useSelector` to read it in any component without going through the table; the table writes to your atom directly when you call `table.setSorting(...)` etc. + covers: + - "The three read surfaces and what they're for: `table.atoms.` (narrowest), `table.store.state` + (full snapshot), `table.state` (typed selector output)." + - 'Precedence: `options.atoms[key]` > `options.state[key]` > internal `baseAtoms[key]`. Mixing `state` + + `atoms` for the same slice — `atoms` wins and `state` is ignored.' + - 'When external atoms beat `state` + `on*Change`: cross-component subscriptions, persistence (writing + the atom to localStorage from outside the table), atom-based integrations, sharing one slice across + multiple tables.' + - '`useCreateAtom(initial)` for stable atom identity inside a React component (replacement + for the manual `useRef(createAtom(...))` pattern).' + - '`useSelector(table.atoms.)` / `useSelector(myAtom)` for fine-grained reactivity.' + - '`table.reset()` does NOT clear external atoms — you own them, so you reset them yourself with `myAtom.set(default)`.' + - 'Angular: atoms are signal-backed under the hood. Read them through `computed(() => table.atoms..get(), + { equal: shallow })`.' + packages: + - '@tanstack/react-table' + - '@tanstack/vue-table' + - '@tanstack/angular-table' + - '@tanstack/solid-table' + - '@tanstack/svelte-table' + - '@tanstack/preact-table' + - '@tanstack/store' + - '@tanstack/react-store' + tasks: + - Move a row-selection slice to an external `useCreateAtom({})` and pass it through + `options.atoms.rowSelection` so a sibling sidebar can `useSelector` the same atom. + - Replace classic `state.sorting` + `onSortingChange` controlled state with an external `sortingAtom` + for atom-based ergonomics — the table writes to the atom directly, no `on*Change` plumbing. + - Persist a column-visibility slice by subscribing the atom to localStorage outside the table render + path. + - Read `table.atoms.pagination` in a deeply-nested footer with `useSelector` so the rest of the table + doesn't re-render on page changes. + correct_pattern: | + // External atoms for sorting + pagination — owned by you, shared anywhere. + import { useCreateAtom, useSelector } from '@tanstack/react-store' + import { + useTable, tableFeatures, rowSortingFeature, rowPaginationFeature, + createSortedRowModel, createPaginatedRowModel, sortFns, + } from '@tanstack/react-table' + import type { PaginationState, SortingState } from '@tanstack/react-table' + + const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + + function MyTable({ columns, data }) { + const sortingAtom = useCreateAtom([]) + const paginationAtom = useCreateAtom({ + pageIndex: 0, pageSize: 10, + }) + + // Fine-grained reads — these components re-render independently. + const sorting = useSelector(sortingAtom) + const pagination = useSelector(paginationAtom) + + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + }, + // NOTE: no onSortingChange / onPaginationChange needed — + // table.setSorting / table.setPageIndex write through to the atoms. + }) + + // Reset is your job for owned atoms: + const resetMyState = () => { + sortingAtom.set([]) + paginationAtom.set({ pageIndex: 0, pageSize: 10 }) + } + } + wrong_pattern: | + // BAD #1: passing the SAME slice via both `state` and `atoms`. Atoms + // win silently and the `state` value is ignored — confusing to debug. + useTable({ + _features, _rowModels: {}, + columns, data, + state: { sorting: localSorting }, // ignored + onSortingChange: setLocalSorting, // ignored + atoms: { sorting: sortingAtom }, // wins + }) + + // BAD #2: creating the atom inside render without `useCreateAtom`. + // A new atom every render → the table re-binds every render → state + // resets to initial. (The same trap as `useState(createAtom(...))`.) + function MyTable() { + const sortingAtom = createAtom([]) // wrong: unstable + useTable({ _features, _rowModels: {}, columns, data, + atoms: { sorting: sortingAtom } }) + } + + // BAD #3: expecting `table.reset()` to clear an external atom. It + // doesn't — you own the atom. Call sortingAtom.set([]) yourself. + + // ✓ Fix: + // ✓ Fix: `, +}) +export class CellActionsComponent { + readonly cell = injectTableCellContext() +} +``` + +Each directive uses Angular's `providers` array to register a per-host factory, +so contexts are correctly scoped — multiple `[tanStackTableCell]` directives on +sibling cells provide independent values. + +--- + +## `*flexRender` directly (custom props) + +Reach for the long form `*flexRender` when: + +- You're rendering something that isn't `columnDef.cell` / `header` / `footer` + (e.g. a registered `headerComponents` member, see `createTableHook`). +- You want to override the props passed to the render function. + +```html + + {{ rendered }} + +``` + +Custom props (e.g. extending the default context): + +```html + + {{ rendered }} + +``` + +Inside the rendered component, `injectFlexRenderContext()` returns this full +props object. + +You can also pass `flexRenderInjector:` to override the injector used for +`createComponent`. + +--- + +## Render-function injection context + +Because column-def `cell` / `header` / `footer` functions run inside +`runInInjectionContext`, this **works** at the top of those functions: + +```ts +cell: ({ getValue }) => { + const router = inject(Router) // ✅ legal + return router.url.endsWith('/admin') ? `[admin] ${getValue()}` : getValue() +} +``` + +Be deliberate: this runs every time the cell renders. For per-app values that +don't change, prefer `inject(...)` at the component level and close over. diff --git a/packages/angular-table/skills/angular/angular-rendering-directives/references/flex-render-component-options.md b/packages/angular-table/skills/angular/angular-rendering-directives/references/flex-render-component-options.md new file mode 100644 index 0000000000..59299caac0 --- /dev/null +++ b/packages/angular-table/skills/angular/angular-rendering-directives/references/flex-render-component-options.md @@ -0,0 +1,64 @@ +# `flexRenderComponent` — Full Options Reference + +When you need custom inputs not derived from the render context, output +callbacks, a custom injector, or Angular v20+ `bindings` / `directives`, +**wrap the component**: + +```ts +import { flexRenderComponent, type ColumnDef } from '@tanstack/angular-table' +import { EditableCell } from './editable-cell' + +const columns: Array> = [ + { + accessorKey: 'firstName', + cell: ({ getValue, row, column, table }) => + flexRenderComponent(EditableCell, { + inputs: { + value: getValue(), + }, + outputs: { + change: (value) => { + table.options.meta?.updateData(row.index, column.id, value) + }, + }, + }), + }, +] +``` + +## How inputs/outputs are wired + +- **`inputs`** → applied via `ComponentRef.setInput(key, value)` (works with + both `input()` signals and legacy `@Input()`). Diffed per change-detection + cycle with `KeyValueDiffers` — unchanged values are _not_ re-set, so object + inputs are reference-checked. Keep input objects referentially stable when + you can. +- **`outputs`** → resolved at component-instance level. The wrapper reads the + property by name, checks it is an `OutputEmitterRef`, and subscribes. The + subscription is cleaned up when the component is destroyed. +- **`injector`** → use when the rendered component needs to inject from a + specific scope (e.g. a feature module / sub-injector). +- **`bindings`** (Angular v20+) → forwarded directly to + `ViewContainerRef.createComponent` at creation time. Use this with + `inputBinding`, `outputBinding`, `twoWayBinding` for native programmatic + rendering semantics. +- **`directives`** (Angular v20+) → host directives forwarded the same way. + +```ts +import { inputBinding, outputBinding, twoWayBinding, signal } from '@angular/core' + +readonly name = signal('Ada') + +cell: () => + flexRenderComponent(EditableNameCell, { + bindings: [ + inputBinding('value', this.name), + twoWayBinding('value', this.name), + outputBinding('valueChange', (v) => console.log('changed', v)), + ], + }) +``` + +> **Do not mix `bindings` with `inputs`/`outputs`** on the same component. +> `bindings` apply at creation time and participate in the initial CD cycle; +> `inputs`/`outputs` apply after. Mixing them risks double-initialization. diff --git a/packages/angular-table/skills/angular/client-to-server/SKILL.md b/packages/angular-table/skills/angular/client-to-server/SKILL.md new file mode 100644 index 0000000000..58cf528b59 --- /dev/null +++ b/packages/angular-table/skills/angular/client-to-server/SKILL.md @@ -0,0 +1,467 @@ +--- +name: angular/client-to-server +description: > + Convert an Angular Table v9 from client-side to server-side processing. Flip + `manualPagination` / `manualSorting` / `manualFiltering` / `manualGrouping` / `manualExpanding` + for the slices the server now owns; drop the corresponding `_rowModels` row-model factories the + server replaces; supply `rowCount` (server total) so pagination computes correctly; hoist + `pagination` / `sorting` / `columnFilters` / `globalFilter` to Angular signals with `state` + + `on[State]Change`; fetch via `rxResource` / `httpResource` / `@tanstack/angular-query`; preserve + previous data on refetch with `linkedSignal` (or `placeholderData: keepPreviousData` for Query); + set `getRowId` for stable selection across refetches. +type: lifecycle +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/getting-started + - filtering + - sorting + - pagination +sources: + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:docs/framework/angular/guide/migrating.md + - TanStack/table:examples/angular/remote-data/ + - TanStack/table:packages/angular-table/src/injectTable.ts +--- + +# Client → Server Conversion (Angular Table v9) + +> Goal: take a working client-side Angular Table v9 and migrate it to server-driven +> processing for one or more of pagination / sorting / filtering / grouping / +> expanding — without rewriting your row markup, columns, or feature surface. +> +> The canonical Angular example is `examples/angular/remote-data/`, using +> `rxResource` + `linkedSignal`. The same pattern composes with +> `@tanstack/angular-query` (see `compose-with-tanstack-query`). + +--- + +## 1. The 5-step recipe + +For each slice the server now owns: + +1. **Flip `manualX: true`** in table options. This tells the table "don't + process this on the client — trust the data you receive." +2. **Drop the matching client-side row-model factory** from `_rowModels` + (or keep it if you still want the feature's _state_ but no client + recomputation — see §3). +3. **Hoist the slice to an Angular signal**, control it via `state.x` + + `on[State]Change`. Server requests must depend on the signal. +4. **Pass `rowCount`** (the server's total) so `getPageCount()`, + `getCanNextPage()`, etc. compute correctly under `manualPagination`. +5. **Set `getRowId`** so row selection (and refetch identity) survives across + server refetches. + +Plus: keep previous data visible during refetches (avoid a "0-rows flash") with +`linkedSignal`, `placeholderData: keepPreviousData` (Query), or +`previousValue:` (`httpResource`). + +--- + +## 2. The `manualX` matrix + +| Slice | Option | Client row-model needed? | Notes | +| -------------- | ------------------------ | ------------------------ | -------------------------------------------------------------- | +| Pagination | `manualPagination: true` | drop `paginatedRowModel` | also pass `rowCount: ` | +| Sorting | `manualSorting: true` | drop `sortedRowModel` | feature still controls `sorting` state | +| Column filters | `manualFiltering: true` | drop `filteredRowModel` | also affects global filter when sharing the filtered row model | +| Global filter | `manualFiltering: true` | drop `filteredRowModel` | global filter shares the filtered row model | +| Grouping | `manualGrouping: true` | drop `groupedRowModel` | rare — most servers don't return grouped trees | +| Expanding | `manualExpanding: true` | drop `expandedRowModel` | server returns sub-rows pre-expanded | + +Selection, visibility, ordering, pinning, sizing, resizing, row-pinning are +all UI-only state — they don't have manual modes. They keep working unchanged. + +--- + +## 3. Canonical example — pagination + sorting + global filter + +The `examples/angular/remote-data/` pattern, condensed: + +```ts +import { + ChangeDetectionStrategy, + Component, + inject, + linkedSignal, + signal, +} from '@angular/core' +import { rxResource } from '@angular/core/rxjs-interop' +import { HttpClient, HttpParams } from '@angular/common/http' +import { map } from 'rxjs' +import { + FlexRender, + injectTable, + tableFeatures, + rowPaginationFeature, + rowSortingFeature, + globalFilteringFeature, + createColumnHelper, + type ColumnDef, + type PaginationState, + type SortingState, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + globalFilteringFeature, +}) + +const columnHelper = createColumnHelper() + +type TodoResponse = { items: Array; totalCount: number } + +@Component({ + selector: 'app-root', + imports: [FlexRender], + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + private readonly http = inject(HttpClient) + + // 1. Hoist controlled slices to signals + readonly pagination = signal({ pageIndex: 0, pageSize: 10 }) + readonly sorting = signal([{ id: 'id', desc: false }]) + readonly globalFilter = signal(null) + + // 2. Fetch — server query depends on those signals + private readonly data = rxResource({ + params: () => ({ + page: this.pagination(), + sorting: this.sorting(), + globalFilter: this.globalFilter(), + }), + stream: ({ params: { page, sorting, globalFilter } }) => { + let params = new HttpParams({ + fromObject: { + _page: page.pageIndex + 1, + _limit: page.pageSize, + }, + }) + if (globalFilter) params = params.set('title_like', globalFilter) + if (sorting.length) { + params = params + .set('_sort', sorting.map((s) => s.id).join(',')) + .set( + '_order', + sorting.map((s) => (s.desc ? 'desc' : 'asc')).join(','), + ) + } + return this.http + .get>('https://jsonplaceholder.typicode.com/todos', { + params, + observe: 'response', + }) + .pipe( + map( + (res) => + ({ + items: res.body ?? [], + totalCount: Number(res.headers.get('X-Total-Count')), + }) satisfies TodoResponse, + ), + ) + }, + }) + + // 3. Keep previous page visible during refetch — no "0 rows" flash + readonly dataWithLatest = linkedSignal< + { + value: TodoResponse | undefined + status: 'idle' | 'loading' | 'resolved' | 'error' + }, + TodoResponse + >({ + source: () => ({ + value: this.data.value(), + status: this.data.status(), + }), + computation: (source, previous) => { + if (previous && source.status === 'loading') return previous.value + return source.value ?? { items: [], totalCount: 0 } + }, + }) + + readonly columns: Array> = [ + columnHelper.accessor('id', { header: 'Id', cell: (i) => i.getValue() }), + columnHelper.accessor('title', { + header: 'Title', + cell: (i) => i.getValue(), + }), + columnHelper.accessor('completed', { + header: 'Completed', + cell: (i) => (i.getValue() ? '✅' : '❌'), + }), + ] + + // 4. Wire the table — manualX flags, drop row-model factories, supply rowCount + readonly table = injectTable(() => { + const data = this.dataWithLatest() + return { + _features, + _rowModels: {}, // ← dropped paginatedRowModel, sortedRowModel, filteredRowModel + columns: this.columns, + data: data.items, + getRowId: (row) => String(row.id), + + // Controlled slices + state: { + pagination: this.pagination(), + sorting: this.sorting(), + globalFilter: this.globalFilter(), + }, + + // Manual modes + manualPagination: true, + manualSorting: true, + manualFiltering: true, + + // Server's truth about total row count + rowCount: data.totalCount, + + onPaginationChange: (u) => + typeof u === 'function' + ? this.pagination.update(u) + : this.pagination.set(u), + + onSortingChange: (u) => + typeof u === 'function' ? this.sorting.update(u) : this.sorting.set(u), + + // When filter changes, also reset page index + onGlobalFilterChange: (u) => { + typeof u === 'function' + ? this.globalFilter.update(u) + : this.globalFilter.set(u) + this.pagination.update((p) => ({ ...p, pageIndex: 0 })) + }, + } + }) +} +``` + +### What changed from the client-side version + +- `_rowModels: {}` — no `paginatedRowModel`, no `sortedRowModel`, no + `filteredRowModel`. The server is the source of truth. +- `manualPagination` / `manualSorting` / `manualFiltering: true`. +- `rowCount: data.totalCount` — required for correct `getPageCount()` and the + next/prev buttons. +- `state` + per-slice `on[State]Change` for everything the server reads. +- `getRowId` set so row selection survives refetch reorderings. +- `linkedSignal` keeps the previous response visible during loading — without + it, paginating yields a one-frame "no rows" flash because `data.value()` is + `undefined` mid-fetch. +- Resetting `pageIndex` on global-filter change is a UX rule, not framework + behavior — make it explicit. + +--- + +## 4. Wiring with `@tanstack/angular-query-experimental` + +For Query users, the equivalent of `linkedSignal` is +`placeholderData: keepPreviousData`. See `compose-with-tanstack-query` for the +full pattern. The table-side wiring (manual flags, dropped row models, +controlled signals, `rowCount`, `getRowId`) is identical. + +--- + +## 5. `rowCount` and friends + +Under `manualPagination: true`, the table no longer knows the total. You must +tell it: + +```ts +rowCount: data.totalCount // total rows server reports +// pageCount: 42 // can be passed instead, if your API gives pages not rows +``` + +If you omit both, `getPageCount()` returns `-1` and the "next page" button +never disables. If your API reports `pageCount` directly (rare), prefer +`pageCount` — otherwise compute it from `rowCount`. + +--- + +## 6. Always set `getRowId` when server-driven + +Without `getRowId`, row IDs default to row index. That works on the client +because order is stable per render. On the server, a refetch may return rows in +a different order — `RowSelectionState`, keyed by row ID, then targets the +wrong rows. + +```ts +getRowId: (row) => row.id +``` + +Required for: + +- `rowSelectionFeature` correctness across refetches +- pinned-row identity +- stable `track row.id` performance in `@for` + +--- + +## 7. Debouncing rapid input — global filter typing + +Naively, every keystroke triggers a server fetch. Two options: + +- **Manual signal indirection** — keep a `globalFilterInput` signal that the + UI writes to, then update `globalFilter` after a delay via `effect(...) + +setTimeout` or RxJS `debounceTime`. +- **Compose with `@tanstack/angular-pacer`** — see + `compose-with-tanstack-pacer` (not in this batch but on the roadmap). + +Resetting `pageIndex` to 0 when filter or sort changes is a UX standard: + +```ts +onGlobalFilterChange: (u) => { + typeof u === 'function' ? this.globalFilter.update(u) : this.globalFilter.set(u) + this.pagination.update((p) => ({ ...p, pageIndex: 0 })) +}, +``` + +--- + +## 8. Mixed mode — some slices server, others client + +Common pattern: pagination + sorting on the server, but row selection + +column visibility stay client-only. **Nothing special required** — only the +slices you mark `manualX` are server-driven. Selection / visibility / ordering +work unchanged. + +You can also keep client-side filtering on a column while paginating on the +server, but be wary: if rows are paginated server-side, you only have the +current page to filter against. Usually it's cleaner to flip all data-shape +slices to the server consistently. + +--- + +## 9. Resetting state on slice changes + +These behaviors are intentional and you'll often want to _override_ them when +server-driven: + +| Default | When server-driven | +| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `autoResetPageIndex: true` resets `pageIndex` to 0 when data identity changes | OK as-is — every fetch is a new array reference, so the page index resets unless you also pass `autoResetPageIndex: false` | +| Filter change does **not** auto-reset page | UX-standard to reset manually (see §7) | +| Sort change does **not** auto-reset page | Reset manually if your UX expects "new sort → page 1" | + +If your fetch always returns a fresh array, **set `autoResetPageIndex: false`** — +otherwise paginating to page 3 will reset back to page 0 the moment the new +data lands. The remote-data example demonstrates the alternative pattern for +edits (toggle the flag around the update). + +--- + +## Failure modes + +### 1. (CRITICAL) Flipping `manualPagination: true` but keeping + +`paginatedRowModel` in `_rowModels` + +The client row-model factory will re-paginate the (already-paginated) data, +chopping the visible rows down to the first `pageSize` of the page slice. +**Drop the factory** when going manual — or accept double-pagination. + +### 2. (CRITICAL) Forgetting `rowCount` under `manualPagination` + +`getPageCount()` returns `-1`, `getCanNextPage()` is `true` forever, +"page N of -1" appears in the UI. Always pass either `rowCount: serverTotal` +or `pageCount: serverPageCount`. + +### 3. (CRITICAL) Missing `getRowId` with selection + server refetches + +The row-selection state is keyed by row ID. With index-as-ID, refetches that +return rows in any new order (sort flip, page change) reselect the wrong +rows. Always set `getRowId: row => row.id` (or whatever your primary key is). + +### 4. (HIGH) "0 rows" flash between pages + +If your fetch resolves to `undefined` during loading, `data.items` becomes `[]` +mid-fetch — the table renders empty for a frame. Use `linkedSignal` (or +`@tanstack/query`'s `placeholderData: keepPreviousData`, or +`httpResource`'s previous-value semantics) to keep the previous page visible. + +### 5. (HIGH) Forgetting to handle both value AND updater-fn shapes in `on[State]Change` + +```ts +// ❌ Crashes when the table passes an updater function +onPaginationChange: (value) => this.pagination.set(value) + +// ✅ +onPaginationChange: (u) => + typeof u === 'function' ? this.pagination.update(u) : this.pagination.set(u) +``` + +### 6. (HIGH) `autoResetPageIndex` resetting your server pagination + +By default, when data identity changes, the table resets to page 0. Under +server-driven pagination, _every_ fetch is a new array, so the table resets +the page index back to 0 every time. Set `autoResetPageIndex: false` and +manage page resets explicitly (e.g. reset on filter/sort change, but not on +the fetch itself). + +### 7. (HIGH) Filtering on the client when only one page is loaded + +```ts +manualPagination: true, +// columnFilteringFeature still registered, filteredRowModel still attached +``` + +The filtered row model now filters only the _current page_ — useless. If the +server paginates, the server must also filter; flip `manualFiltering: true` +and drop the client `filteredRowModel`. + +### 8. (HIGH) Forgetting to depend on the controlled signals in your fetch + +If your `rxResource` / Query's `queryKey` doesn't read `pagination()`, +`sorting()`, `globalFilter()`, refetches won't happen. Both the table and the +fetcher must observe the same signals. + +### 9. (MEDIUM) Reimplementing pagination state with raw `pageIndex` / + +`pageSize` signals separate from the table + +```ts +// ❌ Two sources of truth +readonly pageIndex = signal(0) +readonly pageSize = signal(10) +// table doesn't know about either + +// ✅ +readonly pagination = signal({ pageIndex: 0, pageSize: 10 }) +state: { pagination: this.pagination() } +onPaginationChange: ... +``` + +Same lesson: use `setSorting`, not a manual sort signal that the table can't +see. + +### 10. (MEDIUM) Not resetting `pageIndex` on filter/sort change + +A common bug: user is on page 5, types in the filter, gets "no results" — but +the new filtered result set only has 2 pages. They have to manually click back +to page 1. Always reset `pageIndex` to 0 in `onGlobalFilterChange` / +`onColumnFiltersChange`. + +### 11. (MEDIUM) Treating `_rowModels: {}` as "no row models work" + +Core row model is always automatic. `table.getRowModel().rows` returns the +data array as `Row<...>` objects no matter what — `_rowModels: {}` just means +no client-side processing on top. + +--- + +## See also + +- `tanstack-table/angular/table-state` — state ownership, `state` vs `atoms` +- `tanstack-table/angular/compose-with-tanstack-query` — server fetch with Query +- `tanstack-table/angular/compose-with-tanstack-store` — external atom ownership +- `tanstack-table/core/filtering` — manualFiltering semantics +- `tanstack-table/core/sorting` — manualSorting semantics +- `tanstack-table/core/pagination` — manualPagination + `rowCount` / `pageCount` +- Example: `examples/angular/remote-data/` diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-query/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-query/SKILL.md new file mode 100644 index 0000000000..eda0ab6962 --- /dev/null +++ b/packages/angular-table/skills/angular/compose-with-tanstack-query/SKILL.md @@ -0,0 +1,482 @@ +--- +name: angular/compose-with-tanstack-query +description: > + Compose TanStack Table v9 with `@tanstack/angular-query-experimental` for server-side data. + Key the query on the controlled table state that drives the request (pagination, sorting, + filters); use `placeholderData: keepPreviousData` to avoid a "0 rows flash" between pages; + set `manualPagination` / `manualSorting` / `manualFiltering` for the slices the server owns; + drop the matching client `_rowModels` factories; pass `rowCount` from the server response; + set `getRowId` for stable selection across refetches; hoist controlled slices to Angular + signals + `state` + `on[State]Change`. Alternative — `rxResource` / `httpResource` if you + don't want to add the Query dependency (see `client-to-server`). +type: composition +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/client-to-server + - angular/getting-started +sources: + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:examples/angular/remote-data/ + - TanStack/query:packages/angular-query-experimental/src/ +--- + +# Compose with TanStack Query (Angular) + +> Goal: server-driven Angular Table v9 with `@tanstack/angular-query-experimental` +> as the fetch / cache / refetch layer. The pattern is the same as the +> `examples/angular/remote-data/` example, just with `injectQuery` instead of +> `rxResource`. +> +> The non-Query variant (`rxResource` / `httpResource`) is documented in +> `tanstack-table/angular/client-to-server`. Both work — Query adds caching, +> request deduplication, background refetch, and offline coordination. + +--- + +## 1. Install + +```bash +pnpm add @tanstack/angular-query-experimental +``` + +Then in `app.config.ts`: + +```ts +import { + provideTanStackQuery, + QueryClient, +} from '@tanstack/angular-query-experimental' + +export const appConfig: ApplicationConfig = { + providers: [ + provideTanStackQuery(new QueryClient()), + // ... + ], +} +``` + +--- + +## 2. The pattern in one snippet + +```ts +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core' +import { HttpClient, HttpParams } from '@angular/common/http' +import { lastValueFrom, map } from 'rxjs' +import { + injectQuery, + keepPreviousData, +} from '@tanstack/angular-query-experimental' +import { + FlexRender, + injectTable, + tableFeatures, + rowPaginationFeature, + rowSortingFeature, + globalFilteringFeature, + createColumnHelper, + type ColumnDef, + type PaginationState, + type SortingState, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + globalFilteringFeature, +}) + +const columnHelper = createColumnHelper() + +type TodoResponse = { items: Array; totalCount: number } + +@Component({ + selector: 'app-root', + imports: [FlexRender], + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + private readonly http = inject(HttpClient) + + // 1. Hoist controlled slices to Angular signals + readonly pagination = signal({ pageIndex: 0, pageSize: 10 }) + readonly sorting = signal([]) + readonly globalFilter = signal(null) + + // 2. Query keyed on those signals — refetches when any of them change + readonly todosQuery = injectQuery(() => ({ + queryKey: ['todos', this.pagination(), this.sorting(), this.globalFilter()], + queryFn: () => { + const p = this.pagination() + const s = this.sorting() + const f = this.globalFilter() + + let params = new HttpParams({ + fromObject: { _page: p.pageIndex + 1, _limit: p.pageSize }, + }) + if (f) params = params.set('title_like', f) + if (s.length) { + params = params + .set('_sort', s.map((x) => x.id).join(',')) + .set('_order', s.map((x) => (x.desc ? 'desc' : 'asc')).join(',')) + } + + return lastValueFrom( + this.http + .get>('https://jsonplaceholder.typicode.com/todos', { + params, + observe: 'response', + }) + .pipe( + map( + (res) => + ({ + items: res.body ?? [], + totalCount: Number(res.headers.get('X-Total-Count')), + }) satisfies TodoResponse, + ), + ), + ) + }, + placeholderData: keepPreviousData, // ← prevents "0 rows" flash on refetch + staleTime: 30_000, + })) + + // 3. Stable column defs (module-scope-able) + readonly columns: Array> = [ + columnHelper.accessor('id', { header: 'Id', cell: (i) => i.getValue() }), + columnHelper.accessor('title', { + header: 'Title', + cell: (i) => i.getValue(), + }), + columnHelper.accessor('completed', { + header: 'Completed', + cell: (i) => (i.getValue() ? '✅' : '❌'), + }), + ] + + // 4. Wire the table — manual flags, no client row models, rowCount, getRowId + readonly table = injectTable(() => { + const data = this.todosQuery.data() ?? { items: [], totalCount: 0 } + return { + _features, + _rowModels: {}, // ← dropped paginatedRowModel / sortedRowModel / filteredRowModel + columns: this.columns, + data: data.items, + getRowId: (row) => String(row.id), + + state: { + pagination: this.pagination(), + sorting: this.sorting(), + globalFilter: this.globalFilter(), + }, + + manualPagination: true, + manualSorting: true, + manualFiltering: true, + + rowCount: data.totalCount, // for getPageCount() under manualPagination + + autoResetPageIndex: false, // we manage page resets explicitly + + onPaginationChange: (u) => + typeof u === 'function' + ? this.pagination.update(u) + : this.pagination.set(u), + onSortingChange: (u) => + typeof u === 'function' ? this.sorting.update(u) : this.sorting.set(u), + onGlobalFilterChange: (u) => { + typeof u === 'function' + ? this.globalFilter.update(u) + : this.globalFilter.set(u) + this.pagination.update((p) => ({ ...p, pageIndex: 0 })) // UX reset + }, + } + }) +} +``` + +--- + +## 3. The four mandatory pieces + +For server-driven Table + Query to work correctly: + +1. **`queryKey` includes every signal the request reads.** If `pagination` + changes but `queryKey` doesn't include `this.pagination()`, the query + won't refetch. +2. **`placeholderData: keepPreviousData`** keeps the last response visible + during refetches. Without it, `todosQuery.data()` becomes `undefined` + mid-fetch, your table shows 0 rows for a frame, the user notices. +3. **`manualPagination` / `manualSorting` / `manualFiltering: true`** for + slices the server owns + **drop the matching `_rowModels` factories** so + the table doesn't re-process the data the server already filtered/sorted/paged. +4. **`rowCount: data.totalCount`** (or `pageCount`) so `getPageCount()` + computes correctly under `manualPagination`. + +Plus: **`getRowId` for stable identity** across refetches (required for +correct row selection). + +--- + +## 4. Loading and error UI + +`injectQuery` returns a signal-rich object. Read the state in templates: + +```html +@if (todosQuery.isPending()) { +

Loading…

+} @else if (todosQuery.isError()) { +

Failed: {{ todosQuery.error()?.message }}

+} @else { + + ... +
+} +``` + +With `placeholderData: keepPreviousData`, you'll usually want to show the +table even while refetching, plus an inline indicator: + +```html + + + ... + + + ... + +
+ +@if (todosQuery.isFetching()) { +
Refreshing…
+} +``` + +`isPending()` is true only for the very first fetch; `isFetching()` is true +on every background refetch. + +--- + +## 5. Pagination button states + +Under `manualPagination` + `keepPreviousData`, the next-page button should be +disabled when there's no more data — but `getCanNextPage()` only knows that +because you passed `rowCount`. Always pass it: + +```html + +Page {{ table.atoms.pagination.get().pageIndex + 1 }} of {{ + table.getPageCount() }} + +``` + +Disabling buttons during `isFetching()` prevents double-clicks that fire a +second refetch. + +--- + +## 6. Row selection across refetches + +```ts +// In the table options +getRowId: (row) => String(row.id), +``` + +Plus register `rowSelectionFeature`. Now `RowSelectionState` is keyed by +`row.id` (the server primary key) — refetches that change row order don't +break selection. Without `getRowId`, IDs default to row index and selection +points at the wrong rows after a sort flip or refetch. + +--- + +## 7. Mutations (cell-level edits) + +Use Query mutations for cell edits and invalidate the list query on success. +For inline editing UI, see `compose-with-tanstack-form` (when it ships) — or +the `examples/angular/editable/` pattern with `flexRenderComponent` and a +local edit signal. + +```ts +import { inject } from '@angular/core' +import { injectMutation, QueryClient } from '@tanstack/angular-query-experimental' +import { lastValueFrom } from 'rxjs' + +private readonly queryClient = inject(QueryClient) + +readonly toggleTodoMutation = injectMutation(() => ({ + mutationFn: (id: number) => + lastValueFrom(this.http.patch(`/todos/${id}`, { /* … */ })), + onSuccess: () => { + this.queryClient.invalidateQueries({ queryKey: ['todos'] }) + }, +})) + +// In a cell: +cell: ({ row }) => flexRenderComponent(ToggleButton, { + inputs: { todo: row.original }, + outputs: { toggle: (id) => this.toggleTodoMutation.mutate(id) }, +}) +``` + +`invalidateQueries` triggers a background refetch; with `keepPreviousData`, +the user sees the existing list while the new one loads. + +--- + +## 8. Should I use external `Store` atoms or Angular signals here? + +For the filter / sort / pagination slices that the Query reads, **either +works**. The example above uses Angular signals because they read cleanly with +`@for` and `OnPush`, and Query's `queryKey` polls them on the next CD cycle. + +Use an external TanStack Store atom (see `compose-with-tanstack-store`) when: + +- The same slice must drive multiple tables. +- You're syncing the slice to the URL or `localStorage`. +- Other non-table consumers in the app already use the atom. + +In those cases: + +```ts +import { paginationStore } from './stores' +import { injectSelector } from '@tanstack/angular-store' + +readonly paginationSig = injectSelector(paginationStore, (s) => s) + +readonly todosQuery = injectQuery(() => ({ + queryKey: ['todos', this.paginationSig() /*, sort, filter */], + // ... +})) + +readonly table = injectTable(() => ({ + // ... + atoms: { pagination: paginationStore }, + // no state.pagination needed — the atom owns it +})) +``` + +--- + +## Failure modes + +### 1. (CRITICAL) `queryKey` missing the controlled signals + +```ts +// ❌ Query won't refetch when pagination changes +queryKey: ['todos'], + +// ✅ +queryKey: ['todos', this.pagination(), this.sorting(), this.globalFilter()], +``` + +Symptom: paginating "doesn't load the next page." Always include every signal +the request reads. + +### 2. (CRITICAL) No `placeholderData: keepPreviousData` → "0 rows flash" + +Without it, `data()` is `undefined` mid-refetch, the table renders 0 rows +for a frame. `keepPreviousData` (imported from +`@tanstack/angular-query-experimental`) keeps the last successful payload +visible until the new one resolves. + +### 3. (CRITICAL) Forgetting `rowCount` under `manualPagination` + +`getPageCount()` returns `-1`, "next" never disables. The server tells you +how many rows exist — pass it. + +### 4. (CRITICAL) Keeping client row models for slices the server owns + +```ts +// ❌ Double-processes the data +manualPagination: true, +_rowModels: { paginatedRowModel: createPaginatedRowModel() }, // re-paginates server page + +// ✅ +_rowModels: {} // (or just keep the ones still client-side) +``` + +Same applies to `sortedRowModel` under `manualSorting` and `filteredRowModel` +under `manualFiltering`. + +### 5. (CRITICAL) Missing `getRowId` with row selection + +Selection is keyed by row ID. Index-as-ID breaks when the server returns rows +in a new order. `getRowId: (row) => row.id`. + +### 6. (HIGH) `autoResetPageIndex` bouncing the user to page 0 on every refetch + +Every Query response is a new array reference → table sees "new data" → resets +`pageIndex`. Set `autoResetPageIndex: false` and reset explicitly when you +want to (e.g. in `onGlobalFilterChange`). + +### 7. (HIGH) Refetching on every keystroke + +Typing into the global filter fires a fetch per character. Debounce: either +keep a separate `globalFilterInput` signal and propagate to `globalFilter` on +a delay, or compose with `@tanstack/angular-pacer` (when its skill ships). +Also reset `pageIndex: 0` on filter change. + +### 8. (HIGH) Forgetting the updater-fn branch in `on[State]Change` + +```ts +// ❌ Crashes when TanStack Table passes a function (e.g. table.setPageIndex((p) => p + 1)) +onPaginationChange: (value) => this.pagination.set(value) + +// ✅ +onPaginationChange: (u) => + typeof u === 'function' ? this.pagination.update(u) : this.pagination.set(u) +``` + +### 9. (MEDIUM) Reaching for `effect(...)` to call `query.refetch()` on signal changes + +Don't. The whole point of `queryKey` is that Query refetches when the key +changes. Adding an `effect` that calls `refetch()` produces double fetches and +race conditions. Trust the key. + +### 10. (MEDIUM) Two sources of truth for filter state + +Common bug: keep a `signal('')` for the input AND a `state.globalFilter` +controlled value, and try to sync them via `effect`. Pick one: the table's +`globalFilter` is fine for both UI and server query. If you need debouncing, +use an _additional_ `globalFilterInput` signal for the raw input and update +the table-controlled signal on a delay. + +--- + +## See also + +- `tanstack-table/angular/client-to-server` — the no-Query baseline using + `rxResource` / `httpResource` +- `tanstack-table/angular/table-state` — Angular signal + `state` + `on*Change` +- `tanstack-table/angular/compose-with-tanstack-store` — when to use shared + atoms instead of signals +- `tanstack-table/core/filtering` — manualFiltering semantics +- `tanstack-table/core/sorting` — manualSorting semantics +- `tanstack-table/core/pagination` — manualPagination + `rowCount` +- Example: `examples/angular/remote-data/` — analogous pattern with + `rxResource` instead of `injectQuery` +- `@tanstack/angular-query-experimental` docs for `injectQuery`, + `injectMutation`, `injectInfiniteQuery`, `QueryClient` diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md new file mode 100644 index 0000000000..4dd81f7831 --- /dev/null +++ b/packages/angular-table/skills/angular/compose-with-tanstack-store/SKILL.md @@ -0,0 +1,396 @@ +--- +name: angular/compose-with-tanstack-store +description: > + Compose TanStack Table v9 with `@tanstack/angular-store`. TanStack Table v9 is itself built on + TanStack Store — each state slice is an atom. Three read surfaces: `table.atoms.` (per-slice + readonly, signal-backed), `table.store` (flat readonly view), and `table.baseAtoms.` + (writable). The `atoms` table option lets you replace an internal slice with an external + TanStack Store atom for cross-app sharing (URL sync, persistence, multi-table coordination). + In Angular, native signals + `state` + `on[State]Change` is the default; reach for external + atoms only when ownership crosses an app boundary the signal model can't easily span. +type: composition +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - state-management +sources: + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/table:packages/angular-table/src/reactivity.ts + - TanStack/table:packages/angular-table/src/injectTable.ts + - TanStack/store:packages/angular-store/src/ +--- + +# Compose with TanStack Store (Angular) + +> TanStack Table v9 **is** a TanStack Store consumer. The internal state model +> uses `alien-signals` atoms, exposed as `table.atoms.`, +> `table.baseAtoms.`, and the flat `table.store`. In Angular, those +> atoms are signal-backed via the `angularReactivity(injector)` binding. +> +> **For most Angular Table apps, native signals + `state` + `on[State]Change` +> is the right ownership model.** Reach for `@tanstack/angular-store` atoms +> when the slice must travel through code that doesn't share an injection +> scope with the table — URL sync, multi-table coordination, persistence +> layers, server caches, devtools. + +--- + +## 1. The three read surfaces + +Every TanStack Table instance exposes its state at three layers: + +| Surface | Shape | Angular reactivity | Use when | +| ------------------------- | --------------------------------- | ------------------------------- | --------------------------------------------------- | +| `table.baseAtoms.` | Writable `Atom` | Backed by an Angular `signal` | Direct internal writes; rare | +| `table.atoms.` | Readonly `Atom` (derived) | Backed by an Angular `computed` | Reading or driving Angular reactivity per-slice | +| `table.store` | Readonly flat `Store` | Backed by an Angular `computed` | Reading multiple slices in one go (devtools, debug) | + +All three are populated only for **registered features** (`_features`). All +three are signal-backed via `angularReactivity(injector)`: +`createReadonlyAtom` → Angular `computed`, `createWritableAtom` → Angular +`signal`, subscriptions bridged through `toObservable(computed(signal), { +injector })`. + +Read them inside templates, `computed(...)`, or `effect(...)` and Angular +tracks the dependency. + +```ts +this.table.atoms.pagination.get() // current value (reactive) +this.table.atoms.pagination.subscribe(obs) // RxJS observer form +this.table.store.state.pagination // flat snapshot read +this.table.baseAtoms.pagination.set(...) // direct internal write (avoid) +``` + +--- + +## 2. The `atoms` option — bring your own atom + +The `atoms` table option lets you replace the internal `baseAtom` for a slice +with an **external TanStack Store atom**. Once registered, `table.atoms.` +reads from that external atom, and `table.set` / `feature.on*Change` write +through it. + +```ts +import { Store } from '@tanstack/store' +import { + injectTable, + tableFeatures, + rowPaginationFeature, + rowSortingFeature, + type PaginationState, + type SortingState, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ rowPaginationFeature, rowSortingFeature }) + +// Module-scope (or app-scope) shared atoms +export const paginationStore = new Store({ + pageIndex: 0, + pageSize: 25, +}) + +export const sortingStore = new Store([]) + +@Component({...}) +export class App { + readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + atoms: { + pagination: paginationStore, + sorting: sortingStore, + }, + })) +} +``` + +Now `paginationStore.state` and `table.atoms.pagination.get()` always agree. +Any other consumer of `paginationStore` (a URL-sync service, another table, +a devtools panel) sees the same updates. + +> **External atoms take precedence over `state.`.** If you supply both +> `atoms.pagination` and `state.pagination`, the atom wins silently. + +--- + +## 3. When to use external atoms vs Angular signals + +The maintainer guidance: **Angular signals first**. Atoms when ownership +crosses boundaries. + +| Scenario | Use | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| Single component owns the slice | Angular `signal()` + `state` + `on[State]Change` | +| URL-sync (deep-linkable pagination, filter, sort) | External `Store` atom — see §4 | +| Two tables share a `globalFilter` | External `Store` atom on a shared module | +| Persisting to `localStorage` between sessions | External `Store` atom + subscribe to write | +| TanStack Query owns the server cache, table consumes filters | External `Store` atom or Angular signal — both work; pick what reads cleaner | +| Devtools / inspector across multiple tables | External `Store` atom — uniform consumer surface | + +In a pure component-local scenario, an Angular signal is simpler — fewer +imports, no module-level globals, plays nicely with `OnPush`. + +--- + +## 4. Real example — URL-synced pagination + +```ts +import { effect, signal } from '@angular/core' +import { Router, ActivatedRoute } from '@angular/router' +import { Store } from '@tanstack/store' +import { + injectTable, + tableFeatures, + rowPaginationFeature, + type PaginationState, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ rowPaginationFeature }) + +// Shared, app-scope atom +export const paginationStore = new Store({ + pageIndex: 0, + pageSize: 25, +}) + +@Component({ + selector: 'page-route', + // … +}) +export class PageRoute { + private readonly router = inject(Router) + private readonly route = inject(ActivatedRoute) + + constructor() { + // Seed from URL once + const qp = this.route.snapshot.queryParamMap + paginationStore.setState((p) => ({ + pageIndex: Number(qp.get('p') ?? p.pageIndex), + pageSize: Number(qp.get('s') ?? p.pageSize), + })) + + // Mirror to URL + paginationStore.subscribe(() => { + const { pageIndex, pageSize } = paginationStore.state + this.router.navigate([], { + queryParams: { p: pageIndex, s: pageSize }, + queryParamsHandling: 'merge', + replaceUrl: true, + }) + }) + } + + readonly table = injectTable(() => ({ + _features, + _rowModels: { + /* … */ + }, + columns, + data: this.data(), + atoms: { + pagination: paginationStore, // ← external atom owns the slice + }, + })) +} +``` + +`table.nextPage()` now writes to `paginationStore`, which writes to the URL. +A user copying the URL into a new tab lands on the same page. + +--- + +## 5. Read external atom values reactively in Angular + +`@tanstack/angular-store` provides `injectSelector` / `injectAtom` for +deriving Angular signals from TanStack Store atoms outside the table context: + +```ts +import { injectSelector } from '@tanstack/angular-store' +import { paginationStore } from './stores' + +@Component({...}) +export class StatsBar { + readonly pageIndex = injectSelector(paginationStore, (s) => s.pageIndex) + // Angular Signal, signal-tracked normally +} +``` + +Inside a table-owning component, you already have `table.atoms.pagination.get()` +— both forms are equivalent because the external atom _is_ the internal atom +for that slice. + +--- + +## 6. Multi-table coordination + +A common pattern: two tables on the same page should share a `globalFilter`. + +```ts +import { Store } from '@tanstack/store' + +export const sharedFilter = new Store(null) + +// Table A +readonly tableA = injectTable(() => ({ + _features: tableFeatures({ globalFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns: columnsA, + data: this.dataA(), + atoms: { globalFilter: sharedFilter }, +})) + +// Table B (separate component, same module) +readonly tableB = injectTable(() => ({ + _features: tableFeatures({ globalFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns: columnsB, + data: this.dataB(), + atoms: { globalFilter: sharedFilter }, +})) +``` + +Calling `tableA.setGlobalFilter('foo')` updates `sharedFilter`, which Table B +also reads — both views filter together. Without the shared atom you'd need +a separate cross-component sync mechanism. + +--- + +## 7. Reset semantics + +This is a known sharp edge worth understanding: + +- `table.resetPagination()` (and equivalents) updates _through the feature + state updater_. When the slice is owned by an external atom, the external + atom is updated. +- `table.reset()` (the core API) resets the **internal `baseAtoms`**. Do not + use it as the primary reset for externally-owned slices — it bypasses your + external atom. +- For an externally-owned slice, reset by writing to your atom directly + (`paginationStore.setState({ pageIndex: 0, pageSize: 25 })`) or by calling + the slice-specific reset API which routes through the updater. + +--- + +## 8. State and atom precedence — the rules + +For any given registered slice, the table picks state from this priority order: + +1. **`atoms.`** (external atom) — wins everything +2. **`state.`** (controlled value, in initializer) +3. **`initialState.`** (one-time seed) +4. Feature default (slice's blank value) + +**Don't supply more than one source for the same slice** unless you +intentionally want a specific layer to win. The precedence is silent — no +runtime warning today. + +--- + +## 9. Cross-app patterns + +### Hydration / SSR-like state seeding + +For SSR-rendered Angular tables that hydrate a known initial state from the +server payload, the atom approach is the cleanest: + +```ts +const paginationStore = new Store(serverPayload.pagination) +// later: +atoms: { + pagination: paginationStore +} +``` + +The atom is constructed with the hydrated value; the table never re-seeds +from `initialState` because `atoms` takes precedence. + +### Devtools / inspector + +A separate devtools component can subscribe to `paginationStore`, +`sortingStore`, etc. without holding a reference to the table — useful when +the devtools live in a different injection scope. + +--- + +## Failure modes + +### 1. (CRITICAL) Reimplementing TanStack Store with raw signals or Subjects + +```ts +// ❌ A homegrown shared store +@Injectable({ providedIn: 'root' }) +export class FilterService { + readonly value = signal(null) + setFilter(v: string | null) { this.value.set(v) } +} + +// then trying to pipe it into the table… +state: { globalFilter: this.filterService.value() } +onGlobalFilterChange: (u) => /* ... */ +``` + +This works, but you've also lost the atom-bridge that lets `table.setGlobalFilter` +write back through. The atom flow (`atoms: { globalFilter: filterStore }`) is +fewer moving parts and idiomatic v9. Prefer it when the slice spans multiple +consumers. + +### 2. (CRITICAL) Supplying both `state.x` and `atoms.x` for the same slice + +The atom wins; the Angular signal becomes a write-only ghost. No runtime +warning today. Pick one source of truth per slice. The most common bug here is +"I added atoms.pagination but the on[State]Change handler I left from before +no longer fires" — it does fire (the atom is updated through the table's +updater plumbing), but your Angular signal isn't being read by the table. + +### 3. (CRITICAL) Using `table.baseAtoms.x.set(...)` to update an externally-owned slice + +`baseAtoms` are the internal writable atoms. When `atoms.x` is registered, +`table.atoms.x` and the feature updater route through the external atom, but +the internal `baseAtoms.x` is now an orphan — writing to it has no effect on +the table's behavior. Write to the external atom instead. + +### 4. (HIGH) Calling `table.reset()` on an externally-owned slice + +`table.reset()` resets the internal `baseAtoms` — bypasses your external +atom. Use slice-specific resets (`resetSorting()`, `resetPagination()`, +`resetGlobalFilter()`) or write to the external atom directly. + +### 5. (HIGH) Forgetting that external atom state is reactive in Angular + +Reading `paginationStore.state` in a template **is reactive** in v9 because +the adapter wraps it — but reading it in plain TypeScript outside a reactive +scope is a snapshot. Use `injectSelector(paginationStore, …)` to get an +Angular signal for general consumption, or read `table.atoms.pagination.get()` +inside the component that owns the table. + +### 6. (MEDIUM) Putting `new Store(...)` inside a component class field + +Module-level (or `providedIn: 'root'` service) is the right place. Creating a +`new Store(...)` in a component field gives you a per-instance atom — which +defeats the cross-app sharing point. Use external atoms specifically for +_shared_ state. + +### 7. (MEDIUM) Hand-rolling subscription cleanup + +When you `paginationStore.subscribe(fn)`, that returns an unsubscribe. Inside +an Angular component, prefer Angular's `effect(...) + +injectSelector(paginationStore, …)` for derived signals, or +`DestroyRef.onDestroy(unsubscribe)` for raw subscriptions. + +--- + +## See also + +- `tanstack-table/angular/table-state` — the prerequisite atom model +- `tanstack-table/angular/client-to-server` — server-driven tables (where + external atoms shine for URL sync) +- `tanstack-table/angular/compose-with-tanstack-query` — Query + Table + (sometimes external atoms simplify the bridge) +- `tanstack-table/core/state-management` — framework-agnostic atom semantics +- `@tanstack/angular-store` docs — `injectSelector`, `injectAtom`, + `createStoreContext` diff --git a/packages/angular-table/skills/angular/compose-with-tanstack-virtual/SKILL.md b/packages/angular-table/skills/angular/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..7af826b1a1 --- /dev/null +++ b/packages/angular-table/skills/angular/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,400 @@ +--- +name: angular/compose-with-tanstack-virtual +description: > + Compose TanStack Table v9 with `@tanstack/angular-virtual` for virtualized rendering of large + row sets. TanStack Table does NOT virtualize on its own. Pattern: get `rows = table.getRowModel().rows`, + feed `rows.length` to `injectVirtualizer({ count, estimateSize, getScrollElement, overscan })`, + iterate `virtualizer.getVirtualItems()` in the template, position each row with + `transform: translateY(item.start)` inside a tall sentinel, set + `[style.height.px]="virtualizer.getTotalSize()"` to make the scrollbar correct. Handle the + table-feature interactions: row-expanding (variable subRow heights → measure with + `measureElement`), column sizing/pinning (column virtualization is separate), + row-selection (selection state survives virtualization because it's keyed by row ID). +type: composition +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/getting-started + - angular/angular-rendering-directives +sources: + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/virtual:packages/angular-virtual/src/ + - TanStack/table:examples/angular/basic-inject-table/ +--- + +# Compose with TanStack Virtual (Angular) + +> TanStack Table is headless — it computes which rows / cells exist, but does +> not decide which ones to render to the DOM. For tables larger than a few +> hundred visible rows, pair with [`@tanstack/angular-virtual`](https://tanstack.com/virtual) +> so only the rows in the viewport (+ overscan) actually mount. +> +> Required reading: `tanstack-table/angular/getting-started` and +> `tanstack-table/angular/table-state`. + +--- + +## 1. Install + +```bash +pnpm add @tanstack/angular-virtual +``` + +Requires the same Angular version as `@tanstack/angular-table`. + +--- + +## 2. The integration in one shape + +```ts +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + signal, + viewChild, + ElementRef, +} from '@angular/core' +import { + FlexRender, + injectTable, + tableFeatures, + type ColumnDef, +} from '@tanstack/angular-table' +import { injectVirtualizer } from '@tanstack/angular-virtual' + +const _features = tableFeatures({}) + +@Component({ + selector: 'app-virtual-table', + imports: [FlexRender], + templateUrl: './virtual-table.html', + styleUrl: './virtual-table.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VirtualTable { + readonly data = signal>(makeData(50_000)) + readonly scrollContainer = + viewChild.required>('scroll') + + readonly table = injectTable(() => ({ + _features, + _rowModels: {}, + columns, + data: this.data(), + getRowId: (row) => row.id, + })) + + // Stable reference to the rows array for the virtualizer + readonly rows = computed(() => this.table.getRowModel().rows) + + readonly rowVirtualizer = injectVirtualizer(() => ({ + count: this.rows().length, + getScrollElement: () => this.scrollContainer().nativeElement, + estimateSize: () => 36, // fixed-height rows + overscan: 10, + })) +} +``` + +```html + +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + + @for (virtualRow of rowVirtualizer.getVirtualItems(); track + virtualRow.key) { @let row = rows()[virtualRow.index]; + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ {{ value }} +
+ {{ value }} +
+
+``` + +### What's doing what + +- **Table** produces `rows` (`table.getRowModel().rows`). Length, identity, + order are decided by registered features (sort, filter, pagination, + grouping). +- **Virtualizer** turns `rows.length` into the subset of "virtual items" + currently visible (+ `overscan`). It tracks scroll on `getScrollElement()` + and emits `getVirtualItems()` keyed by `virtualRow.key` (the row index by + default). +- **Template** renders only the virtual items, positions each with + `translateY(virtualRow.start)` inside a sentinel of total height + `getTotalSize()`. The scrollbar reflects the full row count, but only the + visible window has DOM nodes. + +--- + +## 3. Mandatory layout details + +This integration touches CSS in a few non-obvious places. None are optional: + +- **Scroll container has a fixed height** (`height: 600px` / `100vh` / + whatever) **and `overflow: auto`**. The virtualizer needs both to compute + visible range. +- **Use `display: grid` on ``, `display: flex` on `` / + `` / ``**, or use `
` markup. Native `
` layout + defeats positioning rows absolutely. The virtual example above uses CSS + grid to keep semantic table markup while letting the rows position freely. +- **Container `` is `position: relative` with explicit + `height = virtualizer.getTotalSize()`.** Without that height, the scrollbar + doesn't reflect the full data. +- **Rows are `position: absolute; top: 0; left: 0; width: 100%` with + `transform: translateY(virtualRow.start)`.** +- **Sticky header**: `position: sticky; top: 0; z-index: 1` on the `` + / its `` — the scroll container provides the scrolling. + +--- + +## 4. Variable row heights — measure dynamically + +When rows can be different heights (expanded subRows, dynamic cell content), +pass `measureElement` and a sensible `estimateSize`: + +```ts +readonly rowVirtualizer = injectVirtualizer(() => ({ + count: this.rows().length, + getScrollElement: () => this.scrollContainer().nativeElement, + estimateSize: () => 36, + overscan: 10, + measureElement: (element) => element?.getBoundingClientRect().height ?? 36, +})) +``` + +In the template, bind the element so the virtualizer can measure it: + +```html + +``` + +(See `@tanstack/angular-virtual` docs for the exact directive name and API; +the principle is: every mounted row reports its real size, the virtualizer +caches that, scrollbar adjusts.) + +--- + +## 5. Row expanding — `rowExpandingFeature` + +Combine with `rowExpandingFeature` for "click to expand details": + +- Register `rowExpandingFeature` in `_features` and + `expandedRowModel: createExpandedRowModel()` in `_rowModels`. +- Use `table.getExpandedRowModel().rows` (or `getRowModel().rows`, which + already includes expansion under `paginateExpandedRows: true` semantics — + see `tanstack-table/core/row-expanding`). +- **Always use `measureElement`** because expansion changes row heights. +- The virtualizer keys items by index; expanded subRows shift later rows + down — that's correct and expected. + +--- + +## 6. Row selection works transparently + +Row selection is keyed by row ID (`getRowId`), not by DOM presence. A row can +be selected while off-screen; scrolling it into view shows the right checkbox +state. **Always set `getRowId`** — critical for both selection and +virtualizer key stability. + +--- + +## 7. Column virtualization (horizontal) + +For very wide tables (50+ columns), virtualize columns too — a second +`injectVirtualizer` over `table.getVisibleLeafColumns().length`. The pattern +mirrors row virtualization but on the X axis. Combine with +`columnPinningFeature` so pinned columns escape the virtualizer (always +rendered, sticky). + +That's a meaningfully bigger lift — most tables don't need it. Reach for it +only when you've profiled and column count is the bottleneck. + +--- + +## 8. Interaction with pagination + +**If you paginate, you usually don't virtualize.** Pagination already caps +the rendered row count to `pageSize`. Adding virtualization on top is +typically wasted effort — you've already solved the rendering bottleneck. + +The exceptions: + +- Pages can hold thousands of rows (rare). +- Pagination is "load more" / infinite scroll style — then virtualize the + accumulated rows. + +--- + +## 9. Interaction with sticky / pinned rows + +`rowPinningFeature` + virtualization is fiddly. Pinned rows live at the +top/bottom of the table; they should render _outside_ the virtualizer's +absolute positioning. Render them in dedicated `` / +top/bottom-of-`` sections, and call `table.getCenterRows()` (the +non-pinned rows) to feed the virtualizer. See +`tanstack-table/core/row-pinning` for the API surface. + +--- + +## 10. SSR / first-paint + +On the server / first hydration, the scroll container's height is unknown; +the virtualizer can render zero rows. Two mitigations: + +- Render a small initial chunk server-side (without the virtualizer) and let + Angular hydrate into the virtualized version client-side. +- Provide an explicit `initialRect: { width, height }` to the virtualizer + options for SSR. + +--- + +## Failure modes + +### 1. (CRITICAL) Trying to use TanStack Table's own virtualization + +There is none. TanStack Table doesn't ship a virtualizer. If an agent +suggests `getVirtualizedRows()` or `enableVirtualization: true` on the table — +those don't exist. Use `@tanstack/angular-virtual`. + +### 2. (CRITICAL) Missing height on the scroll container + +```html + +
+ +
+
+``` + +The virtualizer measures the _scroll element_'s viewport. Without an explicit +or computed height, the viewport is 0 and nothing renders. + +### 3. (CRITICAL) Missing `getTotalSize()` height on the row container + +```html + + + + + + +``` + +Without this, you can scroll to the bottom of the _visible_ rows but can +never reach row 1000. The scrollbar lies. + +### 4. (CRITICAL) Forgetting `transform: translateY(...)` per row + +Absolutely-positioned rows without `transform` stack at `top: 0` — every row +renders on top of every other. + +### 5. (CRITICAL) Using native `
` layout with absolute-positioned rows + +Native `
` layout overrides positioning on `` / ` + +// v9 — uses the FlexRender component + + + +``` + +`FlexRender` handles grouping placeholder / aggregated branches when `columnGroupingFeature` is registered. + +Source: `packages/lit-table/src/flexRender.ts`. + +### 5. Move to slice atoms (optional but preferred) + +```ts +// v8 / v9 fallback — @state() field + onStateChange +@state() private _sorting: SortingState = [] + +protected render() { + const table = this.tableController.table({ + /* … */, + state: { sorting: this._sorting }, + onSortingChange: (u) => { + this._sorting = u instanceof Function ? u(this._sorting) : u + }, + }) +} + +// v9 preferred — external atom (per-slice ownership, no on*Change needed) +import { createAtom } from '@tanstack/store' + +const sortingAtom = createAtom([]) + +protected render() { + const table = this.tableController.table({ + /* … */, + atoms: { sorting: sortingAtom }, + }) +} +``` + +Source: `examples/lit/basic-external-atoms/src/main.ts`. + +### 6. Drop `onStateChange` + +The v8-style global `onStateChange` is gone. Subscribe per-slice with `on*Change`, an external atom, or `table.store.subscribe(...)` if you really need every change. + +## Common Mistakes + +### CRITICAL Keeping the v8 controller-with-thunk shape + +Wrong: + +```ts +// v8 shape — `controller.table` was a property +private tableController = new TableController(this, () => ({ columns, data: this.data })) + +protected render() { + const table = this.tableController.table // no longer a property in v9 +} +``` + +Correct: drop the thunk; call `.table(options, selector?)` each render. +Source: `packages/lit-table/src/TableController.ts`. + +### CRITICAL Keeping `get*RowModel` options after upgrading + +Wrong: + +```ts +this.tableController.table({ + _features, + _rowModels: {}, + columns, + data, + getSortedRowModel: getSortedRowModel(), // ignored +}) +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSortingFeature }) +this.tableController.table({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +v9 doesn't read `get*RowModel` options. Features need both registration in `_features` and a factory in `_rowModels`. + +### CRITICAL Calling a feature API without registering the feature + +Wrong: + +```ts +const _features = tableFeatures({}) // no rowSelectionFeature +const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data, +}) +table.getIsAllRowsSelected() // type error / runtime no-op +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSelectionFeature }) +const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data, + enableRowSelection: true, +}) +``` + +This is the #1 v9 trap. +Source: `docs/guide/features.md`. + +### HIGH Re-using `getCoreRowModel: getCoreRowModel()` + +Wrong: still passing `getCoreRowModel` — v9 includes the core row model automatically. + +Correct: drop it. `_rowModels: {}` is valid. + +### HIGH Single-generic column helper / `ColumnDef` + +Wrong: + +```ts +const columnHelper = createColumnHelper() +const columns: ColumnDef[] = [ + /* … */ +] +``` + +Correct: + +```ts +const columnHelper = createColumnHelper() +const columns: Array> = [ + /* … */ +] +``` + +### HIGH Reimplementing built-ins + +Wrong: hand-rolled sort/filter/pagination outside the table — #1 AI tell. + +Correct: register the matching feature + factory and use the feature APIs. +Source: `docs/guide/features.md`. + +### MEDIUM Calling `flexRender` directly when grouping is registered + +Wrong: `flexRender(cell.column.columnDef.cell, cell.getContext())` for an aggregated/placeholder cell. + +Correct: use `FlexRender({ cell })` — it handles the aggregated/placeholder branches when `columnGroupingFeature` is registered. + +## See Also + +- `tanstack-table/lit/getting-started` — green-field v9 setup. +- `tanstack-table/lit/lit-table-controller` — controller lifecycle in depth. +- `tanstack-table/lit/table-state` — atoms, Subscribe, createTableHook. diff --git a/packages/lit-table/skills/lit/table-state/SKILL.md b/packages/lit-table/skills/lit/table-state/SKILL.md new file mode 100644 index 0000000000..4b56ddaa13 --- /dev/null +++ b/packages/lit-table/skills/lit/table-state/SKILL.md @@ -0,0 +1,437 @@ +--- +name: lit/table-state +description: > + Wiring reactivity for `@tanstack/lit-table` v9. Covers `TableController` + (constructed once per LitElement host, `.table(options, selector?)` called per + render), reading state via `table.state` / `table.store` / `table.atoms.`, + rendering with `table.FlexRender` / `FlexRender`, fine-grained subscriptions + via `table.Subscribe`, owning slices with external atoms via `createAtom` + + `options.atoms`, and packaging shared config into `createTableHook` + (`useAppTable`, `createAppColumnHelper`, `useTableContext`, + `table.AppCell` / `table.AppHeader` / `table.AppFooter`). Routing keywords: + TableController, ReactiveController, useAppTable, atoms, lit-context, + FlexRender, lit-table. +type: framework +library: tanstack-table +framework: lit +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - TanStack/table:docs/framework/lit/guide/table-state.md + - TanStack/table:docs/framework/lit/lit-table.md + - TanStack/table:packages/lit-table/src/TableController.ts + - TanStack/table:packages/lit-table/src/createTableHook.ts + - TanStack/table:packages/lit-table/src/flexRender.ts + - TanStack/table:packages/lit-table/src/reactivity.ts + - TanStack/table:examples/lit/basic-table-controller/src/main.ts + - TanStack/table:examples/lit/basic-external-atoms/src/main.ts + - TanStack/table:examples/lit/basic-app-table/src/main.ts +--- + +> **Maintainer note:** the Lit adapter is scheduled for a rewrite alongside TanStack Lit Store during the v9 beta cycle. APIs in this skill (especially `table.Subscribe` and the `TableController` invalidation strategy) may change in a future beta. The patterns below match `9.0.0-alpha.47`. + +This skill builds on `tanstack-table/state-management` and `tanstack-table/setup`. Read those first — `state-management` explains the v9 atom model. The Lit adapter wires that atom model into a `ReactiveController` (`TableController`) attached to a `LitElement` host. + +## Setup + +The shape every Lit v9 table follows: register `_features` and `_rowModels` at module scope, construct `TableController` once per host element, and call `.table(options, selector?)` from inside `render()`. + +```ts +import { LitElement, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { + FlexRender, + TableController, + rowSortingFeature, + createSortedRowModel, + sortFns, + tableFeatures, + type ColumnDef, +} from '@tanstack/lit-table' + +type Person = { firstName: string; lastName: string; age: number } + +const _features = tableFeatures({ rowSortingFeature }) + +const columns: Array> = [ + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + }, + { accessorKey: 'lastName', header: () => html`Last Name` }, + { accessorKey: 'age', header: 'Age' }, +] + +@customElement('people-table') +export class PeopleTable extends LitElement { + // ONE controller per host. The constructor calls host.addController(this). + private tableController = new TableController(this) + + @state() + private data: Person[] = [] + + protected render() { + const table = this.tableController.table( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this.data, + }, + (state) => ({ sorting: state.sorting }), + ) + + return html` +
`. Either: + +- Use `display: grid` on `` and `display: flex` on `` / `` uses `height: virtualizer.getTotalSize() + 'px'`, each row uses `position: absolute` + `transform: translateY(...)`. + +### MEDIUM Mixing virtualization with `manualPagination` + +You usually don't need both. Server pagination already limits the row count. Virtualize when the client holds the full dataset. + +## See Also + +- `tanstack-table/lit/table-state` — Subscribe for fine-grained re-renders. +- `tanstack-table/lit/lit-table-controller` — controller lifecycle. +- `tanstack-table/row-expanding` — virtualizing rows with sub-component rows requires variable height + measureElement. diff --git a/packages/lit-table/skills/lit/getting-started/SKILL.md b/packages/lit-table/skills/lit/getting-started/SKILL.md new file mode 100644 index 0000000000..484a5f9937 --- /dev/null +++ b/packages/lit-table/skills/lit/getting-started/SKILL.md @@ -0,0 +1,332 @@ +--- +name: lit/getting-started +description: > + End-to-end first-table journey for `@tanstack/lit-table` v9: install the + adapter (plus required `lit` and `@lit/context` peers), declare `_features` + via `tableFeatures()`, declare `_rowModels` with their factories, build a + typed column helper, construct one `TableController` per LitElement host, + call `.table(options, selector?)` inside `render()`, and render with + `FlexRender({ cell|header|footer })`. Routing keywords: install lit-table, + first table, getting started, TableController, basic-table-controller, + tableFeatures. +type: lifecycle +library: tanstack-table +framework: lit +library_version: '9.0.0-alpha.47' +requires: + - setup + - column-definitions + - state-management + - lit/table-state +sources: + - TanStack/table:docs/installation.md + - TanStack/table:docs/framework/lit/lit-table.md + - TanStack/table:examples/lit/basic-table-controller/src/main.ts + - TanStack/table:packages/lit-table/src/TableController.ts +--- + +> **Maintainer note:** the Lit adapter is scheduled for a rewrite alongside TanStack Lit Store during the v9 beta cycle. APIs in this skill may change in a future beta. The patterns below match `9.0.0-alpha.47`. + +This skill walks through a first working Lit v9 table end-to-end. Read `tanstack-table/setup` and `tanstack-table/state-management` for v9 core concepts and `tanstack-table/lit/lit-table-controller` for the controller lifecycle. + +## Install + +`@tanstack/lit-table` is the Lit adapter. It depends on `@tanstack/table-core` and `@tanstack/store`, and lists `lit` and `@lit/context` as peer dependencies. + +```bash +npm install @tanstack/lit-table lit @lit/context +``` + +Peer dependency versions: `lit ^3.1.3`, `@lit/context ^1.1.0`. + +Source: `packages/lit-table/package.json`. + +## Step 1 — Declare `_features` + +v9 is explicit about what a table uses. Use `tableFeatures({...})` at module scope. The TypeScript shape drives state inference, API surface, and tree-shaking. + +```ts +import { + tableFeatures, + rowPaginationFeature, + rowSortingFeature, +} from '@tanstack/lit-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) +``` + +If `_features` does not include `rowSelectionFeature`, then `table.atoms.rowSelection`, `table.setRowSelection`, etc. become TypeScript errors — and the runtime won't ship that logic. Pass `tableFeatures({})` for a minimum-overhead table with just the core row model. + +Source: `docs/framework/lit/lit-table.md`; `docs/guide/features.md`. + +## Step 2 — Declare `_rowModels` + +Each registered feature that needs a row-model stage maps to a factory under `_rowModels`. The factory takes a record of \*Fns for that stage. + +```ts +import { + createPaginatedRowModel, + createSortedRowModel, + sortFns, +} from '@tanstack/lit-table' + +const _rowModels = { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), +} +``` + +The core row model is always included — `_rowModels: {}` is valid for a feature-free table. + +## Step 3 — Type your data and build columns + +```ts +import type { ColumnDef } from '@tanstack/lit-table' +import { html } from 'lit' + +type Person = { + firstName: string + lastName: string + age: number + visits: number + status: string + progress: number +} + +const columns: Array> = [ + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + }, + { + accessorFn: (row) => row.lastName, + id: 'lastName', + header: () => html`Last Name`, + cell: (info) => html`${info.getValue()}`, + }, + { + accessorKey: 'age', + header: () => 'Age', + cell: (info) => info.renderValue(), + }, + { accessorKey: 'visits', header: () => html`Visits` }, + { accessorKey: 'status', header: 'Status' }, + { accessorKey: 'progress', header: 'Profile Progress' }, +] +``` + +For a more type-safe path, use `createColumnHelper()`. + +Source: `examples/lit/basic-table-controller/src/main.ts`. + +## Step 4 — Build the LitElement host with `TableController` + +```ts +import { LitElement, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { FlexRender, TableController } from '@tanstack/lit-table' + +@customElement('lit-table-example') +class LitTableExample extends LitElement { + // ONE controller per host. Constructed as a class field so the constructor's + // `host.addController(this)` call happens once. + private tableController = new TableController(this) + + @state() + private data: Person[] = makeData(20) + + private rerender() { + this.data = makeData(20) + } + + protected render() { + // Build the table for THIS render pass. + // First call constructs the core table + subscribes the host to table.store. + // Later calls merge options into the same instance. + const table = this.tableController.table( + { + _features, + _rowModels, + columns, + data: this.data, + }, + () => ({}), // empty selector — we don't need to project state for this minimal example + ) + + return html` + +
` + (see §3), preserving semantic markup, OR +- Use `
` markup throughout. + +### 6. (HIGH) Variable row heights without `measureElement` + +Default `estimateSize` is a constant. Different real heights → wrong +positions → rows visually overlap or leave gaps. Pass `measureElement` and a +way for each mounted row to report its real size. + +### 7. (HIGH) Pagination + virtualization both enabled + +Pagination already caps row count. Adding virtualization on top doubles the +indirection for no win. Pick one. + +### 8. (HIGH) Reimplementing virtualization with `IntersectionObserver` + +Saw an agent build a homegrown "render rows when visible" with +`IntersectionObserver`? That's hundreds of lines of broken virtualization. +Use the library. + +### 9. (HIGH) Wrong `track` in the virtualized `@for` + +```html + +@for (virtualRow of rowVirtualizer.getVirtualItems(); track row.id) + + +@for (virtualRow of rowVirtualizer.getVirtualItems(); track virtualRow.key) +``` + +Track by the virtual item's stable key (or index). The row is _inside_ the +virtual item — Angular uses the outer track for DOM reuse. + +### 10. (MEDIUM) `injectVirtualizer` outside an injection context + +Like `injectTable`, `injectVirtualizer` calls `assertInInjectionContext()`. +Place it on a class field, in a constructor, or inside `runInInjectionContext`. + +### 11. (MEDIUM) Recreating `count` / `estimateSize` on every signal change without + +stable callbacks + +Move `estimateSize`, `measureElement`, `getScrollElement` to stable +references (class arrow methods or module-scope functions) where possible. +Otherwise the virtualizer re-initializes its internal state on every change. + +### 12. (MEDIUM) Missing `getRowId` — selection breaks across re-sorts in a + +virtualized table + +This isn't virtualization-specific, but it's especially visible here because +virtualization renders a window of rows; refreshing that window via scroll +makes mismatched checkbox state obvious. `getRowId: (row) => row.id` is +mandatory. + +--- + +## See also + +- `tanstack-table/angular/getting-started` — baseline table that this skill + layers virtualization on top of +- `tanstack-table/angular/production-readiness` — when to reach for + virtualization vs server-side pagination +- `tanstack-table/core/row-expanding` — variable subRow heights + virtual +- `tanstack-table/core/column-layout` — pinning interaction +- `@tanstack/angular-virtual` docs — `injectVirtualizer`, options reference, + variable-height patterns diff --git a/packages/angular-table/skills/angular/getting-started/SKILL.md b/packages/angular-table/skills/angular/getting-started/SKILL.md new file mode 100644 index 0000000000..61dd28daf7 --- /dev/null +++ b/packages/angular-table/skills/angular/getting-started/SKILL.md @@ -0,0 +1,496 @@ +--- +name: angular/getting-started +description: > + End-to-end first-table journey for TanStack Table v9 in Angular: install + `@tanstack/angular-table`, declare `_features` with `tableFeatures()`, register row-model + factories under `_rowModels` with explicit `*Fns` parameters, build columns with the + `TFeatures, TData` generic order, call `injectTable(() => ({...}))` from an injection context, + and render with `FlexRender` / `*flexRenderHeader` / `*flexRenderCell` / `*flexRenderFooter`. + Covers the minimum-viable signal-backed table plus the upgrade path to sorting + filtering + + pagination. +type: lifecycle +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/angular-rendering-directives + - setup + - column-definitions +sources: + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:docs/framework/angular/guide/rendering.md + - TanStack/table:packages/angular-table/src/injectTable.ts + - TanStack/table:examples/angular/basic-inject-table/ + - TanStack/table:examples/angular/basic-app-table/ +--- + +# Getting Started — Angular Table v9 + +> Goal: from zero to a working signal-backed, sorted + paginated, type-safe +> table in Angular ≥19. +> +> v9 is **explicit**: tell the table which features you want with `_features`, +> tell it which row models you want with `_rowModels`. That explicitness is what +> makes the v9 bundle tree-shakeable. + +--- + +## 1. Install + +```bash +pnpm add @tanstack/angular-table +# or npm / yarn / bun +``` + +Requires Angular ≥19 (signal APIs, `input()`, structural directive metadata). +Standalone components are assumed. + +--- + +## 2. The simplest possible table (core only) + +```ts +// app.ts +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import { + FlexRender, + injectTable, + tableFeatures, + type ColumnDef, +} from '@tanstack/angular-table' + +type Person = { + id: string + firstName: string + lastName: string + age: number +} + +// 1. _features OUTSIDE the component class (stable reference) +const _features = tableFeatures({}) // empty = core row model only + +// 2. columns OUTSIDE the component class (stable reference) +const columns: Array> = [ + { + accessorKey: 'firstName', + header: 'First name', + cell: (info) => info.getValue(), + }, + { + accessorKey: 'lastName', + header: 'Last name', + cell: (info) => info.getValue(), + }, + { + accessorKey: 'age', + header: () => 'Age', + cell: (info) => info.getValue(), + }, +] + +@Component({ + selector: 'app-root', + imports: [FlexRender], // tuple imports BOTH directives + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + readonly data = signal>([ + { id: '1', firstName: 'Ada', lastName: 'Lovelace', age: 36 }, + { id: '2', firstName: 'Alan', lastName: 'Turing', age: 41 }, + ]) + + // 3. injectTable in an injection context (a class field qualifies) + readonly table = injectTable(() => ({ + _features, // required in v9 + _rowModels: {}, // {} = core only; that's fine + columns, // stable ref + data: this.data(), // signal read → re-syncs the table on change + })) +} +``` + +```html + + + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ @if (!header.isPlaceholder) { + + {{ value }} + + } +
+ + {{ value }} + +
+``` + +That's a complete v9 table. No sorting, no pagination — just `` markup +driven by the row model. + +### What the boilerplate is doing + +- `tableFeatures({})` registers no opt-in features. The core row model + (`getRowModel()`) is always available. With `_features: tableFeatures({})`, + `table.atoms.*` only contains the slices core ships with — no `pagination`, + no `sorting`, no `rowSelection` until you add the matching features. +- `_rowModels: {}` does not register any feature-specific row models. Core + row model is included automatically. +- `injectTable(() => ({...}))` runs the initializer, builds the table, and + re-runs the initializer whenever any signal read inside changes. Stable + references outside the initializer keep `columns` / `_features` / `_rowModels` + from getting recreated on every data update. + +--- + +## 3. Add a feature — sorting + +Each opt-in feature has two pieces in v9: + +1. The **feature** itself (`rowSortingFeature`) in `_features` — adds APIs + like `column.toggleSorting()` and the `sorting` state slice. +2. The **row-model factory** (`createSortedRowModel(sortFns)`) in `_rowModels` + — produces the sorted output. Without it, `table.getRowModel().rows` is + unsorted regardless of sort state. + +```ts +import { + injectTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + type ColumnDef, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ + rowSortingFeature, +}) + +readonly table = injectTable(() => ({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), // <-- enables sorting output + }, + columns, + data: this.data(), +})) +``` + +In the template, drive sorting from the header: + +```html +@if (!header.isPlaceholder) { + +} +``` + +> **Use `column.toggleSorting()`, not your own sort handler.** It correctly +> handles the asc → desc → unsorted cycle. Same applies for every other +> feature. + +`sortFns` is the registry of built-in sort functions +(`alphanumeric`, `basic`, `datetime`, etc.). Pass only the ones you use to +tree-shake (`createSortedRowModel({ basic: sortFns.basic })`), or pass `sortFns` +in its entirety for all of them. + +--- + +## 4. Add filtering + pagination + +```ts +import { + injectTable, + tableFeatures, + rowSortingFeature, + columnFilteringFeature, + rowPaginationFeature, + createSortedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + sortFns, + filterFns, + type ColumnDef, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ + rowSortingFeature, + columnFilteringFeature, + rowPaginationFeature, +}) + +readonly table = injectTable(() => ({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data: this.data(), + initialState: { + pagination: { pageIndex: 0, pageSize: 10 }, + }, +})) +``` + +Pagination controls — again, prefer the table APIs: + +```html + + + Page {{ table.atoms.pagination.get().pageIndex + 1 }} of {{ + table.getPageCount() }} + + + + +``` + +Reading state in the template via `table.atoms..get()` is signal-backed +— Angular tracks it and re-renders on change. + +--- + +## 5. Use the column helper for safer types + +`createColumnHelper()` (generic order: features first!) gives +type-safe accessor / display / group definitions, plus a `columns(...)` method +for better inference across heterogeneous columns: + +```ts +import { createColumnHelper } from '@tanstack/angular-table' + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor((row) => `${row.firstName} ${row.lastName}`, { + id: 'fullName', + header: 'Full name', + }), + columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ row }) => `Edit #${row.original.id}`, + }), +]) +``` + +> v9 changed the generic order: `createColumnHelper()`, +> **not** `createColumnHelper()`. Same for `ColumnDef`. + +If multiple components share the same `_features` / `_rowModels`, factor them +into a `createTableHook(...)` call — see +`tanstack-table/angular/angular-rendering-directives` §10 and the +`composable-tables` example. + +--- + +## 6. Stable row identity — set `getRowId` + +If your rows have a primary key, set `getRowId`. This makes row selection, +row pinning, and refetch-based updates correct. + +```ts +readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + getRowId: (row) => row.id, // ← stable ID across re-fetches +})) +``` + +Without `getRowId`, the row index becomes the ID — selection state +("rows 0–4 selected") survives sorting but breaks across server refetches that +return rows in a new order. + +--- + +## 7. State ownership — start with internal, hoist when you need to + +The simplest table lets TanStack Table own all state internally. You set +starting values with `initialState`, and you use APIs like `table.nextPage()` +and `table.setSorting(...)` to drive updates. + +```ts +readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + initialState: { + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [{ id: 'age', desc: true }], + }, +})) +``` + +Hoist a slice into an Angular signal only when something outside the table +needs to read or react to it (URL sync, debounced server fetch, persistence, +cross-component coordination). The pattern is `state` + `on[State]Change` → +see `tanstack-table/angular/table-state` §6. + +For full server-driven tables, see `tanstack-table/angular/client-to-server`. + +--- + +## Failure modes + +### 1. (CRITICAL) Calling `injectTable` outside an injection context + +`injectTable` calls `assertInInjectionContext`. It must be invoked from a +class-field initializer, constructor, or factory inside a DI scope. Calling it +from a service method or a `setTimeout` callback throws: + +> `NG0203: inject() must be called from an injection context...` + +If you need to construct a table from a service method, capture the injector +and use `runInInjectionContext(injector, () => injectTable(...))`. + +### 2. (CRITICAL) Hallucinating v8 `createAngularTable` or `getCoreRowModel()` + +```ts +// ❌ v8 +import { createAngularTable, getCoreRowModel } from '@tanstack/angular-table' + +// ✅ v9 +import { injectTable, tableFeatures } from '@tanstack/angular-table' +``` + +There is no `getCoreRowModel()` / `getSortedRowModel()` / `getFilteredRowModel()` +in v9. Core row model is automatic; the rest are +`createSortedRowModel(sortFns)` / `createFilteredRowModel(filterFns)` / etc. +registered under `_rowModels`. + +### 3. (CRITICAL) Reimplementing what the table API already does + +Telltale AI signs in a getting-started snippet: + +- Custom `sortBy()` on the data signal instead of `table.setSorting()` / + `column.toggleSorting()`. +- Manual `pageIndex` math instead of `table.nextPage()` / `table.getCanNextPage()`. +- Computing `getCanNextPage()` as `pageIndex < Math.ceil(rows / pageSize) - 1` + instead of asking the table. +- Manual filtering of the data array before passing it to the table when you + could just register `columnFilteringFeature` + `createFilteredRowModel`. + +The table already does all of this. Use it. + +### 4. (HIGH) Feature without its row model (or vice versa) + +```ts +// ❌ rowSortingFeature without createSortedRowModel → sort state changes, rows don't reorder +_features: tableFeatures({ rowSortingFeature }) +_rowModels: { +} + +// ✅ +_rowModels: { + sortedRowModel: createSortedRowModel(sortFns) +} +``` + +Full mapping table → [`references/feature-row-model-mapping.md`](references/feature-row-model-mapping.md). + +### 5. (HIGH) Declaring `columns` / `_features` / `_rowModels` inside the initializer + +```ts +// ❌ Recreated on every signal change +readonly table = injectTable(() => ({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns: [/* … */], + data: this.data(), +})) + +// ✅ Stable references outside, signal reads inside +const _features = tableFeatures({ rowSortingFeature }) +const columns: Array> = [/* … */] + +readonly table = injectTable(() => ({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this.data(), +})) +``` + +### 6. (HIGH) Importing a non-existent `flexRender` function + +In Angular, `FlexRender` is a directive tuple, not a function. There is no +`flexRender(fn, ctx)` call expression — that's the React/Vue API. Always: + +```ts +import { FlexRender } from '@tanstack/angular-table' +@Component({ imports: [FlexRender] }) +``` + +and use `*flexRenderCell` / `*flexRenderHeader` / `*flexRenderFooter` in the +template. + +Lower-severity failure modes (MEDIUM: `createColumnHelper` generic-order flip, +importing only `FlexRenderDirective` without the shorthand) → +[`references/feature-row-model-mapping.md`](references/feature-row-model-mapping.md#lower-severity-failure-modes-medium). + +--- + +## References + +- [Feature → row-model mapping table and MEDIUM failure modes](references/feature-row-model-mapping.md) + +--- + +## See also + +- `tanstack-table/angular/table-state` — state model, ownership, controlled vs internal +- `tanstack-table/angular/angular-rendering-directives` — full rendering API surface +- `tanstack-table/angular/migrate-v8-to-v9` — for projects upgrading from v8 +- `tanstack-table/angular/client-to-server` — flipping a working table to a server endpoint +- `tanstack-table/angular/production-readiness` — tree-shaking, stable refs, selectors +- Example: `examples/angular/basic-inject-table/` +- Example: `examples/angular/basic-app-table/` (uses `createTableHook`) diff --git a/packages/angular-table/skills/angular/getting-started/references/feature-row-model-mapping.md b/packages/angular-table/skills/angular/getting-started/references/feature-row-model-mapping.md new file mode 100644 index 0000000000..0de278f2e3 --- /dev/null +++ b/packages/angular-table/skills/angular/getting-started/references/feature-row-model-mapping.md @@ -0,0 +1,48 @@ +# Feature → Row Model Mapping & Optional Patterns + +Reference material extracted from the getting-started SKILL.md. + +--- + +## Feature → row model mapping + +Every opt-in v9 feature has two pieces: + +1. The **feature** itself in `_features` — adds APIs (e.g. + `column.toggleSorting()`) and the matching state slice. +2. A **row-model factory** in `_rowModels` — produces the derived row output. + Without it, sort/filter/paginate UI updates but rows don't reorder. + +| Feature | Row model needed | +| ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `rowSortingFeature` | `sortedRowModel: createSortedRowModel(sortFns)` | +| `columnFilteringFeature` / `globalFilteringFeature` | `filteredRowModel: createFilteredRowModel(filterFns)` | +| `rowPaginationFeature` | `paginatedRowModel: createPaginatedRowModel()` | +| `rowExpandingFeature` | `expandedRowModel: createExpandedRowModel()` | +| `columnGroupingFeature` | `groupedRowModel: createGroupedRowModel(aggregationFns)` | +| `columnFacetingFeature` | `facetedRowModel: createFacetedRowModel()` (+ `facetedMinMaxValues` / `facetedUniqueValues`) | +| `rowSelectionFeature` | (no row model needed) | +| `columnVisibilityFeature` / `columnOrderingFeature` / `columnPinningFeature` / `columnSizingFeature` / `columnResizingFeature` / `rowPinningFeature` | (no row model needed) | + +--- + +## Lower-severity failure modes (MEDIUM) + +### Wrong `createColumnHelper` generic order + +```ts +// ❌ v8 shape +const columnHelper = createColumnHelper() + +// ✅ v9 — features first +const columnHelper = createColumnHelper() +``` + +Or use `createAppColumnHelper()` from a `createTableHook(...)` factory, +which pre-binds `TFeatures`. + +### Importing only `FlexRenderDirective` and missing the shorthand + +`FlexRender` is preferred — it imports both `FlexRenderDirective` (the long +`*flexRender`) and `FlexRenderCell` (the shorthand). If you only import one, +`*flexRenderCell` won't compile. diff --git a/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..c29d717665 --- /dev/null +++ b/packages/angular-table/skills/angular/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,415 @@ +--- +name: angular/migrate-v8-to-v9 +description: > + Mechanical v8 → v9 migration for `@tanstack/angular-table`: `createAngularTable` → + `injectTable`, `get*RowModel()` options → `_rowModels` factories with explicit `*Fns`, + required `_features` via `tableFeatures()`, `state` access via `table.store.state` instead + of `table.getState()`, `createColumnHelper()` generic-order flip, every + type now requires `TFeatures`, `enablePinning` split into `enableColumnPinning` / + `enableRowPinning`, `sortingFn` → `sortFn` rename pile, `ColumnSizingInfo` → `ColumnResizing` + split, removal of `_`-prefixed internals, signal-backed atoms replacing v8 memoized accessors, + and structural-directive rendering replacing v8 component-based rendering. +type: lifecycle +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/getting-started + - angular/angular-rendering-directives +sources: + - TanStack/table:docs/framework/angular/guide/migrating.md + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/table:packages/angular-table/src/injectTable.ts + - TanStack/table:packages/angular-table/src/reactivity.ts +--- + +# Migrate from TanStack Table v8 to v9 (Angular) + +> **Angular does not ship a legacy v8 API in v9** (unlike React's +> `useLegacyTable`). You migrate directly to v9's `injectTable` + `_features` + +> `_rowModels` shape. There is no incremental in-place adapter — the public +> entrypoint name itself changes. + +This skill is a mechanical translation table. Work through it top-to-bottom. + +For exhaustive lookup tables (row-model mapping, feature registration, type +generics, sorting renames, sizing-vs-resizing split, etc.) → +[`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md). + +--- + +## 1. Entrypoint rename + +```ts +// v8 +import { createAngularTable, getCoreRowModel } from '@tanstack/angular-table' + +const v8Table = createAngularTable(() => ({ + columns, + data: data(), + getCoreRowModel: getCoreRowModel(), +})) + +// v9 +import { injectTable, tableFeatures } from '@tanstack/angular-table' + +const _features = tableFeatures({}) // empty is valid; core row model is automatic + +const v9Table = injectTable(() => ({ + _features, + _rowModels: {}, + columns, + data: data(), +})) +``` + +Key behavioral change: **the `injectTable` initializer re-runs when signals +inside it change**, then the adapter calls `table.setOptions({ ...prev, ...new })`. +Move stable values (`columns`, `_features`, `_rowModels`) **outside** the +initializer so they aren't recreated on every data update. + +--- + +## 2. Required new options: `_features` + `_rowModels` + +v9 is opt-in for every feature. **Both options are required.** + +```ts +// v8 — features were bundled, row models added piecewise +createAngularTable(() => ({ + columns, + data: data(), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + filterFns, // root option + sortingFns, // root option +})) + +// v9 +import { + injectTable, + tableFeatures, + columnFilteringFeature, + rowSortingFeature, + rowPaginationFeature, + createFilteredRowModel, + createSortedRowModel, + createPaginatedRowModel, + filterFns, + sortFns, // note rename: sortingFns → sortFns +} from '@tanstack/angular-table' + +const _features = tableFeatures({ + columnFilteringFeature, + rowSortingFeature, + rowPaginationFeature, +}) + +injectTable(() => ({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), // fns are PARAMETERS now + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data: data(), +})) +``` + +Row-model and feature lookup tables → [`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md#row-model-migration-table). + +--- + +## 3. State access: `getState()` → `table.store.state` (and atoms) + +```ts +// v8 +const { sorting, pagination } = table.getState() + +// v9 — flat snapshot +const { sorting, pagination } = table.store.state + +// v9 — per slice (signal-backed in Angular) +const sorting = table.atoms.sorting.get() +const pagination = table.atoms.pagination.get() +``` + +In Angular, all three (`table.atoms.`, `table.store.state`, +`table.baseAtoms.`) are signal-backed — reading them inside a template, +`computed(...)`, or `effect(...)` registers an Angular dependency +automatically. No `toSignal(...)` wrappers needed. + +See `tanstack-table/angular/table-state` for the full state surface mental +model. + +--- + +## 4. Controlled state — `on[State]Change` shape + +The shape is largely the same. **`onStateChange` (the single global v8 hook) is +gone in v9.** Slices are controlled individually via `state.` + +`on[State]Change` callbacks. Each callback receives either a new value or an +updater function: + +```ts +// v9 pattern +import { signal } from '@angular/core' +import type { SortingState, PaginationState } from '@tanstack/angular-table' + +readonly sorting = signal([]) +readonly pagination = signal({ pageIndex: 0, pageSize: 10 }) + +readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + state: { + sorting: this.sorting(), + pagination: this.pagination(), + }, + onSortingChange: (updater) => { + updater instanceof Function + ? this.sorting.update(updater) + : this.sorting.set(updater) + }, + onPaginationChange: (updater) => { + updater instanceof Function + ? this.pagination.update(updater) + : this.pagination.set(updater) + }, +})) +``` + +> Always check `updater instanceof Function` (or `typeof updater === 'function'`). +> TanStack Table calls the callback with both shapes depending on the +> transition. + +--- + +## 5. Column helper generic-order flip + +```ts +// v8 +const columnHelper = createColumnHelper() + +// v9 — TFeatures FIRST, then TData +const columnHelper = createColumnHelper() +``` + +New in v9: `columnHelper.columns([...])` preserves each column's `TValue` — +prefer it over a bare array literal: + +```ts +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First', + cell: (i) => i.getValue(), + }), + columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: () => 'Edit', + }), +]) +``` + +If you don't want to repeat `TFeatures` everywhere, use `createTableHook(...)` +and the resulting `createAppColumnHelper()` which pre-binds features. + +Every public type now requires `TFeatures` (`ColumnDef`, +`Cell`, etc.). Full mapping → +[`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md#type-generics--every-type-takes-tfeatures-now). + +--- + +## 6. Rendering — directive-based, with shorthand directives + +v8 Angular rendering was already directive-flavored, but v9 adds the +**shorthand directives** (`*flexRenderCell`, `*flexRenderHeader`, +`*flexRenderFooter`) that auto-resolve the column-def slot and context. Prefer +them over the long `*flexRender="… ; props: …"` form. + +```html + + + + + + + +``` + +```ts +import { FlexRender } from '@tanstack/angular-table' // ← imports both directives +@Component({ imports: [FlexRender], ... }) +``` + +v9-only: + +- `flexRenderComponent(Component, { inputs, outputs, bindings, directives, injector })` + for explicit component rendering. +- DI tokens (`TanStackTable` / `TanStackTableHeader` / `TanStackTableCell` + directives + `injectTableContext()` / `injectTableHeaderContext()` / + `injectTableCellContext()` / `injectFlexRenderContext()`) — no more input + drilling. +- Column-def `cell` / `header` / `footer` functions run inside + `runInInjectionContext`, so `inject(...)` and signals work in them. + +See `tanstack-table/angular/angular-rendering-directives` for the full surface. + +--- + +## 7. Reactivity model — signals replace v8 memo accessors + +v8 backed reactivity with manual memoized getters. v9's adapter +(`angularReactivity(injector)`) backs every readonly atom with an Angular +`computed` and every writable atom with an Angular `signal`. Consequences: + +- **No `toSignal(...)` adapters around table state.** Read `table.atoms.x.get()` + / `table.store.state.x` directly inside templates, `computed`, `effect`. +- **`computed(...)` is for derivation / equality, not for "make it reactive".** + Use `{ equal: shallow }` from `@tanstack/angular-table` on object/array + slices to skip downstream work on no-op updates. +- **The `injectTable` initializer re-runs on signal changes.** Don't put + expensive object literals in there. + +--- + +## 8. Renames at a glance + +- `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, `SortingFn` → `SortFn` + (full table in [`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md#naming-renames--sorting)). +- `enablePinning` → `enableColumnPinning` / `enableRowPinning` (split). +- `columnSizingInfo` state → `columnResizing` state (sizing/resizing split into + two features — see [`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md#column-sizing-vs-resizing--split)). +- All `_`-prefixed internal APIs removed; use the public equivalents. + +--- + +## Migration checklist + +- [ ] Replace `createAngularTable` import + call with `injectTable`. +- [ ] Add `_features: tableFeatures({...})` (or `stockFeatures`) — required. +- [ ] Convert every `get*RowModel()` option to a `_rowModels.` entry with + the matching `create*RowModel(...)` factory. +- [ ] Add `filterFns` / `sortFns` / `aggregationFns` as **factory parameters** + where needed. +- [ ] Update `createColumnHelper()` → `createColumnHelper()`. +- [ ] Update every `ColumnDef` / `Cell` etc. to include + `TFeatures`. +- [ ] Replace `table.getState()` reads with `table.store.state` (or + `table.atoms..get()` for per-slice reactivity). +- [ ] Remove any usage of the v8 single `onStateChange` — split into per-slice + `on[State]Change`. +- [ ] In `on[State]Change` callbacks, handle both value and updater-fn shapes. +- [ ] Move `columns`, `_features`, `_rowModels` **outside** the `injectTable` + initializer. +- [ ] Switch any `flexRender` long-form to `*flexRenderCell` / `*flexRenderHeader` / + `*flexRenderFooter` shorthand where applicable. +- [ ] Where you need component rendering with explicit options, switch wrapper + shape to `flexRenderComponent(Component, { inputs, outputs, ... })`. +- [ ] Replace prop-drilled cell/header inputs with + `injectTableCellContext()` / `injectTableHeaderContext()` / + `injectFlexRenderContext()` (optional but worthwhile). +- [ ] Rename `sortingFn` → `sortFn`, `getSortingFn` → `getSortFn`, + `sortingFns` → `sortFns`, `SortingFn` → `SortFn`. +- [ ] Replace `columnSizingInfo` state / setters / change handler with the + `columnResizing` equivalents; add `columnResizingFeature` to `_features` + if you actually drag-resize. +- [ ] Replace `enablePinning` with `enableColumnPinning` / `enableRowPinning`. +- [ ] Update `ColumnMeta` module augmentation to include the `TFeatures` + generic. +- [ ] Drop any `_`-prefixed internal API usages; replace with public + equivalents. +- [ ] (Optional) Adopt `tableOptions(...)` for shared base config. +- [ ] (Optional) Adopt `createTableHook(...)` for app-wide table infrastructure. + +--- + +## Failure modes + +### 1. (CRITICAL) Leaving `getCoreRowModel()` / `getSortedRowModel()` / etc. in v9 options + +These options don't exist anymore. They become `_rowModels` entries with +factory functions. The TypeScript error is loud but agents sometimes silence +it with `as any` — don't. + +### 2. (CRITICAL) Reaching for `createAngularTable` from v8 muscle memory + +Always `injectTable(() => ({...}))`. The injection-context requirement means +it must run from a class field, constructor, or +`runInInjectionContext(injector, () => injectTable(...))`. + +### 3. (CRITICAL) Registering a feature but forgetting its row model + +```ts +// ❌ filtering enabled, but no filtered row model — UI changes, rows don't filter +_features: tableFeatures({ columnFilteringFeature }) +_rowModels: { +} // missing filteredRowModel + +// ✅ +_rowModels: { + filteredRowModel: createFilteredRowModel(filterFns) +} +``` + +Same for sorting, pagination, expanding, grouping, faceting. Selection, +visibility, ordering, pinning, sizing, resizing do **not** need a row model. + +### 4. (HIGH) `getState()` → `table.store.state` text replacement loses reactivity + +Bulk-replacing `table.getState().x` with `table.store.state.x` works for _current +value_ reads, but if you used a `computed`/`memo` around `getState()` for +reactivity, switch to `table.atoms.x.get()` — it's already signal-backed and +needs no wrapper. + +### 5. (HIGH) Stale `sortingFn` / `sortingFns` references in column defs + +The rename is mechanical: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, +`getSortingFn` → `getSortFn`. Missed renames produce silent runtime fallbacks +to default sort. + +### 6. (HIGH) Column helper using v8 generic order + +```ts +// ❌ TS will complain — the first generic is TFeatures, not TData +const columnHelper = createColumnHelper() +``` + +### 7. (HIGH) Putting `_features` / `columns` / row-model factories inside the `injectTable` initializer + +The v8 mental model was "build columns inside the hook". v9's +`injectTable` initializer re-runs on every signal read change — keep heavy +literals outside. + +Lower-severity failure modes (MEDIUM/LOW: `stockFeatures` cleanup, `enablePinning` +removal, `columnSizingInfo` rename, single-handler porting, hand-rolled +`TFeatures` in render fns, `_`-prefix internal usage, reimplementing built-in +APIs, `ColumnMeta` augmentation drift, `flexRender` as a function, mixing v8/v9 +atoms) → [`references/v8-to-v9-mapping.md`](references/v8-to-v9-mapping.md#lower-severity-failure-modes-mediumlow). + +--- + +## References + +- [Full v8 → v9 mapping tables (row models, features, types, renames, MEDIUM failure modes)](references/v8-to-v9-mapping.md) + +--- + +## See also + +- `tanstack-table/angular/getting-started` — what the v9 target shape looks like +- `tanstack-table/angular/table-state` — signal-backed atom model +- `tanstack-table/angular/angular-rendering-directives` — full rendering API +- `tanstack-table/angular/production-readiness` — once compiling, optimize the bundle +- `tanstack-table/core/migrate-v8-to-v9` — framework-agnostic core changes diff --git a/packages/angular-table/skills/angular/migrate-v8-to-v9/references/v8-to-v9-mapping.md b/packages/angular-table/skills/angular/migrate-v8-to-v9/references/v8-to-v9-mapping.md new file mode 100644 index 0000000000..923f258664 --- /dev/null +++ b/packages/angular-table/skills/angular/migrate-v8-to-v9/references/v8-to-v9-mapping.md @@ -0,0 +1,261 @@ +# v8 → v9 Mapping Tables — Full Reference + +Mechanical translation tables and detailed renames for the Angular Table v8 → +v9 migration. The SKILL.md keeps the primary patterns; this file is the +exhaustive lookup. + +--- + +## Row-model migration table + +| v8 option | v9 `_rowModels` key | v9 factory | +| -------------------------- | --------------------- | --------------------------------------- | +| `getCoreRowModel()` | (automatic) | — | +| `getFilteredRowModel()` | `filteredRowModel` | `createFilteredRowModel(filterFns)` | +| `getSortedRowModel()` | `sortedRowModel` | `createSortedRowModel(sortFns)` | +| `getPaginationRowModel()` | `paginatedRowModel` | `createPaginatedRowModel()` | +| `getExpandedRowModel()` | `expandedRowModel` | `createExpandedRowModel()` | +| `getGroupedRowModel()` | `groupedRowModel` | `createGroupedRowModel(aggregationFns)` | +| `getFacetedRowModel()` | `facetedRowModel` | `createFacetedRowModel()` | +| `getFacetedMinMaxValues()` | `facetedMinMaxValues` | `createFacetedMinMaxValues()` | +| `getFacetedUniqueValues()` | `facetedUniqueValues` | `createFacetedUniqueValues()` | + +## Feature registration table + +| v8 (implicit) | v9 feature import | +| --------------- | ------------------------- | +| filter columns | `columnFilteringFeature` | +| global filter | `globalFilteringFeature` | +| sort rows | `rowSortingFeature` | +| pagination | `rowPaginationFeature` | +| row selection | `rowSelectionFeature` | +| expanding rows | `rowExpandingFeature` | +| pin rows | `rowPinningFeature` | +| pin columns | `columnPinningFeature` | +| hide columns | `columnVisibilityFeature` | +| reorder columns | `columnOrderingFeature` | +| size columns | `columnSizingFeature` | +| resize columns | `columnResizingFeature` | +| group columns | `columnGroupingFeature` | +| facet columns | `columnFacetingFeature` | + +> If you don't want to think about tree-shaking yet, you can pass +> `stockFeatures`: +> +> ```ts +> import { stockFeatures } from '@tanstack/angular-table' +> _features: stockFeatures +> ``` +> +> This restores v8-like "everything bundled" behavior — but the v9 bundle +> wins are gone. Plan to migrate to a curated `tableFeatures({...})` as part of +> productionization. + +--- + +## Type generics — every type takes `TFeatures` now + +```txt +v8 v9 +--- --- +Column → Column +ColumnDef → ColumnDef +Table → Table +Row → Row +Cell → Cell +Header → Header +HeaderContext → HeaderContext +CellContext → CellContext +``` + +Easiest fix: extract `typeof _features` once. + +```ts +const _features = tableFeatures({ rowSortingFeature, columnFilteringFeature }) + +type Features = typeof _features + +const columns: Array> = [ + /* … */ +] +``` + +If you're on `stockFeatures`: + +```ts +import type { StockFeatures, ColumnDef } from '@tanstack/angular-table' +const columns: Array> = [ + /* … */ +] +``` + +`ColumnMeta` module augmentation also needs `TFeatures`: + +```ts +declare module '@tanstack/angular-table' { + interface ColumnMeta< + TFeatures extends TableFeatures, + TData extends RowData, + TValue, + > { + align?: 'left' | 'right' + } +} +``` + +--- + +## Naming renames — sorting + +| v8 | v9 | +| ----------------------------------- | ------------------------ | +| `sortingFn` (column-def field) | `sortFn` | +| `column.getSortingFn()` | `column.getSortFn()` | +| `column.getAutoSortingFn()` | `column.getAutoSortFn()` | +| `SortingFn` type | `SortFn` | +| `SortingFns` interface | `SortFns` | +| `sortingFns` (built-in fn registry) | `sortFns` | + +Find-and-replace, then update `createSortedRowModel(sortFns)`. + +--- + +## Column sizing vs resizing — split + +v8 combined sizing and resizing into one feature. v9 splits them so you can +ship only what you use. + +| v8 | v9 | +| --------------------------------- | ---------------------------------------------------------- | +| `ColumnSizing` (single feature) | `columnSizingFeature` + `columnResizingFeature` (separate) | +| `columnSizingInfo` state | `columnResizing` state | +| `setColumnSizingInfo()` | `setColumnResizing()` | +| `onColumnSizingInfoChange` option | `onColumnResizingChange` option | + +If you only render fixed-width columns and never drag-to-resize, import only +`columnSizingFeature`. + +--- + +## Pinning option split + +```ts +// v8 +enablePinning: true + +// v9 — explicit +enableColumnPinning: true +enableRowPinning: true +``` + +--- + +## Row API: privates promoted to public + +| v8 | v9 | +| ---------------------------------------- | -------------------------------------- | +| `row._getAllCellsByColumnId()` (private) | `row.getAllCellsByColumnId()` (public) | + +All other `_`-prefixed internal APIs from v8 are removed in v9 — use the +public equivalents. If you were touching `_`-prefixed members, you were on +unsupported territory; v9 forces the fix. + +--- + +## `tableOptions(...)` — new composition helper + +v9 ships `tableOptions(...)` for type-safe partial option composition. Useful +during migration if you have shared base config: + +```ts +import { tableOptions, tableFeatures, rowSortingFeature } from '@tanstack/angular-table' + +const baseOptions = tableOptions({ + _features: tableFeatures({ rowSortingFeature }), + debugTable: isDevMode(), +}) + +readonly table = injectTable(() => ({ + ...baseOptions, + _rowModels: { /* … */ }, + columns: this.columns, + data: this.data(), +})) +``` + +And for whole-app patterns, `createTableHook(...)` — see +`tanstack-table/angular/angular-rendering-directives`. + +--- + +## `RowData` type tightened + +`RowData` is now stricter (object-like; no primitives). If you had +`type Row = string`, that won't fly in v9 — wrap it in an object. + +--- + +## Lower-severity failure modes (MEDIUM/LOW) + +### Skipping `stockFeatures` cleanup later + +Using `stockFeatures` is a fine migration shortcut, but it forgoes the v9 +bundle wins. Once everything compiles, swap `stockFeatures` for an explicit +`tableFeatures({...})` listing only the features you use. + +### Forgetting `enablePinning` is gone + +`enablePinning: true` silently does nothing. Use `enableColumnPinning: true` +and/or `enableRowPinning: true`. + +### Forgetting `columnSizingInfo` is gone + +Replace state name `columnSizingInfo` with `columnResizing`, setter +`setColumnSizingInfo` with `setColumnResizing`, handler +`onColumnSizingInfoChange` with `onColumnResizingChange`. And add +`columnResizingFeature` to `_features` if you actually need resizing. + +### Single global `onStateChange` ported as a giant per-slice fan-out + +In v9 each slice has its own callback. If you previously used `onStateChange` +to multiplex changes, port to the specific `on[State]Change` callbacks you +actually care about — don't recreate a megaswitch. + +### Hand-rolling `TFeatures` in render-fn types + +When a `cell` / `header` function signature requires `CellContext`, +let `createColumnHelper()` infer it for you. Spelling +features by hand in dozens of render-fn signatures is a sign you should be +using `createAppColumnHelper` from `createTableHook`. + +### Reaching for `_`-prefixed internals because the public method "doesn't exist" + +The "missing" method usually means the feature isn't in `_features`. Add the +feature; don't peek at internals. + +### Reimplementing what the table API already does + +Symptoms of v8 muscle memory carrying through: + +- Manually sorting the data signal in `effect(...)` instead of + `table.setSorting(...)` and using `table.getRowModel().rows`. +- Manually paginating the rows array instead of using `paginatedRowModel`. +- Hand-rolling `getSelectedRowModel().flatRows` from a `rowSelection` signal. + +If a v8 escape hatch was needed because of a bug or missing API, check the v9 +public API first — many things were added in v9. + +### `ColumnMeta` augmentation without the `TFeatures` generic + +The interface now takes `TFeatures` first. Old declarations get silently merged +but typed wrong. + +### Importing `flexRender` as a function + +There's no `flexRender(fn, ctx)` in Angular. `FlexRender` is a directive tuple +constant. Imports + template directive form is the only shape. + +### Trying to share v9 atoms with v8 — incompatible + +If you have a partial v8 codebase coexisting, **don't** try to bridge atoms +across the version boundary. Migrate per-component. diff --git a/packages/angular-table/skills/angular/production-readiness/SKILL.md b/packages/angular-table/skills/angular/production-readiness/SKILL.md new file mode 100644 index 0000000000..0fdd55fed7 --- /dev/null +++ b/packages/angular-table/skills/angular/production-readiness/SKILL.md @@ -0,0 +1,468 @@ +--- +name: angular/production-readiness +description: > + Ship-ready optimizations for Angular Table v9: register only the `_features` you actually use + (tree-shake the bundle); keep `columns` / `_features` / `_rowModels` / feature-fn maps as + stable references OUTSIDE the `injectTable` initializer; pass only the `*Fns` your data needs + to `createSortedRowModel` / `createFilteredRowModel` / `createGroupedRowModel`; use + `ChangeDetectionStrategy.OnPush`; lean on signal-backed atoms (`table.atoms..get()`) + instead of broad `table.store.state` reads where granularity matters; use `{ equal: shallow }` + on object/array `computed` selectors; set `getRowId` for stable identity; track by `id` in + every `@for`; defer cell components with `flexRenderComponent` only when you need its options; + scope DI tokens via `[tanStackTable*]` directives to kill prop drilling. +type: lifecycle +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - angular/table-state + - angular/getting-started + - angular/angular-rendering-directives +sources: + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:docs/framework/angular/guide/migrating.md + - TanStack/table:packages/angular-table/src/injectTable.ts + - TanStack/table:packages/angular-table/src/reactivity.ts + - TanStack/table:examples/angular/composable-tables/ +--- + +# Production Readiness (Angular Table v9) + +> Once your table compiles and renders, this is the cost reduction pass. +> Angular's signal-backed adapter makes most of v9's perf "free" if you don't +> fight it — the work is mostly about _what you don't do_: not recreating +> objects, not pulling in features you don't use, not over-wrapping with +> selectors. + +--- + +## 1. Bundle: register only the features you use + +The single biggest v9 win is feature tree-shaking. Every feature you put in +`tableFeatures({...})` pulls in its code; everything you leave out is dropped +by the bundler. + +```ts +// ❌ Pulls in EVERY feature, even unused ones +const _features = stockFeatures + +// ✅ Only what this table actually uses +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, +}) +``` + +Use `stockFeatures` to bootstrap during a v8 → v9 migration, then **come back +and curate**. The bundle wins only land once you do. + +The same applies to feature-fn registries — pass only the `*Fns` your data +needs: + +```ts +import { + createSortedRowModel, + createFilteredRowModel, + sortFns, + filterFns, +} from '@tanstack/angular-table' + +// ❌ pulls in every built-in sort + filter fn +_rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), +} + +// ✅ only what you use +_rowModels: { + sortedRowModel: createSortedRowModel({ basic: sortFns.basic, datetime: sortFns.datetime }), + filteredRowModel: createFilteredRowModel({ includesString: filterFns.includesString }), +} +``` + +Same logic for `aggregationFns` if you use grouping. + +--- + +## 2. Stable references — keep them OUTSIDE the initializer + +`injectTable(() => ({...}))` **re-runs the initializer every time a signal read +inside it changes** and then calls `table.setOptions({ ...prev, ...new })`. +Anything you create inside the initializer is recreated on every signal +change. + +```ts +// ❌ columns / _features / _rowModels / feature fns recreated on every data() change +@Component({...}) +export class App { + readonly table = injectTable(() => ({ + _features: tableFeatures({ rowSortingFeature }), // ← new ref each run + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, // ← new ref each run + columns: [/* … */], // ← new ref each run + data: this.data(), + })) +} + +// ✅ stable references outside; only reactive reads inside +const _features = tableFeatures({ rowSortingFeature }) +const _rowModels = { sortedRowModel: createSortedRowModel(sortFns) } +const columns: Array> = [/* … */] + +@Component({...}) +export class App { + readonly table = injectTable(() => ({ + _features, + _rowModels, + columns, + data: this.data(), + })) +} +``` + +Same rule for the controlled-state pattern — keep `state: { pagination: this.pagination() }` +inside the initializer, but keep the signal definitions on the class. + +For shared infrastructure across multiple tables, `createTableHook(...)` lets +you define `_features` / `_rowModels` / default options once at module scope. + +--- + +## 3. `ChangeDetectionStrategy.OnPush` everywhere + +Every component that hosts or renders a TanStack Table should be: + +```ts +@Component({ + // ... + changeDetection: ChangeDetectionStrategy.OnPush, +}) +``` + +With signal-backed atoms, OnPush is sufficient — atom reads in the template +are tracked through `computed`, so Angular schedules a check when the signal +changes. Default change detection causes redundant work on every event. + +All `examples/angular/*` use `OnPush`. Match that. + +--- + +## 4. Read narrowly — `table.atoms..get()` over `table.store.state` + +Both surfaces are signal-backed. The difference is _which signal_ gets read. + +```ts +// Wider — depends on the flat snapshot signal (recomputes when ANY registered slice changes) +const pageIndex = computed(() => this.table.store.state.pagination.pageIndex) + +// Narrower — depends only on the pagination atom +const pageIndex = computed(() => this.table.atoms.pagination.get().pageIndex) +``` + +For most apps the difference is negligible. For high-frequency atoms or +deeply-derived components, prefer per-atom reads. + +--- + +## 5. `{ equal: shallow }` on object/array `computed` + +When you derive an object or array slice, downstream `effect`s and `computed`s +re-run whenever the reference changes — even if the structural contents +didn't. Use `shallow` from `@tanstack/angular-table` to short-circuit: + +```ts +import { computed } from '@angular/core' +import { shallow } from '@tanstack/angular-table' + +readonly pagination = computed( + () => this.table.atoms.pagination.get(), + { equal: shallow }, +) + +readonly visibleColumns = computed( + () => this.table.atoms.columnVisibility.get(), + { equal: shallow }, +) +``` + +This is **not** about reactivity — atoms are reactive already. This is about +skipping no-op downstream recomputations when the slice rebuilds with the same +values. + +**Don't reach for it on every read.** Reserve it for derived selectors whose +downstream is expensive (effects that hit the server, big template +re-renders). + +--- + +## 6. `track row.id` and `getRowId` + +Always provide a stable identity: + +```ts +readonly table = injectTable(() => ({ + // ... + getRowId: (row) => row.id, // stable primary key +})) +``` + +Then in every `@for`: + +```html +@for (row of table.getRowModel().rows; track row.id) { ... } @for (cell of +row.getVisibleCells(); track cell.id) { ... } @for (header of +headerGroup.headers; track header.id) { ... } +``` + +Without `getRowId`, IDs default to row index — re-rendering the entire row list +on a sort flip, refetch, or pagination move because Angular thinks every row +is new. + +--- + +## 7. Render-cost rules for cells + +Cell render fns run for every visible cell on every re-render. Cheap is the +goal. + +- **Return a primitive when you can.** `cell: (info) => info.getValue()` is + fastest. +- **Return a component class (not a wrapper) when only inputs need wiring.** + The renderer's `KeyValueDiffers` skips `setInput` for unchanged values. +- **Reach for `flexRenderComponent(...)` only for explicit options** — + custom inputs not derived from context, output callbacks, an injector, + Angular v20+ `bindings` / `directives`. +- **Don't put expensive `inject()` calls in render fns.** They run inside + `runInInjectionContext` every render. Inject at the component level and + close over the value. +- **Don't allocate inside render fns when you can avoid it.** Closures, new + array literals, etc. + +### Stable input references + +For object inputs (like `data` arrays you pass into a sub-component), keep the +reference stable across renders. `KeyValueDiffers` is reference-based for +Angular's default input equality, so a `{ ...obj }` literal on every render +defeats it. + +--- + +## 8. Kill prop drilling with DI tokens + +Passing `cell` / `header` / `table` through 2+ component layers is both +ergonomic noise and a perf hazard (each input has its own diffing cost). +Replace with the host directives + inject helpers: + +```html + +``` + +```ts +export class CellActionsComponent { + readonly cell = injectTableCellContext() + // cell() is a Signal>; reads are reactive +} +``` + +Inside `*flexRender*` components, the tokens are auto-provided (no host +directive needed) — see `tanstack-table/angular/angular-rendering-directives` +§7. + +--- + +## 9. Large data — let virtualization do the work + +A 10k-row table is fine in v9 in terms of state, but rendering 10k rows is +slow. **Don't render what's off-screen.** Pair with `@tanstack/angular-virtual` +— see `tanstack-table/angular/compose-with-tanstack-virtual`. + +Also consider: + +- Server-side pagination if data is huge — see + `tanstack-table/angular/client-to-server`. +- `defaultColumn: { size, minSize, maxSize }` to set sane sizing defaults if + you've registered `columnSizingFeature`. + +--- + +## 10. Avoid `effect(...)` for cross-slice sync — write directly + +```ts +// ❌ Effect chain — runs after CD, can layer-cake +effect(() => { + const filter = this.globalFilter() + this.pagination.update((p) => ({ ...p, pageIndex: 0 })) +}) + +// ✅ Reset inline in the on*Change handler +onGlobalFilterChange: (u) => { + typeof u === 'function' + ? this.globalFilter.update(u) + : this.globalFilter.set(u) + this.pagination.update((p) => ({ ...p, pageIndex: 0 })) +} +``` + +The on\*Change handler runs synchronously at the source of truth; an `effect` +runs after Angular's CD pass, which can lead to double renders. + +--- + +## 11. Avoid double-controlling a slice + +Don't supply both `state.x` and `atoms.x` for the same slice — atom wins +silently, the Angular signal becomes a write-only sink, and you've doubled +the wiring cost. Pick exactly one source of truth per slice (see +`tanstack-table/angular/table-state` §6–§7). + +--- + +## 12. Build hygiene + +- **`bundle-stats` / `source-map-explorer`**: after curating `_features`, + verify your final bundle doesn't include retired features. If you see + `rowGroupingFeature` in the bundle but never imported it, something is + pulling in `stockFeatures` indirectly. +- **`debugTable: isDevMode()`** — only in dev. Don't leave `debugTable: true` + in production. + +--- + +## 13. Quick wins checklist + +- [ ] `_features` listed explicitly (no `stockFeatures` in production). +- [ ] `*Fns` registries passed only what you use to + `createSortedRowModel` / `createFilteredRowModel` / + `createGroupedRowModel`. +- [ ] `columns`, `_features`, `_rowModels`, feature fns are at module scope + or stable class fields — never inside the `injectTable` initializer. +- [ ] Component is `ChangeDetectionStrategy.OnPush`. +- [ ] `getRowId` set when rows have a stable primary key. +- [ ] All `@for` blocks track by `id`. +- [ ] Cell render fns return primitives or component classes when possible; + `flexRenderComponent(...)` reserved for explicit-option cases. +- [ ] Reading state inside `effect`s / heavy `computed`s uses + `table.atoms..get()` (not the flat snapshot). +- [ ] Object/array `computed` selectors that feed expensive downstream use + `{ equal: shallow }`. +- [ ] No `cell` / `header` / `table` inputs drilled through multiple + components — replaced with `injectTableCellContext()` / etc. +- [ ] `debugTable` only in `isDevMode()`. +- [ ] Tables larger than a few hundred visible rows use virtualization. + +--- + +## Failure modes + +### 1. (CRITICAL) Shipping `stockFeatures` to production + +`stockFeatures` defeats the v9 bundle wins. The migrating skill explicitly +calls this out — `stockFeatures` is a v8 → v9 bootstrap, not a production +end-state. + +### 2. (CRITICAL) Recreating `columns` / `_features` / `_rowModels` inside the + +`injectTable` initializer + +The initializer re-runs on every signal read change. New `columns` reference +triggers full column-model rebuilds — for big tables this is visibly slow. +Module-scope it. + +### 3. (CRITICAL) Reimplementing what the table already does + +Symptoms: + +- Manual sort on the data array inside a `computed`/`effect`, then passing + the sorted array to the table. +- Manual pagination math driving `data: paged()` — paginated by the user, not + the table. +- Hand-rolled global filter `.filter(...)` inside `effect`. + +All of these are far slower than the built-in row models (which memoize and +short-circuit) and ship more code. Use `table.setSorting(...)`, +`table.setColumnFilters(...)`, the registered `_rowModels` factories. + +### 4. (HIGH) `OnPush` not set + +Default change detection runs on every event in the entire app. Even with +signal-backed atoms, you're paying for unnecessary template checks. `OnPush` +is the table's idiomatic setting. + +### 5. (HIGH) `@for` without stable `track` + +`@for (row of rows)` without a `track` value at all is a build error in +Angular ≥17 strict mode; `track $index` defeats DOM reuse on sort/refetch. +Always `track row.id` (and `getRowId` on the table). + +### 6. (HIGH) Over-wrapping every read in `computed(...)` + +```ts +// ❌ adds a computed layer for no reason +readonly pagination = computed(() => this.table.atoms.pagination.get()) +``` + +The atom is already signal-backed. Use `computed` for derivation, custom +equality, or shared selectors — not for "make it reactive." + +### 7. (HIGH) `{ equal: shallow }` on every `computed` + +Shallow equality has a runtime cost (one pass over keys). For primitive +selectors it's strictly slower than `Object.is`. Reserve it for derived +object/array slices whose downstream is expensive. + +### 8. (HIGH) Drilling `cell` / `header` / `table` through multiple + +components + +Inputs add diffing cost on every change-detection cycle. Replace with the +`[tanStackTableCell]` / `[tanStackTableHeader]` / `[tanStackTable]` host +directives and `injectTableCellContext()` / etc. at the leaf. + +### 9. (HIGH) `flexRenderComponent(...)` for every cell + +`flexRenderComponent` adds a wrapper with `reflectComponentType` overhead +and an `OutputEmitterRef` subscription scan. For plain component pass-through +where context inputs cover everything, **return the component class +directly** — the renderer does `setInput` on its own. + +### 10. (MEDIUM) `effect(...)` chains for what should be on\*Change inline + +If the user changes `globalFilter` and your `pagination` reset lives in an +`effect`, you get a CD pass for the filter and a second one for the reset. +Inline the reset in `onGlobalFilterChange`. + +### 11. (MEDIUM) Forgetting `autoResetPageIndex: false` for server-driven tables + +Every fetch produces a new array reference, which triggers the default +auto-reset and bounces the user back to page 0 mid-pagination. See +`tanstack-table/angular/client-to-server` §9. + +### 12. (MEDIUM) `debugTable: true` left in production + +Turns on per-operation `console.info` logging from the core. Use +`debugTable: isDevMode()`. + +### 13. (MEDIUM) Reaching for `Subscribe` patterns ported from React docs + +Angular doesn't need a `Subscribe` boundary the way React does. The +adapter's signal binding handles fine-grained reactivity at the atom level — +templates re-evaluate the dependencies they actually read. + +--- + +## See also + +- `tanstack-table/angular/getting-started` — the first-table baseline +- `tanstack-table/angular/table-state` — narrow vs wide reads, controlled state +- `tanstack-table/angular/angular-rendering-directives` — `flexRenderComponent`, + DI tokens +- `tanstack-table/angular/client-to-server` — server-driven optimizations +- `tanstack-table/angular/compose-with-tanstack-virtual` — virtualizing big + tables +- Example: `examples/angular/composable-tables/` — `createTableHook` for + app-wide infrastructure diff --git a/packages/angular-table/skills/angular/table-state/SKILL.md b/packages/angular-table/skills/angular/table-state/SKILL.md new file mode 100644 index 0000000000..d446a5b94b --- /dev/null +++ b/packages/angular-table/skills/angular/table-state/SKILL.md @@ -0,0 +1,425 @@ +--- +name: angular/table-state +description: > + TanStack Table v9 state ownership in Angular: signal-backed atoms via `angularReactivity`, + the `injectTable(() => ({...}))` lazy initializer pattern, reading `table.atoms..get()` + inside templates / `computed(...)` / `effect(...)`, `shallow` for object slices, controlled state + with Angular signals + `state` + `on[State]Change`, and when to reach for external TanStack Store + atoms instead. Required reading before any other Angular Table v9 skill. +type: framework +library: tanstack-table +framework: angular +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - TanStack/table:docs/framework/angular/angular-table.md + - TanStack/table:docs/framework/angular/guide/table-state.md + - TanStack/table:docs/framework/angular/guide/migrating.md + - TanStack/table:packages/angular-table/src/injectTable.ts + - TanStack/table:packages/angular-table/src/reactivity.ts + - TanStack/table:packages/angular-table/src/lazySignalInitializer.ts + - TanStack/table:examples/angular/basic-inject-table/ + - TanStack/table:examples/angular/row-selection-signal/ +--- + +# Angular Table State (v9) + +> **TanStack Table is a state-management coordinator.** v9 rebuilt that coordinator +> on top of TanStack Store (`alien-signals`). In Angular, the adapter bridges those +> atoms to native Angular signals, so reading `table.atoms..get()` from a +> template, `computed(...)`, or `effect(...)` participates in Angular reactivity. +> +> This is the prerequisite for every other Angular Table skill. Don't skip it. + +--- + +## 1. Prerequisites — `_features` and `_rowModels` decide what state exists + +In v9, **a state slice only exists if its feature is registered in `_features`**. +This is the #1 v9-specific gotcha and the root cause of many "missing API" +TypeScript errors. + +```ts +import { + injectTable, + tableFeatures, + rowPaginationFeature, + rowSortingFeature, + createPaginatedRowModel, + createSortedRowModel, + sortFns, +} from '@tanstack/angular-table' + +// Declare features OUTSIDE the initializer (see §2 below) +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +readonly table = injectTable(() => ({ + _features, + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + }, + columns, + data: this.data(), +})) + +this.table.atoms.pagination.get() // ✅ +this.table.atoms.sorting.get() // ✅ +// this.table.atoms.rowSelection // ❌ TS error — rowSelectionFeature not registered +``` + +If you see `Property 'atoms.rowSelection' does not exist` or +`table.toggleRowSelected is not a function`, **add the feature to `_features`** — +don't reach for `@ts-ignore`, don't reimplement the API, don't switch to +`stockFeatures` until you understand which features you actually need. + +`tableFeatures({})` (empty) is valid — you get the core row model only. + +--- + +## 2. The `injectTable` lazy-initializer model + +`injectTable` is the v9 entrypoint (replacing v8's `createAngularTable`). It must +run inside an Angular injection context (a component constructor / class field). + +```ts +readonly table = injectTable(() => ({ + _features, + _rowModels: {}, + columns, + data: this.data(), +})) +``` + +### The initializer is a `computed`-like function + +The initializer runs **whenever any Angular signal read inside it changes**. The +adapter then calls `table.setOptions({ ...previous, ...newOptions })` to sync. +That means: + +- **Reactive values that should re-sync the table** (`this.data()`, controlled + state signals) go _inside_ the initializer. +- **Stable references** (`columns`, `_features`, `_rowModels`, feature-fn maps) + go _outside_ — or you'll recreate the column model on every data update. + +```ts +// ❌ WRONG — columns + _features recreated on every data change +readonly table = injectTable(() => ({ + _features: tableFeatures({ rowSortingFeature }), // new reference each run + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, // ditto + columns: [/* … */], // ditto + data: this.data(), +})) + +// ✅ Stable references outside, signal reads inside +const _features = tableFeatures({ rowSortingFeature }) +const columns: Array> = [/* … */] + +readonly table = injectTable(() => ({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this.data(), // ← only the signal read should be inside +})) +``` + +### The returned table is signal-reactive too + +The `table` returned by `injectTable` exposes APIs that read signal-backed atoms +internally, so calling `table.getRowModel()`, `table.getSelectedRowModel()`, +`table.atoms.pagination.get()`, etc. inside templates / `computed` / `effect` +_just works_ — no manual subscriptions. + +--- + +## 3. The three state surfaces + +A table instance has three ways to look at its state: + +| Surface | Shape | Use when | +| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- | +| `table.baseAtoms.` | writable TanStack Store atom (always exists for registered slices) | low-level direct write; rare | +| `table.atoms.` | **readonly** derived atom per registered feature; backed by Angular `computed` | reading current value or driving reactivity | +| `table.store.state` | flat snapshot object of every registered slice; backed by Angular `computed` | reading multiple slices at once, devtools | + +All three are signal-backed in Angular. Reading any of them inside a template, +`computed(...)`, or `effect(...)` registers an Angular dependency. + +```ts +// Read current value (anywhere) +const pagination = this.table.atoms.pagination.get() + +// Same value, flat shape +const pagination2 = this.table.store.state.pagination + +// Reactive derivation with custom equality +import { computed } from '@angular/core' +import { shallow } from '@tanstack/angular-table' + +readonly pageIndex = computed( + () => this.table.atoms.pagination.get().pageIndex, +) + +readonly pagination = computed( + () => this.table.atoms.pagination.get(), + { equal: shallow }, // structural equality — skip downstream work on no-op updates +) +``` + +### When do I need `computed(...)`? + +You **don't need `computed`** just to make an atom reactive. The atom is already +signal-backed. Use `computed(...)` only when you want: + +1. **Derivation** — `computed(() => this.table.atoms.pagination.get().pageIndex)` +2. **Custom equality** — `{ equal: shallow }` on object/array slices, so + downstream `effect`s skip no-op updates when the slice is recreated with the + same values. +3. **Caching of an expensive transformation** that reads from multiple atoms. + +For plain reads in a template, `{{ table.atoms.pagination.get().pageIndex }}` +is fine. + +--- + +## 4. Setting state — use feature APIs, not direct writes + +TanStack Table exposes a method for nearly every state transition. **Use those +methods.** Don't reimplement what's already in the public API — that's the #1 +tell of AI-generated table code. + +```ts +// ✅ Use the API +this.table.setPageIndex(0) +this.table.nextPage() +this.table.setPageSize(25) +this.table.setSorting([{ id: 'age', desc: true }]) +this.table.setColumnFilters([{ id: 'status', value: 'active' }]) +this.table.toggleAllRowsSelected(true) +this.table.resetSorting() +this.table.resetPagination() +this.table.resetPagination(true) // reset to feature default, not initialState +row.toggleSelected() +column.toggleVisibility() +column.toggleSorting() + +// ❌ Don't write to atoms directly unless you really have to +this.table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) +``` + +Direct `baseAtoms` writes bypass `on[State]Change` handlers and won't update +externally owned state — if you've controlled the slice with an Angular signal, +you must update the signal, not the base atom. + +--- + +## 5. Setting starting values — `initialState` + +`initialState` is the single right place to seed registered slices. It is also +the value that reset APIs reset to. + +```ts +readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + initialState: { + sorting: [{ id: 'age', desc: true }], + pagination: { pageIndex: 0, pageSize: 25 }, + }, +})) + +// Later +this.table.resetSorting() // → initialState.sorting +this.table.resetSorting(true) // → feature default ([]) +``` + +`initialState` only applies to slices whose feature is registered. Mutating +`initialState` after construction does **not** re-seed state — use it for +starting values only. + +--- + +## 6. Controlled state — the recommended Angular pattern + +Most Angular Table apps that need cross-component access to a state slice use +**Angular signals + `state` + `on[State]Change`**. This keeps ownership in +Angular's signal model while `injectTable` keeps the table in sync. + +```ts +import { signal } from '@angular/core' +import { + injectTable, + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + type PaginationState, + type SortingState, +} from '@tanstack/angular-table' + +const _features = tableFeatures({ rowPaginationFeature, rowSortingFeature }) + +export class Component { + readonly data = signal>([]) + readonly sorting = signal([]) + readonly pagination = signal({ pageIndex: 0, pageSize: 10 }) + + readonly table = injectTable(() => ({ + _features, + _rowModels: { + /* … */ + }, + columns, + data: this.data(), + state: { + sorting: this.sorting(), + pagination: this.pagination(), + }, + onSortingChange: (updater) => { + updater instanceof Function + ? this.sorting.update(updater) + : this.sorting.set(updater) + }, + onPaginationChange: (updater) => { + updater instanceof Function + ? this.pagination.update(updater) + : this.pagination.set(updater) + }, + })) +} +``` + +### `on[State]Change` rules + +- **Always pass an updater-or-value handler.** TanStack Table calls + `on[State]Change(updaterOrValue)` where `updaterOrValue` is either a new value + or `(old) => new` — check with `instanceof Function` / `typeof === 'function'`. +- **Pair `on[State]Change` with `state.`.** Providing + `onPaginationChange` without `state.pagination` will result in your callback + firing but the table reading its own internal atom — confusing. +- **The v8 `onStateChange` (single global handler) is gone in v9.** Slices are + controlled individually. + +### Don't double-own a slice + +For any given slice, exactly one of these should be the source of truth: + +- `initialState.` (uncontrolled, internal) +- `state.` + `on[State]Change` (controlled by Angular signal) +- `atoms.` (controlled by external TanStack Store atom — see §7) + +If you supply both `state.x` and `atoms.x`, the external atom wins silently. If +you supply both `initialState.x` and `state.x`, `state.x` wins. Pick one. + +--- + +## 7. Beyond signals: external atoms, state types, app-wide hooks + +For most Angular apps, **signals + `state` + `on[State]Change` (§6) is the +right ownership model.** When you need more, see +[`references/external-atoms-and-app-hook.md`](references/external-atoms-and-app-hook.md): + +- **External TanStack Store atoms** — `atoms: { pagination: paginationAtom }` + for slices owned by `@tanstack/store` / `@tanstack/angular-store`, when + multiple non-table parts of the app share the slice. +- **State type imports** — `PaginationState`, `SortingState`, + `RowSelectionState`, `TableState`, etc. +- **`createTableHook(...)`** — app-wide `injectAppTable` / + `createAppColumnHelper` that pre-bind `_features` and `_rowModels`. Also + exposes `tableComponents` / `cellComponents` / `headerComponents` registries + (covered in `angular-rendering-directives`). + +--- + +## Failure modes + +### 1. (CRITICAL) Hallucinating v8 `createAngularTable` / pre-v9 APIs + +```ts +// ❌ v8 — does not exist in v9 +import { createAngularTable, getCoreRowModel } from '@tanstack/angular-table' +const table = createAngularTable(() => ({ + columns, + data: data(), + getCoreRowModel: getCoreRowModel(), +})) + +// ✅ v9 +import { injectTable, tableFeatures } from '@tanstack/angular-table' +const _features = tableFeatures({}) +const table = injectTable(() => ({ + _features, + _rowModels: {}, + columns, + data: data(), +})) +``` + +Also retired: `getFilteredRowModel`, `getSortedRowModel`, `getPaginationRowModel` +as top-level options → migrated to `_rowModels: { filteredRowModel: ..., sortedRowModel: ..., paginatedRowModel: ... }` +with explicit `*Fns` parameters. + +### 2. (CRITICAL) Missing API because feature not in `_features` + +`table.atoms.rowSelection`, `table.toggleAllRowsSelected`, +`row.getCanSelect`, `column.getCanSort` etc. are **only** present when the +matching feature is in `_features`. The fix is to add the feature, not to +patch around it. + +### 3. (CRITICAL) Reimplementing built-in state transitions + +```ts +// ❌ DON'T +this.pagination.update((p) => ({ ...p, pageIndex: p.pageIndex + 1 })) + +// ✅ +this.table.nextPage() +``` + +Same for `setPageIndex`, `setPageSize`, `setSorting`, `toggleSorting`, +`setColumnFilters`, `setGlobalFilter`, `toggleSelected`, `toggleAllRowsSelected`, +`setColumnVisibility`, `setColumnOrder`, `setExpanded`, `toggleExpanded`, +`resetSorting`, `resetPagination`, `resetRowSelection`, etc. + +### 4. (HIGH) Expensive values declared **inside** the `injectTable` initializer + +Because the initializer re-runs when any reactive read inside it changes, +declaring `columns`, `_features`, `_rowModels`, or feature-fn maps inside the +function causes them to be recreated and re-applied on every data update. +Move them outside the class or to stable class fields. + +### 5. (HIGH) Forgetting that the initializer re-runs + +If you `console.log` inside the `injectTable` initializer, you'll see it fire +multiple times during the component lifetime — that's correct. The adapter +handles the diff and calls `table.setOptions`. Don't kick off side-effects from +inside the initializer; put them in an `effect(...)` reading the relevant +atoms. + +Lower-severity failure modes (MEDIUM/LOW: `state.x` vs `atoms.x` conflict, +updater-fn handling in `on[State]Change`, in-place mutation of state values, +premature `computed` wrapping) → +[`references/external-atoms-and-app-hook.md`](references/external-atoms-and-app-hook.md#lower-severity-failure-modes-mediumlow). + +--- + +## References + +- [External TanStack Store atoms, state types, `createTableHook` setup, and MEDIUM failure modes](references/external-atoms-and-app-hook.md) + +--- + +## See also + +- `tanstack-table/angular/getting-started` — end-to-end first table +- `tanstack-table/angular/angular-rendering-directives` — `*flexRender*`, DI tokens, `flexRenderComponent` +- `tanstack-table/angular/migrate-v8-to-v9` — v8 → v9 mechanical mapping +- `tanstack-table/angular/compose-with-tanstack-store` — external atoms in depth +- `tanstack-table/angular/client-to-server` — controlled state for server-driven tables +- `tanstack-table/core/state-management` — framework-agnostic atom model diff --git a/packages/angular-table/skills/angular/table-state/references/external-atoms-and-app-hook.md b/packages/angular-table/skills/angular/table-state/references/external-atoms-and-app-hook.md new file mode 100644 index 0000000000..e02eac1d13 --- /dev/null +++ b/packages/angular-table/skills/angular/table-state/references/external-atoms-and-app-hook.md @@ -0,0 +1,152 @@ +# External Atoms & `createTableHook` — Full Reference + +Extended ownership options beyond signals + `state` + `on[State]Change`. + +--- + +## External TanStack Store atoms — alternative ownership + +The core `atoms` table option is re-exported from Angular Table. Use it when: + +- You already have a `Store` / `Atom` from `@tanstack/store` or + `@tanstack/angular-store` that should drive a table slice. +- Multiple non-table parts of the app need to read/write the slice through the + same atom (e.g. URL sync, persistence, multi-table coordination). + +**For most Angular apps, prefer signals + `state` first.** Atoms are the +cross-app sharing alternative, not the default. See +`compose-with-tanstack-store` for the full pattern. + +```ts +import { Store } from '@tanstack/store' + +const paginationAtom = new Store({ pageIndex: 0, pageSize: 10 }) + +readonly table = injectTable(() => ({ + _features, + _rowModels: { /* … */ }, + columns, + data: this.data(), + atoms: { + pagination: paginationAtom, // external atom owns the slice + }, +})) +``` + +When `atoms.pagination` is registered, `table.atoms.pagination.get()` reads from +the external atom (not the internal base atom). `table.setPagination(...)` +writes through the external atom's setter. + +--- + +## State types + +Import slice types directly from `@tanstack/angular-table`: + +```ts +import type { + PaginationState, + SortingState, + RowSelectionState, + ColumnFiltersState, + GlobalFilterTableState, + ColumnVisibilityState, + ColumnOrderState, + ColumnPinningState, + ColumnSizingState, + ColumnResizingState, + ExpandedState, + GroupingState, + TableState, +} from '@tanstack/angular-table' + +// TableState is inferred from registered features +type MyTableState = TableState +``` + +--- + +## `createTableHook` — app-wide table infrastructure + +When multiple tables in an app share the same `_features`, `_rowModels`, and +component conventions, factor them into a `createTableHook(...)` call once and +import the resulting `injectAppTable` / `createAppColumnHelper`. + +```ts +// app-table.ts +import { + createTableHook, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + createSortedRowModel, + createPaginatedRowModel, + sortFns, +} from '@tanstack/angular-table' + +export const { + injectAppTable, + createAppColumnHelper, + injectTableContext, + injectTableCellContext, + injectTableHeaderContext, +} = createTableHook({ + _features: tableFeatures({ rowSortingFeature, rowPaginationFeature }), + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + getRowId: (row) => row.id, +}) +``` + +Then in components: + +```ts +import { injectAppTable, createAppColumnHelper } from './app-table' + +const columnHelper = createAppColumnHelper() // only TData generic needed + +readonly table = injectAppTable(() => ({ + columns, + data: this.data(), +})) // _features & _rowModels inherited +``` + +`createTableHook` also lets you register `tableComponents` / `cellComponents` / +`headerComponents` registries — see `angular-rendering-directives` for that. + +--- + +## Lower-severity failure modes (MEDIUM/LOW) + +### Using `state.x` and `atoms.x` together for the same slice + +External atom silently wins. If you intended Angular-signal ownership, remove +`atoms.x` (or vice versa). Pick exactly one source of truth per slice. + +### Forgetting the updater function form in `on[State]Change` + +```ts +// ❌ Crashes when TanStack Table passes a function +onSortingChange: (value) => this.sorting.set(value) + +// ✅ +onSortingChange: (updater) => { + updater instanceof Function + ? this.sorting.update(updater) + : this.sorting.set(updater) +} +``` + +### Mutating `state` slice values in place + +`state: { sorting: this.sorting() }` snapshots the current value. Mutating the +underlying signal value with `.push()` won't trigger re-evaluation. Always +produce a new reference: `this.sorting.update(s => [...s, newSort])`. + +### Premature `computed` selector wrapping for a single read + +For a single atom read in a template (`{{ table.atoms.pagination.get().pageIndex }}`), +a wrapper `computed` adds no value. Reach for `computed` only for derivation, +shared selectors, or `{ equal: shallow }`. diff --git a/packages/lit-table/README.md b/packages/lit-table/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/lit-table/README.md +++ b/packages/lit-table/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/lit-table/package.json b/packages/lit-table/package.json index 9e7e3a9c4e..86f3224d00 100644 --- a/packages/lit-table/package.json +++ b/packages/lit-table/package.json @@ -18,7 +18,8 @@ "lit", "table", "lit-table", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "./dist/index.d.cts", @@ -45,7 +46,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/lit-table/skills/lit/compose-with-tanstack-virtual/SKILL.md b/packages/lit-table/skills/lit/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..05b1395474 --- /dev/null +++ b/packages/lit-table/skills/lit/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,263 @@ +--- +name: lit/compose-with-tanstack-virtual +description: > + TanStack Table does NOT include virtualization — pair with + `@tanstack/lit-virtual`. The standard pattern: get the row array from + `table.getRowModel().rows`, construct a `VirtualizerController(host, opts)` + alongside `TableController`, feed `rows.length` as the virtualizer count + inside `render()`, and render only `virtualizer.getVirtualItems()` with each + row absolutely positioned via `transform: translateY(...)`. Routing keywords: + lit-virtual, VirtualizerController, virtualization, virtualized-rows, lit + table. +type: composition +library: tanstack-table +framework: lit +library_version: '9.0.0-alpha.47' +requires: + - lit/table-state + - row-expanding +sources: + - TanStack/table:docs/guide/virtualization.md + - TanStack/table:examples/lit/virtualized-rows/src/main.ts + - TanStack/table:examples/react/virtualized-rows/ + - TanStack/table:examples/react/virtualized-columns/ +--- + +> **Maintainer note:** the Lit adapter is scheduled for a rewrite alongside TanStack Lit Store during the v9 beta cycle. APIs in this skill may change in a future beta. The patterns below match `9.0.0-alpha.47`. + +TanStack Table is headless — it does not virtualize rows or columns. For long lists, pair the table with `@tanstack/lit-virtual`, which ships `VirtualizerController` — a `ReactiveController` like `TableController`. + +## Install + +```bash +npm install @tanstack/lit-table @tanstack/lit-virtual +``` + +## The Pattern (Row Virtualization) + +1. Build the table with `TableController` as usual. +2. Construct a `VirtualizerController(host, opts)` once. Capture a `Ref` for the scroll element. +3. Inside `render()`, get `rows = table.getRowModel().rows`, then call `virtualizer.setOptions({ ..., count: rows.length })`. +4. Render only `virtualizer.getVirtualItems()`. Each virtual row is absolutely positioned via `transform: translateY(${item.start}px)`. +5. Attach `ref` on each row to call `virtualizer.measureElement(...)` for dynamic sizing. + +```ts +import { LitElement, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { styleMap } from 'lit/directives/style-map.js' +import { Ref, createRef, ref } from 'lit/directives/ref.js' +import { VirtualizerController } from '@tanstack/lit-virtual' +import { + FlexRender, + TableController, + columnSizingFeature, + createSortedRowModel, + rowSortingFeature, + sortFns, + tableFeatures, + type ColumnDef, +} from '@tanstack/lit-table' + +const _features = tableFeatures({ columnSizingFeature, rowSortingFeature }) + +const columns: Array> = [ + { accessorKey: 'id', header: 'ID', size: 60 }, + { + accessorKey: 'firstName', + header: 'First', + cell: (info) => info.getValue(), + }, + // … +] + +@customElement('virtualized-table') +class VirtualizedTable extends LitElement { + @state() + private _data: Person[] = makeData(50_000) + + private tableController = new TableController(this) + private rowVirtualizerController!: VirtualizerController + private tableContainerRef: Ref = createRef() + + connectedCallback() { + this.rowVirtualizerController = new VirtualizerController(this, { + count: this._data.length, + getScrollElement: () => this.tableContainerRef.value!, + estimateSize: () => 33, + overscan: 5, + }) + super.connectedCallback() + } + + protected render() { + const table = this.tableController.table( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this._data, + }, + () => ({}), + ) + + const { rows } = table.getRowModel() + + // Sync the virtualizer count with the post-feature rows. + const virtualizer = this.rowVirtualizerController.getVirtualizer() + virtualizer.setOptions({ ...virtualizer.options, count: rows.length }) + + return html` +
+
+ {{ value }} + @switch (header.column.getIsSorted()) { @case ('asc') { ▲ } @case ('desc') { ▼ + } } + + {{ rendered }} +{{ value }}{{ value }}{{ value }} + {{ value }} + + +
+ + ${repeat( + table.getHeaderGroups(), + (hg) => hg.id, + (hg) => html` + + ${repeat( + hg.headers, + (h) => h.id, + (h) => html` + + `, + )} + + `, + )} + + + ${repeat( + virtualizer.getVirtualItems(), + (item) => item.key, + (item) => { + const row = rows[item.index] + return html` + virtualizer.measureElement(node ?? null))} + > + ${repeat( + row.getAllCells(), + (c) => c.id, + (cell) => html` + + `, + )} + + ` + }, + )} + +
+ ${FlexRender({ header: h })} +
+ ${FlexRender({ cell })} +
+
+ ` + } +} +``` + +Source: `examples/lit/virtualized-rows/src/main.ts`. + +## Column Virtualization + +Same shape, but the virtualizer's `count` is `columns.length` (or visible columns) and you index visible columns inside each row. Useful for wide kitchen-sink tables. The horizontal virtualizer's options include `horizontal: true`. + +## With Pagination / Filtering + +Always use `table.getRowModel().rows.length` as the count — that's the post-feature row array (sorted, filtered, paginated). The virtualizer should never wrap the raw `data` array. + +## With `@tanstack/lit-table`'s `Subscribe` + +The current Lit adapter wires host invalidation through the full store, so re-renders are already triggered when slices change. Use `table.Subscribe` for render-time projections; don't expect source-mode invalidation savings yet. +Source: `packages/lit-table/src/TableController.ts`. + +## Common Mistakes + +### CRITICAL Reimplementing virtualization by hand + +Wrong: manual slicing + intersection observers + per-row offset math. + +Correct: use `@tanstack/lit-virtual`'s `VirtualizerController`. It handles measurement, overscan, scroll alignment, and dynamic sizing. +Source: `docs/guide/virtualization.md`. + +### HIGH Using the wrong row source + +Wrong: + +```ts +new VirtualizerController(this, { count: this._data.length /* … */ }) +``` + +Correct: + +```ts +const { rows } = table.getRowModel() +virtualizer.setOptions({ ...virtualizer.options, count: rows.length }) +``` + +Always count post-feature rows, not raw data. +Source: `examples/lit/virtualized-rows/src/main.ts`. + +### HIGH Constructing `VirtualizerController` inside `render()` + +Wrong: new controller per frame. + +Correct: construct once (typically in `connectedCallback`) and call `setOptions` per render to sync `count`. +Source: `examples/lit/virtualized-rows/src/main.ts` (lines 77–85). + +### HIGH Forgetting `position: relative` on the scroll parent / `position: absolute` on rows + +Wrong: rows stack at the top because there's no positioned ancestor with the total height. + +Correct: scroll parent uses `position: relative`, `
+ + ${repeat( + table.getHeaderGroups(), + (hg) => hg.id, + (hg) => html` + + ${repeat( + hg.headers, + (h) => h.id, + (h) => html` + + `, + )} + + `, + )} + + + ${repeat( + table.getRowModel().rows, + (r) => r.id, + (row) => html` + + ${repeat( + row.getAllCells(), + (c) => c.id, + (cell) => html` `, + )} + + `, + )} + + + ${repeat( + table.getFooterGroups(), + (fg) => fg.id, + (fg) => html` + + ${repeat( + fg.headers, + (h) => h.id, + (h) => html` + + `, + )} + + `, + )} + +
+ ${h.isPlaceholder ? null : FlexRender({ header: h })} +
${FlexRender({ cell })}
+ ${h.isPlaceholder ? null : FlexRender({ footer: h })} +
+ ` + } +} +``` + +Source: `examples/lit/basic-table-controller/src/main.ts` (lines 53–162). + +## Step 5 — Drive features with feature APIs + +```ts + + +``` + +For starting values, use `initialState`. For controlled slices, use `atoms` or `state` + `on*Change` — see `tanstack-table/lit/table-state`. + +## Common Mistakes + +### CRITICAL `new TableController(this)` inside `render()` + +Wrong: + +```ts +protected render() { + const controller = new TableController(this) // every frame + const table = controller.table({ /* … */ }) +} +``` + +Correct: + +```ts +private tableController = new TableController(this) // once per host +``` + +A new controller per render registers a new subscription and resets table state every frame. +Source: `packages/lit-table/src/TableController.ts`. + +### CRITICAL Calling a feature API when the feature is not in `_features` + +Wrong: + +```ts +const _features = tableFeatures({}) // no rowPaginationFeature +const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data: this.data, +}) +table.setPageIndex(0) // TypeScript error AND runtime no-op +``` + +Correct: + +```ts +const _features = tableFeatures({ rowPaginationFeature }) +const table = this.tableController.table({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: this.data, +}) +``` + +v9 generates feature APIs and state slices only for registered features. #1 v9 trap. +Source: `docs/guide/features.md`. + +### HIGH Forgetting the matching row-model factory + +Wrong: + +```ts +const _features = tableFeatures({ rowSortingFeature }) +const table = this.tableController.table({ _features, _rowModels: {} /* … */ }) +table.setSorting([{ id: 'age', desc: true }]) +// rows are NOT sorted — no sortedRowModel registered +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSortingFeature }) +const table = this.tableController.table({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + /* … */ +}) +``` + +### HIGH Building `_features` / `columns` / `data` inside `render()` + +Wrong: re-creating these every frame busts internal memos. + +Correct: `_features` and `columns` at module scope; `data` from a `@state()` field on the element. +Source: `docs/framework/lit/guide/table-state.md` (FAQ #1). + +### HIGH Reimplementing built-in feature logic + +Wrong: hand-rolled sorting / filtering / pagination outside the table. + +Correct: register the feature + factory and use feature APIs. v9 ships built-ins for sorting, filtering, pagination, grouping, expanding, faceting, row selection, column visibility/order/pinning/sizing, and row pinning. +Source: `docs/guide/features.md`. + +### MEDIUM Using v8 hook names (`useReactTable`, `getCoreRowModel`, `flexRender(def, ctx)`) + +Wrong: v8 imports from a Lit context. + +Correct: `@tanstack/lit-table` exposes `TableController` + `FlexRender({ cell | header | footer })`. There is no `useReactTable` here. See `tanstack-table/lit/migrate-v8-to-v9`. + +## See Also + +- `tanstack-table/lit/table-state` — atoms, Subscribe, createTableHook. +- `tanstack-table/lit/lit-table-controller` — controller lifecycle in depth. +- `tanstack-table/lit/migrate-v8-to-v9` — moving an existing v8 codebase over. diff --git a/packages/lit-table/skills/lit/lit-table-controller/SKILL.md b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md new file mode 100644 index 0000000000..2b8dc80df2 --- /dev/null +++ b/packages/lit-table/skills/lit/lit-table-controller/SKILL.md @@ -0,0 +1,311 @@ +--- +name: lit/lit-table-controller +description: > + The `TableController` ReactiveController pattern for hosting a TanStack Table + instance inside a LitElement. One controller per host (constructed in a class + field); `.table(options, selector?)` called from `render()`. The controller + installs the Lit `coreReativityFeature`, subscribes the host to `table.store` + and `table.optionsStore`, and tears those subscriptions down on + `hostDisconnected`. Routing keywords: TableController, ReactiveController, + ReactiveControllerHost, hostConnected, hostDisconnected, lit-table. +type: framework +library: tanstack-table +framework: lit +library_version: '9.0.0-alpha.47' +requires: + - lit/table-state +sources: + - TanStack/table:docs/framework/lit/lit-table.md + - TanStack/table:docs/framework/lit/guide/table-state.md + - TanStack/table:packages/lit-table/src/TableController.ts + - TanStack/table:packages/lit-table/src/reactivity.ts + - TanStack/table:examples/lit/basic-table-controller/src/main.ts +--- + +> **Maintainer note:** the Lit adapter is scheduled for a rewrite alongside TanStack Lit Store during the v9 beta cycle. `TableController`'s invalidation model and `Subscribe` mode may change in a future beta. The patterns below match `9.0.0-alpha.47`. + +`TableController` is the Lit-specific entry point for `@tanstack/lit-table`. It implements the Lit `ReactiveController` interface, hosts the underlying core `Table` instance, and bridges TanStack Store atom changes to `host.requestUpdate()` calls. This skill explains the lifecycle in detail. + +## What `TableController` actually does + +```ts +export class TableController implements ReactiveController { + constructor(host: ReactiveControllerHost) { + ;(this.host = host).addController(this) // registers controller on host + } + + public table(tableOptions, selector?) { + if (!this._table) { + // First call: build the core table with the Lit reactivity bindings. + this._table = constructTable({ + ...tableOptions, + _features: { + coreReativityFeature: litReactivity(), + ...tableOptions._features, + }, + mergeOptions: (def, next) => ({ ...def, ...next }), + }) + this._setupSubscriptions() + } + // Subsequent calls: merge new options. + this._table.setOptions((prev) => ({ ...prev, ...tableOptions })) + + return { + /* ...this._table, Subscribe, FlexRender, get state() {...} */ + } + } + + private _setupSubscriptions() { + this._storeSubscription = this._table.store.subscribe(() => + this.host.requestUpdate(), + ) + this._optionsSubscription = this._table.optionsStore!.subscribe(() => + this.host.requestUpdate(), + ) + } + + hostConnected() { + this._setupSubscriptions() + } + hostDisconnected() { + this._storeSubscription?.unsubscribe() + this._optionsSubscription?.unsubscribe() + } +} +``` + +Source: `packages/lit-table/src/TableController.ts` (full file). + +Key points: + +1. **One core table per controller.** The first `.table(options)` call constructs it; later calls merge options into the same instance. +2. **Two subscriptions:** `table.store` (state) and `table.optionsStore` (options). Both call `host.requestUpdate()`. +3. **Subscriptions are torn down on `hostDisconnected`** and reset on `hostConnected`. +4. **`Subscribe` is whole-store.** The current adapter does not split host invalidation by source; `table.Subscribe` reads its source at render time, but the host still re-renders on any store change. + +## Lifecycle Diagram + +```text + constructor render() hostDisconnected + │ │ │ + ▼ ▼ ▼ +host.addController(this) this.tableController.table(opts) unsubscribe(store) + │ unsubscribe(options) + ▼ + (first call) constructTable(opts) + _setupSubscriptions + (later calls) table.setOptions(prev => ({ ...prev, ...opts })) + │ + ▼ + returns { ...table, Subscribe, FlexRender, state } +``` + +## Canonical Setup + +```ts +import { LitElement, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { + FlexRender, + TableController, + tableFeatures, + type ColumnDef, +} from '@tanstack/lit-table' + +const _features = tableFeatures({}) // module scope + +const columns: Array> = [ + /* … module scope … */ +] + +@customElement('people-table') +class PeopleTable extends LitElement { + // ONE controller, constructed as a class field. + private tableController = new TableController(this) + + @state() + private data: Person[] = [] + + protected render() { + const table = this.tableController.table( + { + _features, + _rowModels: {}, + columns, + data: this.data, + }, + () => ({}), // selector — empty when you don't need to project state + ) + + return html` + + + ${repeat( + table.getHeaderGroups(), + (hg) => hg.id, + (hg) => html` + + ${repeat( + hg.headers, + (h) => h.id, + (h) => html``, + )} + + `, + )} + + + ${repeat( + table.getRowModel().rows, + (r) => r.id, + (row) => html` + + ${repeat( + row.getAllCells(), + (c) => c.id, + (cell) => html``, + )} + + `, + )} + +
${FlexRender({ header: h })}
${FlexRender({ cell })}
+ ` + } +} +``` + +Source: `examples/lit/basic-table-controller/src/main.ts`. + +## Multiple Tables in One Host + +Each table needs its own controller. Don't try to share one across instances. + +```ts +class DashboardElement extends LitElement { + private peopleController = new TableController< + typeof _peopleFeatures, + Person + >(this) + private projectsController = new TableController< + typeof _projectsFeatures, + Project + >(this) + + protected render() { + const people = this.peopleController.table({ + /* … */ + }) + const projects = this.projectsController.table({ + /* … */ + }) + + return html` + + + ` + } +} +``` + +## Reading State Off the Controller + +The controller's `.table(...)` return value carries everything you usually need: feature methods, `FlexRender`, `Subscribe`, and the `state` projection. Direct reads off `table.atoms..get()` and `table.store.state.` are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up. + +```ts +// Inside render(): +const pagination = table.atoms.pagination.get() // current value +const snapshot = table.store.state // current full state +const selected = table.state // projected via the selector you passed to .table() +``` + +## Common Mistakes + +### CRITICAL Creating a new `TableController` per render + +Wrong: + +```ts +protected render() { + const controller = new TableController(this) // every frame + const table = controller.table({ /* … */ }) +} +``` + +Correct: construct the controller once as a class field. + +```ts +private tableController = new TableController(this) + +protected render() { + const table = this.tableController.table({ /* … */ }) +} +``` + +Each new controller installs another subscription on the host; old controller state is discarded; table state resets every frame. +Source: `packages/lit-table/src/TableController.ts`. + +### CRITICAL Calling `.table()` outside `render()` and caching the return value + +Wrong: + +```ts +connectedCallback() { + super.connectedCallback() + this.cachedTable = this.tableController.table({ _features, _rowModels: {}, columns, data: this.data }) +} + +protected render() { + return html`${this.cachedTable.getRowModel().rows.map(/* … */)}` +} +``` + +Correct: call `.table()` each `render()`. The options are merged into the same logical table on each call, and the returned object carries fresh state/projections. + +```ts +protected render() { + const table = this.tableController.table({ _features, _rowModels: {}, columns, data: this.data }) + return html`${table.getRowModel().rows.map(/* … */)}` +} +``` + +Source: `packages/lit-table/src/TableController.ts`. + +### HIGH Forgetting that `Subscribe` re-renders the host on any store change + +Wrong: assuming `table.Subscribe({ source: table.atoms.rowSelection, … })` makes the host invalidate only on selection changes. + +Correct: in the current adapter, the host's `requestUpdate()` is wired to the full `table.store` and `table.optionsStore`. `Subscribe` is a render-time projection convenience; it does not narrow host invalidation. Plan accordingly for large lists. +Source: `packages/lit-table/src/TableController.ts` (lines 200–218 + `_setupSubscriptions`). + +### HIGH Building `_features` inside `render()` + +Wrong: + +```ts +protected render() { + const _features = tableFeatures({ rowSortingFeature }) // new each frame + const table = this.tableController.table({ _features, /* … */ }) +} +``` + +Correct: declare `_features` at module scope (or once on the class, frozen). Identity drives internal memos. + +```ts +const _features = tableFeatures({ rowSortingFeature }) +``` + +Source: `docs/framework/lit/guide/table-state.md` (FAQ #1). + +### MEDIUM Calling `.table()` from a non-host context (e.g. a child component) + +Wrong: passing the controller down and calling `.table()` from a different LitElement. The subscriptions belong to the host that constructed the controller — calling from elsewhere is undefined behavior. + +Correct: each LitElement that needs its own table builds its own controller. Use `createTableHook`'s `useTableContext` / `useCellContext` / `useHeaderContext` (`@lit/context`) to access a table from descendant elements. +Source: `packages/lit-table/src/createTableHook.ts`. + +## See Also + +- `tanstack-table/lit/table-state` — atoms, Subscribe, FlexRender, createTableHook. +- `tanstack-table/lit/getting-started` — first-table walkthrough. +- `tanstack-table/lit/compose-with-tanstack-virtual` — pairing with `@tanstack/lit-virtual`. diff --git a/packages/lit-table/skills/lit/migrate-v8-to-v9/SKILL.md b/packages/lit-table/skills/lit/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..0fa420bcec --- /dev/null +++ b/packages/lit-table/skills/lit/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,308 @@ +--- +name: lit/migrate-v8-to-v9 +description: > + Mechanical breaking-change migration from TanStack Table v8 to v9 for + `@tanstack/lit-table`. v8's `TableController(host, () => options)` shape + collapses to v9's `new TableController(host)` + `.table(options, selector?)`; + per-row-model `get*RowModel` options become `_features` + `_rowModels`; + `flexRender(def, ctx)` becomes `FlexRender({ cell|header|footer })`; core + types gain a `TFeatures` first generic. Routing keywords: lit v8 to v9, + migration, TableController v8, get*RowModel, _features lit. +type: lifecycle +library: tanstack-table +framework: lit +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - TanStack/table:docs/framework/lit/lit-table.md + - TanStack/table:docs/framework/lit/guide/table-state.md + - TanStack/table:docs/framework/react/guide/use-legacy-table.md + - TanStack/table:packages/lit-table/src/TableController.ts +--- + +> **Maintainer note:** the Lit adapter is scheduled for a rewrite alongside TanStack Lit Store during the v9 beta cycle. APIs in this skill may change in a future beta. The patterns below match `9.0.0-alpha.47`. + +The Lit v9 adapter mirrors v9's React surface (atoms, `_features`, `_rowModels`, FlexRender) wrapped in a `ReactiveController`. There is no `useLegacyTable` shim — migrate directly. + +## The Core Mapping + +| v8 (`@tanstack/lit-table`) | v9 (`@tanstack/lit-table`) | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `new TableController(host, () => options)` | `new TableController(host)` then `.table(options, selector?)` | +| `controller.table` (property) | `controller.table(opts, selector?)` (method, called each `render()`) | +| `getCoreRowModel: getCoreRowModel()` option | core row model included by default — `_rowModels: {}` valid | +| `getSortedRowModel: getSortedRowModel()` | `_features: { rowSortingFeature }` + `_rowModels: { sortedRowModel: createSortedRowModel(sortFns) }` | +| `getFilteredRowModel`, `getPaginationRowModel`, etc. | matching `*Feature` + matching `_rowModels` factory | +| `flexRender(def, ctx)` | `FlexRender({ cell })` / `FlexRender({ header })` / `FlexRender({ footer })` | +| `state` + `on*Change` only | still supported; new `atoms` per-slice option preferred | +| `createColumnHelper()` | `createColumnHelper()` | +| `ColumnDef` | `ColumnDef` | +| `Table` | `Table` | + +Source: `docs/framework/lit/lit-table.md`; `packages/lit-table/src/TableController.ts`. + +## Migration Steps + +### 1. Update the controller construction + +```ts +// v8 — controller takes a thunk that returns options. +private tableController = new TableController(this, () => ({ + columns, + data: this.data, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), +})) + +protected render() { + const table = this.tableController.table // property + return html`...` +} +``` + +```ts +// v9 — controller takes only the host. Options pass to .table(...) inside render. +private tableController = new TableController(this) + +protected render() { + const table = this.tableController.table( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this.data, + }, + () => ({}), // optional selector + ) + + return html`...` +} +``` + +### 2. Replace `get*RowModel` options with `_features` + `_rowModels` + +```ts +// v8 +{ + columns, data, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), +} + +// v9 +const _features = tableFeatures({ + rowSortingFeature, + columnFilteringFeature, + rowPaginationFeature, +}) + +{ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, data, +} +``` + +Move `_features` to module scope. Reference identity matters. +Source: `docs/framework/lit/lit-table.md`. + +### 3. Update column types and helpers + +```ts +// v8 +const columnHelper = createColumnHelper() +const columns: ColumnDef[] = columnHelper.columns([ + /* … */ +]) + +// v9 +const columnHelper = createColumnHelper() +const columns: Array> = + columnHelper.columns([ + /* … */ + ]) +``` + +`TFeatures` is now the first generic on `ColumnDef`, `Table`, and `createColumnHelper`. + +### 4. Replace `flexRender` calls + +```ts +// v8 +
${flexRender(cell.column.columnDef.cell, cell.getContext())}${FlexRender({ cell })}${FlexRender({ header })}${FlexRender({ footer: header })}
+ + ${repeat( + table.getHeaderGroups(), + (hg) => hg.id, + (hg) => html` + + ${repeat( + hg.headers, + (h) => h.id, + (h) => html` + + `, + )} + + `, + )} + + + ${repeat( + table.getRowModel().rows, + (r) => r.id, + (row) => html` + + ${repeat( + row.getAllCells(), + (c) => c.id, + (cell) => html` `, + )} + + `, + )} + +
+ ${h.isPlaceholder ? null : FlexRender({ header: h })} +
${FlexRender({ cell })}
+ ` + } +} +``` + +Source: `examples/lit/basic-table-controller/src/main.ts`. + +## Core Patterns + +### 1. `TableController` lifecycle + +- Construct once per host (typically as a class field). The constructor calls `host.addController(this)`. +- Call `.table(options, selector?)` inside `render()` (or any place you have a fresh `options` ready). The first call constructs the underlying core table and subscribes the host to `table.store` and `table.optionsStore`. Subsequent calls merge options and return the same logical table instance. +- `hostConnected` re-establishes subscriptions; `hostDisconnected` tears them down. + +Source: `packages/lit-table/src/TableController.ts`. + +### 2. `.table(options, selector?)` second argument + +The selector is a function from full table state to whatever you want exposed on `table.state`. Default is full state. Narrowing helps document the host's actual data dependencies; **host invalidation is still routed through the full `table.store` subscription**, so source-scoped subscriptions are not yet a guarantee of source-only re-renders. + +```ts +const table = this.tableController.table( + { _features, _rowModels: {}, columns, data: this._data }, + (state) => ({ pagination: state.pagination }), +) + +table.state.pagination +``` + +Source: `docs/framework/lit/guide/table-state.md`. + +### 3. Reading state without subscribing + +Direct atom / store reads return the current value without subscribing to changes. The controller already subscribes the host to the full store, so these reads stay reactive through the host's invalidation. + +```ts +const pagination = table.atoms.pagination.get() +const sorting = table.atoms.sorting.get() +const snapshot = table.store.state +``` + +### 4. `table.Subscribe` in templates + +Use `table.Subscribe` to project a slice during render. It reads the current value at template time. **In the current Lit adapter, host invalidation is wired through the full `table.store` subscription** — treat source mode as a render-time selection convenience. + +```ts +${table.Subscribe({ + selector: (s) => s.pagination, + children: (pagination) => html`Page ${pagination.pageIndex + 1}`, +})} + +// source mode +${table.Subscribe({ + source: table.atoms.rowSelection, + children: (rs) => html`${Object.keys(rs).length} selected`, +})} +``` + +Source: `packages/lit-table/src/TableController.ts` (lines 200–218). + +### 5. External atoms with `createAtom` + `options.atoms` + +Move slice ownership to a TanStack Store atom. The table writes to your atom when you call `table.setSorting(...)` etc. — no `on*Change` handler is needed. + +Precedence: `options.atoms[key]` > `options.state[key]` > internal `baseAtoms[key]`. + +```ts +import { createAtom } from '@tanstack/store' +import { + TableController, + rowPaginationFeature, + tableFeatures, + type PaginationState, +} from '@tanstack/lit-table' + +const _features = tableFeatures({ rowPaginationFeature }) + +// Module-scope atoms — stable identity, shareable across components. +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) + +@customElement('my-table') +class MyTable extends LitElement { + private tableController = new TableController(this) + + protected render() { + const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data: this._data, + atoms: { pagination: paginationAtom }, + }) + + const { pageIndex } = paginationAtom.get() + // ... + } +} +``` + +Source: `examples/lit/basic-external-atoms/src/main.ts`. + +### 6. External state with `state` + `on*Change` + +Classic integration with `@state()` properties. Less atomic than external atoms. + +```ts +@state() +private _sorting: SortingState = [] + +protected render() { + const table = this.tableController.table({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data: this._data, + state: { sorting: this._sorting }, + onSortingChange: (updater) => { + this._sorting = updater instanceof Function ? updater(this._sorting) : updater + }, + }) +} +``` + +Source: `docs/framework/lit/guide/table-state.md`. + +### 7. `createTableHook` for reusable shared config + +Bundle `_features`, `_rowModels`, default options, and pre-bound cell/header components. You get `useAppTable(host, options, selector?)`, `createAppColumnHelper`, and `useTableContext` / `useCellContext` / `useHeaderContext` (Lit Context consumers). + +```ts +const { useAppTable, createAppColumnHelper } = createTableHook({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, +}) + +const columnHelper = createAppColumnHelper() +const columns = columnHelper.columns([ + /* … */ +]) + +@customElement('users-table') +class UsersTable extends LitElement { + @state() private data: Person[] = [] + + // NOTE: capture `this` before the options getter — inside the getter `this` + // refers to the options object. + private appTable = (() => { + const host = this + return useAppTable(this, { + columns, + get data() { + return host.data + }, + }) + })() + + protected render() { + const table = this.appTable.table() + + return html` + + + ${table.getRowModel().rows.map( + (row) => html` + + ${row + .getAllCells() + .map((c) => + table.AppCell( + c, + (cell) => html``, + ), + )} + + `, + )} + +
${cell.FlexRender()}
+ ` + } +} +``` + +Source: `examples/lit/basic-app-table/src/main.ts`; `packages/lit-table/src/createTableHook.ts`. + +## Common Mistakes + +### CRITICAL Creating a new `TableController` every render + +Wrong: + +```ts +protected render() { + const controller = new TableController(this) // new instance every render + const table = controller.table({ /* … */ }) +} +``` + +Correct: + +```ts +class MyTable extends LitElement { + private tableController = new TableController(this) // once + + protected render() { + const table = this.tableController.table({ + /* … */ + }) + } +} +``` + +Each `new TableController(host)` registers another controller on the host. The original table is discarded; the new one resubscribes; state is reset every render. +Source: `packages/lit-table/src/TableController.ts`. + +### CRITICAL Calling a feature API when the feature is not in `_features` + +Wrong: + +```ts +const _features = tableFeatures({}) // no rowPaginationFeature +const table = this.tableController.table({ + _features, + _rowModels: {}, + columns, + data: this._data, +}) +table.setPageIndex(0) // TypeScript error AND runtime no-op +``` + +Correct: + +```ts +const _features = tableFeatures({ rowPaginationFeature }) +const table = this.tableController.table({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: this._data, +}) +``` + +v9 generates feature APIs and state slices only for registered features. The missing-feature failure is the #1 v9 trap. +Source: `docs/guide/features.md`. + +### HIGH Forgetting that `table.Subscribe` invalidates the host on any store change + +Wrong: assuming `` only re-renders the host on row-selection changes. + +Correct: in the current adapter, every store change invalidates the host. Selection inside `table.Subscribe` projects the value, but the host still re-renders whenever the `table.store` subscription fires. Source-only invalidation is noted as "can be added later" in source. +Source: `packages/lit-table/src/TableController.ts`. + +### HIGH `this` binding in the options getter + +Wrong: + +```ts +private appTable = useAppTable(this, { + columns, + get data() { return this.data }, // `this` refers to the options object → infinite recursion +}) +``` + +Correct: + +```ts +private appTable = (() => { + const host = this + return useAppTable(this, { columns, get data() { return host.data } }) +})() +``` + +Source: `examples/lit/basic-app-table/src/main.ts` (lines 77–90). + +### HIGH Unstable `_features` / `columns` / `data` references + +Wrong: building `_features` or `columns` inside `render()` so a new array/object is allocated every frame. + +Correct: declare at module scope. For `data`, prefer a `@state()` field; for derived data, memoize where the dependency actually changes. +Source: `docs/framework/lit/guide/table-state.md` (FAQ #1). + +### HIGH Reimplementing built-in feature logic by hand + +Wrong: hand-rolled sorting / filtering / pagination outside the table. + +Correct: register the matching `*Feature` in `_features`, register its row-model factory in `_rowModels`, and use the feature APIs (`setSorting`, `setColumnFilters`, etc.). This is the #1 AI tell. +Source: `docs/guide/features.md`. + +### MEDIUM Passing the same slice via `atoms` AND `state` + +Wrong: + +```ts +this.tableController.table({ + /* … */, + atoms: { pagination: paginationAtom }, + state: { pagination: this._pagination }, // silently ignored + onPaginationChange: (u) => { /* never runs */ }, // silently ignored +}) +``` + +Correct: pick exactly one ownership path per slice. + +## See Also + +- `tanstack-table/lit/lit-table-controller` — TableController lifecycle in depth. +- `tanstack-table/lit/getting-started` — first-table walkthrough. +- `tanstack-table/lit/migrate-v8-to-v9` — moving an existing v8 codebase over. +- `tanstack-table/lit/compose-with-tanstack-virtual` — pairing with `@tanstack/lit-virtual`. diff --git a/packages/match-sorter-utils/README.md b/packages/match-sorter-utils/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/match-sorter-utils/README.md +++ b/packages/match-sorter-utils/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/preact-table-devtools/README.md b/packages/preact-table-devtools/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/preact-table-devtools/README.md +++ b/packages/preact-table-devtools/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/preact-table-devtools/package.json b/packages/preact-table-devtools/package.json index 6d17bf6529..1717b8a4cb 100644 --- a/packages/preact-table-devtools/package.json +++ b/packages/preact-table-devtools/package.json @@ -18,7 +18,8 @@ "preact", "tanstack", "table", - "devtools" + "devtools", + "tanstack-intent" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", @@ -44,7 +45,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "dependencies": { "@tanstack/devtools-utils": "^0.5.0", diff --git a/packages/preact-table-devtools/skills/preact/compose-with-tanstack-devtools/SKILL.md b/packages/preact-table-devtools/skills/preact/compose-with-tanstack-devtools/SKILL.md new file mode 100644 index 0000000000..05fe76382c --- /dev/null +++ b/packages/preact-table-devtools/skills/preact/compose-with-tanstack-devtools/SKILL.md @@ -0,0 +1,161 @@ +--- +name: preact/compose-with-tanstack-devtools +description: > + Wire up TanStack Devtools for TanStack Table in Preact. Mount `TanStackDevtools` + with `tableDevtoolsPlugin()` once at the app root and call + `useTanStackTableDevtools(table, name?)` after each `useTable` so the table is + registered as a devtools target. Live devtools are tree-shaken to no-ops in + production unless you import from `@tanstack/preact-table-devtools/production`. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - state-management + - preact/table-state +sources: + - TanStack/table:docs/devtools.md + - TanStack/table:packages/preact-table-devtools/src/index.ts + - TanStack/table:packages/preact-table-devtools/src/plugin.tsx + - TanStack/table:packages/preact-table-devtools/src/useTanStackTableDevtools.ts + - TanStack/table:packages/preact-table-devtools/src/production.ts +--- + +This skill builds on `tanstack-table/preact/table-state`. Read that first — the devtools panel inspects whatever table instance you register, so you need a working `useTable` before this skill is useful. + +## Setup + +Install the TanStack Devtools host and the Preact Table adapter: + +```sh +pnpm add @tanstack/preact-devtools @tanstack/preact-table-devtools +``` + +The recommended pattern has two parts: + +1. Mount `` once at the app root with `tableDevtoolsPlugin()`. +2. Call `useTanStackTableDevtools(table, name?)` right after every `useTable()`. + +```tsx +import { render } from 'preact' +import { useTable } from '@tanstack/preact-table' +import { TanStackDevtools } from '@tanstack/preact-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/preact-table-devtools' + +function UsersScreen() { + const table = useTable({ + _features, + _rowModels, + columns, + data, + }) + + // Register this table with the devtools panel. + useTanStackTableDevtools(table, 'Users Table') + + return +} + +render( + <> + + {/* Mount once, anywhere in the tree. */} + + , + document.getElementById('root')!, +) +``` + +`tableDevtoolsPlugin()` returns a plugin descriptor for the multi-panel TanStack Devtools UI. `useTanStackTableDevtools` is a `preact/hooks` `useEffect` wrapper that upserts/removes the registration target on mount/unmount and re-runs when `table` or `name` changes. + +## Patterns + +### Naming Tables + +The optional second argument labels the table in the panel selector. Without it, devtools assign fallback names like `Table 1` and `Table 2`. + +```tsx +useTanStackTableDevtools(table, 'Orders Table') +``` + +### Multiple Tables + +Register as many tables as you like. The Table panel renders a selector. Name each one. + +```tsx +function Dashboard() { + const ordersTable = useTable(ordersOptions) + const usersTable = useTable(usersOptions) + + useTanStackTableDevtools(ordersTable, 'Orders') + useTanStackTableDevtools(usersTable, 'Users') + + return +} +``` + +### Disabling Per Table + +`useTanStackTableDevtools` accepts an `enabled` option. When `false`, the registration is removed (the table disappears from the panel) but the hook still runs cleanly. + +```tsx +useTanStackTableDevtools(table, 'Users Table', { + enabled: import.meta.env.DEV && showTableDevtools, +}) +``` + +### Production Builds + +The default `@tanstack/preact-table-devtools` entrypoint swaps to no-op implementations when `process.env.NODE_ENV !== 'development'`. To ship the real devtools to production, switch BOTH imports to the `/production` entrypoint: + +```tsx +import { TanStackDevtools } from '@tanstack/preact-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/preact-table-devtools/production' +``` + +If you mix entrypoints (one from `/production`, one from the default), one side is a no-op in production and the panel will appear empty. + +### Conditional Devtools by Env + +For a code-split production-only devtools bundle, dynamically import the `/production` entrypoint behind a flag: + +```tsx +import { lazy, Suspense } from 'preact/compat' + +const TableDevtoolsRoot = lazy(async () => { + const { tableDevtoolsPlugin } = + await import('@tanstack/preact-table-devtools/production') + const { TanStackDevtools } = await import('@tanstack/preact-devtools') + return { + default: () => , + } +}) +``` + +## Common Mistakes + +### Forgetting to mount `` at the app root + +Calling `useTanStackTableDevtools(table)` alone does nothing visible — it only registers the table with the devtools target store. Without a `` somewhere in the tree, there is no panel to render the registration. Symptom: hook runs without errors, no devtools button appears. + +### Importing devtools from the default path in a prod-only bundle + +If you only deploy production builds, `@tanstack/preact-table-devtools` resolves to no-op implementations. The plugin will mount, but the panel will be empty. Use `@tanstack/preact-table-devtools/production` if you want the real devtools available there. + +### Accidentally shipping devtools to end users via `/production` + +The flip side: importing from `/production` in your default app bundle means every visitor downloads and runs the devtools UI. That is usually not what you want. Restrict `/production` imports to dev/preview entrypoints or code-split them behind a flag. + +### Calling `useTanStackTableDevtools` outside the component that owns the table + +The hook needs a `Table` instance to register. If you call it in a parent before `useTable` runs, or in a sibling that does not have access to the table, you pass `undefined` and nothing is registered. Always call it in the same component as `useTable`, immediately after. + +### Multiple tables without names + +Two `useTanStackTableDevtools(table)` calls without a name produce selector entries like `Table 1` / `Table 2`. When you have 3+ tables this becomes unusable. Always pass a descriptive name as the second argument. diff --git a/packages/preact-table/README.md b/packages/preact-table/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/preact-table/README.md +++ b/packages/preact-table/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/preact-table/package.json b/packages/preact-table/package.json index a214ce919d..45d11e37d4 100644 --- a/packages/preact-table/package.json +++ b/packages/preact-table/package.json @@ -18,7 +18,8 @@ "preact", "table", "preact-table", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "./dist/index.d.cts", @@ -45,7 +46,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/preact-table/skills/preact/client-to-server/SKILL.md b/packages/preact-table/skills/preact/client-to-server/SKILL.md new file mode 100644 index 0000000000..65f46a32bb --- /dev/null +++ b/packages/preact-table/skills/preact/client-to-server/SKILL.md @@ -0,0 +1,294 @@ +--- +name: preact/client-to-server +description: > + Convert a client-side `@tanstack/preact-table` to server-side (a.k.a. manual) + modes. Pass server-paginated data, set `manualSorting` / `manualFiltering` / + `manualPagination` / `manualGrouping` / `manualExpanding` for whatever the + server owns, supply `rowCount`, key external atoms for pagination/sorting/ + filters and trigger a refetch when they change. Routing keywords: server-side + pagination, manual pagination, manualSorting, manualFiltering, rowCount, + remote data preact. +type: lifecycle +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - state-management + - pagination + - filtering + - sorting + - preact/table-state +sources: + - TanStack/table:examples/preact/basic-external-atoms/src/main.tsx + - TanStack/table:examples/preact/with-tanstack-query/src/main.tsx + - TanStack/table:examples/preact/with-tanstack-query/src/fetchData.ts + - TanStack/table:docs/framework/preact/guide/table-state.md +--- + +Client-side tables run sort/filter/paginate through registered row-model factories. Server-side tables let the server own those stages; the table just renders what the server returned and emits state changes that the app uses to refetch. Same `_features`, same APIs — different ownership. + +## The Manual Flags + +Set the matching flag(s) to `true` to tell the table that the server (not the registered row-model factory) is doing that stage: + +| Flag | Owned by server | +| ------------------ | ----------------------- | +| `manualPagination` | page slicing | +| `manualSorting` | row ordering | +| `manualFiltering` | column + global filters | +| `manualGrouping` | group-by rows | +| `manualExpanding` | row expansion | + +The matching `*Feature` should still be in `_features` so its state slice exists and its APIs work — you are only telling the row-model pipeline to skip the transform. + +For pagination, supply `rowCount` so `table.getPageCount()` is correct. Optional but usually required for a UI. + +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +## Standard Pattern + +Own the slices that drive the server request with external atoms. Subscribe to them with `useSelector` so the request key is reactive. Pass them through `options.atoms`. Trigger the refetch from the same atoms. + +```tsx +import { useMemo } from 'preact/hooks' +import { useCreateAtom, useSelector } from '@tanstack/preact-store' +import { + rowPaginationFeature, + tableFeatures, + useTable, + type PaginationState, +} from '@tanstack/preact-table' + +const _features = tableFeatures({ rowPaginationFeature }) + +function App() { + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + const pagination = useSelector(paginationAtom) + + // Any data fetcher works — fetch / SWR / preact-query / a Suspense source. + const { data: rowsPayload } = useSomeServerFetcher({ + queryKey: ['rows', pagination], + queryFn: () => fetchRows(pagination), + }) + + const defaultData = useMemo(() => [], []) + + const table = useTable( + { + _features, + _rowModels: {}, // no client-side pagination factory + columns, + data: rowsPayload?.rows ?? defaultData, + rowCount: rowsPayload?.rowCount, // makes getPageCount() correct + atoms: { pagination: paginationAtom }, + manualPagination: true, // server owns the slicing + }, + (state) => state, + ) + // ... +} +``` + +Source: `examples/preact/with-tanstack-query/src/main.tsx` (lines 56–86). + +## All Three Slices Server-Owned + +Same shape, more atoms. Compose `pagination + sorting + columnFilters + globalFilter` into the request key. + +```tsx +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + globalFilteringFeature, +}) + +const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = useCreateAtom([]) +const columnFiltersAtom = useCreateAtom([]) +const globalFilterAtom = useCreateAtom('') + +const pagination = useSelector(paginationAtom) +const sorting = useSelector(sortingAtom) +const columnFilters = useSelector(columnFiltersAtom) +const globalFilter = useSelector(globalFilterAtom) + +const { data } = useSomeServerFetcher({ + queryKey: ['rows', pagination, sorting, columnFilters, globalFilter], + queryFn: () => + fetchRows({ pagination, sorting, columnFilters, globalFilter }), +}) + +const table = useTable({ + _features, + _rowModels: {}, // server owns every stage + columns, + data: data?.rows ?? EMPTY, + rowCount: data?.rowCount, + atoms: { + pagination: paginationAtom, + sorting: sortingAtom, + columnFilters: columnFiltersAtom, + globalFilter: globalFilterAtom, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, +}) +``` + +When `manualFiltering: true`, both `columnFilters` and the global filter are treated as server-owned. + +## Common Mistakes + +### CRITICAL Setting `manualPagination` without `rowCount` + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data: response?.rows ?? [], + atoms: { pagination: paginationAtom }, + manualPagination: true, + // no rowCount +}) +table.getPageCount() // Infinity / wrong +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data: response?.rows ?? [], + rowCount: response?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Without `rowCount` the table cannot know how many pages exist. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### HIGH Keeping the client-side row model when going manual + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, // still runs + data: server.rows, + manualPagination: true, +}) +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, // server owns pagination + data: server.rows, + rowCount: server.rowCount, + manualPagination: true, +}) +``` + +With `manualPagination`, the paginated row model has nothing useful to do — drop it. Same for `sortedRowModel` under `manualSorting`, `filteredRowModel` under `manualFiltering`. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### HIGH Forgetting to key the request on the slices the server owns + +Wrong: + +```tsx +const { data } = useQuery({ + queryKey: ['rows'], // never changes + queryFn: () => fetchRows(pagination), +}) +``` + +Correct: + +```tsx +const { data } = useQuery({ + queryKey: ['rows', pagination, sorting, columnFilters, globalFilter], + queryFn: () => + fetchRows({ pagination, sorting, columnFilters, globalFilter }), +}) +``` + +The request must vary by the slice values; otherwise the fetcher cache returns stale data when the user sorts or pages. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### HIGH Page flashes empty between fetches + +Wrong: the request resolves to `undefined` while loading, so the table shows zero rows between pages. + +Correct: pass a stable `defaultData` and (with @tanstack/preact-query) `placeholderData: keepPreviousData`. The table re-uses the last page's rows during the fetch. + +```tsx +import { keepPreviousData } from '@tanstack/preact-query' + +const defaultData = useMemo(() => [], []) + +const { data } = useQuery({ + queryKey: ['rows', pagination], + queryFn: () => fetchRows(pagination), + placeholderData: keepPreviousData, +}) + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: data?.rows ?? defaultData, + rowCount: data?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### MEDIUM Removing the matching feature from `_features` + +Wrong: + +```tsx +const _features = tableFeatures({}) // dropped rowPaginationFeature +useTable({ + _features, + _rowModels: {}, + data: server.rows, + rowCount: server.rowCount, + manualPagination: true, +}) +table.setPageIndex(0) // type error / no-op +``` + +Correct: keep the feature registered. `manualPagination: true` only tells the row-model pipeline to skip slicing — you still want the pagination state slice and `setPageIndex` / `nextPage` APIs. + +```tsx +const _features = tableFeatures({ rowPaginationFeature }) +``` + +Source: `docs/guide/features.md`. + +## See Also + +- `tanstack-table/preact/compose-with-tanstack-query` — full @tanstack/preact-query recipe with keepPreviousData and refetch ergonomics. +- `tanstack-table/preact/table-state` — atoms vs state, table.Subscribe. +- `tanstack-table/pagination`, `tanstack-table/filtering`, `tanstack-table/sorting` — feature-level state shapes. diff --git a/packages/preact-table/skills/preact/compose-with-tanstack-form/SKILL.md b/packages/preact-table/skills/preact/compose-with-tanstack-form/SKILL.md new file mode 100644 index 0000000000..42329cdb9f --- /dev/null +++ b/packages/preact-table/skills/preact/compose-with-tanstack-form/SKILL.md @@ -0,0 +1,230 @@ +--- +name: preact/compose-with-tanstack-form +description: > + Editable cells with `@tanstack/preact-form`. The table is the layout + primitive; the form owns the state. Use `createFormHook` to register + reusable field components (`TextField`, `NumberField`, `SelectField`), and + in each column's `cell` renderer return the matching field component bound + to that row's accessor. Row identity (via `getRowId`) keeps field state + stable as rows resort / re-filter. Routing keywords: preact-form, editable + cells, inline editing, createFormHook, FieldGroup, getRowId. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - row-selection + - column-definitions +sources: + - TanStack/table:examples/react/with-tanstack-form/ + - TanStack/table:docs/framework/preact/preact-table.md +--- + +This skill is the Preact recipe for editable cells via @tanstack/preact-form. The Preact-Form API mirrors the React-Form API closely; the table half of the recipe is what you'd write in vanilla Preact + Table v9. + +> **No dedicated examples/preact/with-tanstack-form yet** — the reference implementation lives under `examples/react/with-tanstack-form/` and ports line-for-line to Preact. The patterns below are the supported integration shape. + +## Install + +```bash +npm install @tanstack/preact-form @tanstack/preact-table +``` + +Peer dependency: `preact >=10`. + +## The Division of Labor + +| Concern | Owner | +| ------------------------------------------------ | ------------------ | +| Layout (rows, columns, headers) | Table | +| Cell rendering API | Table | +| Sorting / filtering / pagination | Table | +| Row identity | Table (`getRowId`) | +| Field state (value, errors, touched, validation) | Form | +| Form-level submit handler | Form | + +The table never owns cell values for the purposes of editing — it renders fields, the form holds the values, and on submit you read from the form snapshot. + +## Pattern — `createFormHook` + field-component cells + +Define reusable field components once. Compose them with `createFormHook` to get a typed `useAppForm`. In each editable column's `cell` renderer, plug the field component into `form.AppField` keyed by the row id. + +```tsx +import { createFormHook, createFormHookContexts } from '@tanstack/preact-form' + +// Field components (one-off — text input, number input, etc.). +function TextField({ field }) { + return ( + field.handleChange((e.target as HTMLInputElement).value)} + onBlur={field.handleBlur} + /> + ) +} + +function NumberField({ field }) { + return ( + + field.handleChange(Number((e.target as HTMLInputElement).value)) + } + onBlur={field.handleBlur} + /> + ) +} + +export const { fieldContext, formContext } = createFormHookContexts() + +export const { useAppForm } = createFormHook({ + fieldContext, + formContext, + fieldComponents: { TextField, NumberField }, + formComponents: {}, +}) +``` + +Use the form per-row, keyed by `row.id`. Tables built with `getRowId` keep the same row id across re-sorts and refilters, so the form state stays attached to the same logical row. + +```tsx +import { + createColumnHelper, + tableFeatures, + useTable, +} from '@tanstack/preact-table' +import { useAppForm } from './form-hook' + +type Person = { id: string; firstName: string; age: number } + +const _features = tableFeatures({}) +const columnHelper = createColumnHelper() + +function EditableTable({ data }: { data: Person[] }) { + const table = useTable({ + _features, + _rowModels: {}, + columns: columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('age', { + header: 'Age', + cell: ({ row, getValue }) => ( + + ), + }), + ]), + data, + getRowId: (row) => row.id, // CRITICAL — keeps form state attached to a row identity + }) + + return ( + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + +
+ +
+ ) +} + +// One small form per row. +function RowFieldCell({ + rowId, + field, + defaultValue, + kind, +}: { + rowId: string + field: string + defaultValue: unknown + kind: 'text' | 'number' +}) { + const form = useAppForm({ + defaultValues: { [field]: defaultValue }, + onSubmit: async ({ value }) => { + await saveRow(rowId, value) + }, + }) + + return ( + + {(f) => (kind === 'text' ? : )} + + ) +} +``` + +The bulk-edit alternative is a single top-level form with `` per cell. Either pattern works; pick whichever matches your save shape. + +## Common Mistakes + +### CRITICAL Forgetting `getRowId` + +Wrong: + +```tsx +useTable({ _features, _rowModels: {}, columns, data /* no getRowId */ }) +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, +}) +``` + +Without `getRowId`, the table assigns positional ids. Sorting or filtering changes row ids, and per-row form state ends up bound to a different logical row. +Source: `docs/framework/preact/guide/table-state.md`. + +### HIGH Storing editable values in the table state instead of the form + +Wrong: putting per-cell drafts in `table.atoms` slices, or in `table.options.data`. + +Correct: leave the table data immutable; let the form hold the in-flight values. Only update the table data on save (refresh from server or splice in the new row). + +### HIGH Re-rendering the entire table on every keystroke + +Wrong: the top-level `useTable` selects every form draft state slice. + +Correct: form field state is held in the form, not the table. The table re-renders only when actual table state changes. Field re-renders happen inside `` automatically. + +### MEDIUM Reimplementing form validation by hand + +Wrong: ad hoc `onChange` per field with validation logic in each cell. + +Correct: use `@tanstack/preact-form`'s validators (`validators: { onChange: schema }`). The form handles touched / dirty / error state; the table just renders the field. + +## See Also + +- `tanstack-table/preact/table-state` — Subscribe / atoms / FlexRender. +- `tanstack-table/row-selection` — combining row selection with bulk edit. +- `tanstack-table/column-definitions` — column helper with TFeatures. diff --git a/packages/preact-table/skills/preact/compose-with-tanstack-pacer/SKILL.md b/packages/preact-table/skills/preact/compose-with-tanstack-pacer/SKILL.md new file mode 100644 index 0000000000..b8bd32cf13 --- /dev/null +++ b/packages/preact-table/skills/preact/compose-with-tanstack-pacer/SKILL.md @@ -0,0 +1,186 @@ +--- +name: preact/compose-with-tanstack-pacer +description: > + Use `@tanstack/preact-pacer` to debounce / throttle the high-frequency writes + that drive an interactive table: column filter inputs and column resize + state. Pattern: import `useDebouncedCallback` from + `@tanstack/preact-pacer/debouncer` (or `useThrottledCallback` for resize), + wrap your `column.setFilterValue` / `table.setColumnSizing` calls, and let + the table's expensive row-model recompute happen on the trailing edge. + Routing keywords: preact-pacer, debounce filter, throttle resize, useDebouncedCallback, + performant filtering. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - filtering + - column-layout +sources: + - TanStack/table:examples/preact/filters/ + - TanStack/table:examples/preact/column-resizing-performant/ + - TanStack/table:examples/react/with-tanstack-form/ +--- + +Filtering and column resizing fire a lot of events. Each `column.setFilterValue(...)` invalidates `filteredRowModel`, then `paginatedRowModel`, then re-renders subscribed components. For large tables that is the difference between a snappy UI and a janky one. Debounce or throttle the writes with @tanstack/preact-pacer. + +## Install + +```bash +npm install @tanstack/preact-pacer +``` + +## Pattern 1 — Debounced column filter input + +Render the input value from local state (immediate visual feedback) and push the value into the table on a debounce. + +```tsx +import { useState } from 'preact/hooks' +import { useDebouncedCallback } from '@tanstack/preact-pacer/debouncer' +import type { Column } from '@tanstack/preact-table' + +function FilterInput({ + column, +}: { + column: Column +}) { + const initial = (column.getFilterValue() as string | undefined) ?? '' + const [value, setValue] = useState(initial) + + const pushToTable = useDebouncedCallback( + (next: string) => column.setFilterValue(next), + { wait: 250 }, + ) + + return ( + { + const next = (e.target as HTMLInputElement).value + setValue(next) + pushToTable(next) + }} + placeholder="Search…" + /> + ) +} +``` + +The local `value` keeps the cursor and typing responsive; `pushToTable` only runs on the trailing edge, so the table only recomputes the filtered row model once per pause. + +Same pattern works for the global filter via `table.setGlobalFilter`. + +Source: `examples/preact/filters/`. + +## Pattern 2 — Throttled column resize + +Column resize fires per-mousemove. Throttling keeps the resize visually smooth without re-running the full layout on every pixel. + +```tsx +import { useThrottledCallback } from '@tanstack/preact-pacer/throttler' + +function ResizeHandle({ header, table }) { + const pushSize = useThrottledCallback( + (size: number) => { + table.setColumnSizing((prev) => ({ ...prev, [header.column.id]: size })) + }, + { wait: 16 }, // ~60fps + ) + + // Hook this into your existing pointermove handler. + return null +} +``` + +In v9, column sizing is driven by `columnSizingFeature` + `table.setColumnSizing`. The throttle wraps the write side; the read side stays direct. + +Source: `examples/preact/column-resizing-performant/`. + +## When NOT to debounce + +- Sorting (`table.setSorting`) — fires once per click. +- Pagination (`table.setPageIndex`, `table.setPageSize`) — fires once per page. +- Row selection (`row.toggleSelected`) — fires once per toggle. + +Debouncing these adds latency for no win. Reach for pacer only on input/resize/scroll-driven writes. + +## With External Atoms + +If you've moved a slice to an external atom, debounce the atom write instead of the table API. + +```tsx +const globalFilterAtom = useCreateAtom('') + +const pushFilter = useDebouncedCallback( + (next: string) => globalFilterAtom.set(next), + { wait: 250 }, +) +``` + +The table reads from the atom; the atom now changes less often; the row model recomputes less often. + +## Common Mistakes + +### CRITICAL Wrapping `column.setFilterValue` AND reading filter via `column.getFilterValue()` in the same input + +Wrong: + +```tsx +const debouncedSet = useDebouncedCallback( + (v: string) => column.setFilterValue(v), + { wait: 250 }, +) +return ( + debouncedSet((e.target as HTMLInputElement).value)} + /> +) +``` + +Correct: hold local state for the visual `value`, debounce the write. + +```tsx +const [v, setV] = useState((column.getFilterValue() ?? '') as string) +const debouncedSet = useDebouncedCallback( + (next: string) => column.setFilterValue(next), + { wait: 250 }, +) + +return ( + { + const next = (e.target as HTMLInputElement).value + setV(next) + debouncedSet(next) + }} + /> +) +``` + +Otherwise the input lags by the debounce wait — the user sees stale characters. + +### HIGH Debouncing the wrong direction + +Wrong: debouncing the read (`column.getFilterValue`), which is just a memoized derivation. + +Correct: debounce the **write** (`column.setFilterValue`) — that's what triggers the row-model recompute. + +### HIGH Treating pacer as a substitute for stable references + +Wrong: debouncing every `useTable` call. + +Correct: pacer doesn't fix unstable `_features` / `columns` / `data` references. Stabilize those first; reach for pacer for input/scroll/resize hotspots. +Source: `docs/framework/preact/guide/table-state.md` (FAQ #1). + +### MEDIUM Forgetting that debounce delays the trailing edge + +The first keystroke still hits the table at the trailing edge of the debounce window. If you want a leading-edge call (e.g. fire immediately, then debounce subsequent calls), use the `{ leading: true, trailing: true }` shape. + +## See Also + +- `tanstack-table/filtering` — column / global filter state shape. +- `tanstack-table/column-layout` — column sizing / pinning / visibility. +- `tanstack-table/preact/production-readiness` — narrow selectors, stable refs. diff --git a/packages/preact-table/skills/preact/compose-with-tanstack-query/SKILL.md b/packages/preact-table/skills/preact/compose-with-tanstack-query/SKILL.md new file mode 100644 index 0000000000..350a7466c9 --- /dev/null +++ b/packages/preact-table/skills/preact/compose-with-tanstack-query/SKILL.md @@ -0,0 +1,283 @@ +--- +name: preact/compose-with-tanstack-query +description: > + Server-side / async data flow with `@tanstack/preact-query`. Key the query on + the table state that drives the request (pagination + sort + filters), pass + `placeholderData: keepPreviousData` to avoid a "0 rows flash" between pages, + set `manualPagination` / `manualSorting` / `manualFiltering` for the slices + the server owns, supply `rowCount`, and let `table.set*` writes to external + atoms re-key the query. Routing keywords: preact-query, server pagination, + keepPreviousData, useQuery, manualPagination, rowCount, fetchData. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - preact/client-to-server + - pagination + - state-management +sources: + - TanStack/table:examples/preact/with-tanstack-query/src/main.tsx + - TanStack/table:examples/preact/with-tanstack-query/src/fetchData.ts + - TanStack/table:docs/framework/preact/guide/table-state.md +--- + +This skill is the @tanstack/preact-query recipe for server-side tables. Read `tanstack-table/preact/client-to-server` first for the manual-mode mechanics; this skill is the Preact Query-specific wiring on top. + +## Install + +```bash +npm install @tanstack/preact-query @tanstack/preact-table @tanstack/preact-store +``` + +## The Standard Recipe + +Own the slices that drive the request with external atoms. Read them with `useSelector` so the `queryKey` is reactive. Pass them through `options.atoms`. Set `manual*` for the slices the server owns. Use `placeholderData: keepPreviousData` so pagination doesn't flash empty. + +```tsx +import { useMemo, useReducer } from 'preact/hooks' +import { render } from 'preact' +import { + QueryClient, + QueryClientProvider, + keepPreviousData, + useQuery, +} from '@tanstack/preact-query' +import { useCreateAtom, useSelector } from '@tanstack/preact-store' +import { + createColumnHelper, + rowPaginationFeature, + tableFeatures, + useTable, + type PaginationState, +} from '@tanstack/preact-table' +import { fetchData } from './fetchData' +import type { Person } from './fetchData' + +const queryClient = new QueryClient() +const _features = tableFeatures({ rowPaginationFeature }) +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor('lastName', { header: 'Last Name' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +function App() { + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + const pagination = useSelector(paginationAtom) + + const dataQuery = useQuery({ + queryKey: ['data', pagination], + queryFn: () => fetchData(pagination), + placeholderData: keepPreviousData, // no "0 rows" flash between pages + }) + + const defaultData = useMemo(() => [], []) + + const table = useTable( + { + _features, + _rowModels: {}, // server owns slicing + columns, + data: dataQuery.data?.rows ?? defaultData, + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, + }, + (state) => state, + ) + + // table.nextPage() writes to paginationAtom → queryKey changes → refetch. + return null +} + +render( + + + , + document.getElementById('root')!, +) +``` + +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +## Server `fetchData` Shape + +The fetcher returns the page of rows plus the total row count so `table.getPageCount()` is correct. + +```ts +// fetchData.ts +import type { PaginationState } from '@tanstack/preact-table' + +export type Person = { + firstName: string + lastName: string + age: number /* … */ +} + +export async function fetchData(pagination: PaginationState): Promise<{ + rows: Person[] + rowCount: number +}> { + // server returns the current page and the total count + const res = await fetch( + `/api/people?page=${pagination.pageIndex}&size=${pagination.pageSize}`, + ) + return res.json() +} +``` + +Source: `examples/preact/with-tanstack-query/src/fetchData.ts`. + +## Adding Sorting and Filters + +Add more external atoms; include them in the `queryKey`; set the matching `manual*` flag. The server's fetcher accepts whatever shape you forward. + +```tsx +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + globalFilteringFeature, +}) + +const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = useCreateAtom([]) +const columnFiltersAtom = useCreateAtom([]) +const globalFilterAtom = useCreateAtom('') + +const pagination = useSelector(paginationAtom) +const sorting = useSelector(sortingAtom) +const columnFilters = useSelector(columnFiltersAtom) +const globalFilter = useSelector(globalFilterAtom) + +const dataQuery = useQuery({ + queryKey: ['data', pagination, sorting, columnFilters, globalFilter], + queryFn: () => + fetchData({ pagination, sorting, columnFilters, globalFilter }), + placeholderData: keepPreviousData, +}) + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: dataQuery.data?.rows ?? defaultData, + rowCount: dataQuery.data?.rowCount, + atoms: { + pagination: paginationAtom, + sorting: sortingAtom, + columnFilters: columnFiltersAtom, + globalFilter: globalFilterAtom, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, +}) +``` + +## Common Mistakes + +### CRITICAL `manualPagination` without `rowCount` + +Wrong: + +```tsx +useTable({ + /* … */, + data: dataQuery.data?.rows ?? defaultData, + manualPagination: true, + atoms: { pagination: paginationAtom }, + // no rowCount +}) +table.getPageCount() // Infinity +``` + +Correct: always pass `rowCount: dataQuery.data?.rowCount`. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### CRITICAL `queryKey` that doesn't include reactive table state + +Wrong: + +```tsx +useQuery({ + queryKey: ['data'], + queryFn: () => fetchData(pagination), +}) +``` + +Correct: + +```tsx +useQuery({ + queryKey: ['data', pagination /* + sorting, filters, etc. */], + queryFn: () => fetchData(pagination), +}) +``` + +The query cache must vary by the slice values, or you'll fetch once and never refresh on user interaction. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### HIGH Missing `placeholderData: keepPreviousData` + +Wrong: data goes `undefined` between pages; the table flashes empty. + +Correct: include `placeholderData: keepPreviousData` so the table keeps the last page rendered until the new page resolves. +Source: `examples/preact/with-tanstack-query/src/main.tsx`. + +### HIGH Inline `data: dataQuery.data?.rows ?? []` + +Wrong: + +```tsx +useTable({ /* … */, data: dataQuery.data?.rows ?? [] }) // new [] every render +``` + +Correct: + +```tsx +const defaultData = useMemo(() => [], []) +useTable({ /* … */, data: dataQuery.data?.rows ?? defaultData }) +``` + +A new empty array each render busts row-model memos. + +### HIGH Keeping the client-side `_rowModels` when manual + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, // wasted work + /* … */, + manualPagination: true, +}) +``` + +Correct: drop the row-model factory whose stage the server owns. With `manualPagination: true`, the server returns the page slice already. + +### MEDIUM Creating a new `paginationAtom` per render + +Wrong: `createAtom(...)` inside the component body. + +Correct: `useCreateAtom(...)` (or atom at module scope). +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +## See Also + +- `tanstack-table/preact/client-to-server` — manual-mode mechanics independent of any specific async lib. +- `tanstack-table/preact/compose-with-tanstack-store` — slice atoms and sharing state. +- `tanstack-table/preact/production-readiness` — narrow selectors, stable refs. diff --git a/packages/preact-table/skills/preact/compose-with-tanstack-store/SKILL.md b/packages/preact-table/skills/preact/compose-with-tanstack-store/SKILL.md new file mode 100644 index 0000000000..7c90533801 --- /dev/null +++ b/packages/preact-table/skills/preact/compose-with-tanstack-store/SKILL.md @@ -0,0 +1,263 @@ +--- +name: preact/compose-with-tanstack-store +description: > + `@tanstack/preact-table` v9 is built on TanStack Store. Each registered state + slice is an atom. The table exposes three reactive surfaces: + `table.atoms.` (per-slice readonly), `table.store` (flat readonly + view), and `table.baseAtoms.` (internal writable). Use external atoms + via `useCreateAtom` + `options.atoms` to hand slice ownership to your app, + share atoms across components with `useSelector`, and subscribe imperatively + with `atom.subscribe(...)` for persistence/sync. Routing keywords: + preact-store, useCreateAtom, useSelector, atoms, external state, slice + ownership, persistence. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - state-management + - preact/table-state +sources: + - TanStack/table:docs/framework/preact/guide/table-state.md + - TanStack/table:examples/preact/basic-external-atoms/src/main.tsx + - TanStack/table:examples/preact/basic-subscribe/src/main.tsx + - TanStack/table:packages/preact-table/src/useTable.ts + - TanStack/table:packages/preact-table/src/reactivity.ts +--- + +`@tanstack/preact-table` v9 stores every registered state slice as a TanStack Store atom under the hood, and `useTable` wires Preact to those atoms via `@tanstack/preact-store`. This skill is the bridge between table state and the rest of your TanStack Store-powered app. + +## The Three Surfaces + +| Surface | Shape | Use when | +| ------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | +| `table.atoms.` | `ReadonlyAtom` per slice | Read or subscribe to one slice (preferred) | +| `table.store` | `ReadonlyStore>` (flat derived view) | Reading full table state, selecting projections | +| `table.baseAtoms.` | `Atom` (writable internal) | Low-level writes when the slice is internally owned | + +External atoms passed via `options.atoms.` take precedence over `options.state[]` and over `table.baseAtoms.`. Writes from feature APIs (`table.setSorting(...)`, `table.setPageIndex(...)`, etc.) flow into whichever atom owns the slice. + +Source: `docs/framework/preact/guide/table-state.md`; `packages/preact-table/src/useTable.ts`. + +## Pattern 1 — Hand a slice to your app + +```tsx +import { useCreateAtom, useSelector } from '@tanstack/preact-store' +import { + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + useTable, + type PaginationState, + type SortingState, +} from '@tanstack/preact-table' + +const _features = tableFeatures({ rowPaginationFeature, rowSortingFeature }) + +function PeopleTable({ data }) { + // Stable atoms owned by this component. + const sortingAtom = useCreateAtom([]) + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + + // Independent reactive reads — only re-renders for the slice that changed. + const sorting = useSelector(sortingAtom) + const pagination = useSelector(paginationAtom) + + const table = useTable({ + _features, + _rowModels: { + /* … */ + }, + columns, + data, + atoms: { sorting: sortingAtom, pagination: paginationAtom }, + }) + + // table.setSorting / table.setPageIndex write to your atoms. + return null +} +``` + +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +## Pattern 2 — Share an atom across components + +Lift the atom to a module / context. Any component can read or write it, and the table stays in sync. + +```tsx +// shared/atoms.ts +import { createAtom } from '@tanstack/preact-store' +import type { RowSelectionState } from '@tanstack/preact-table' + +export const selectionAtom = createAtom({}) +``` + +```tsx +// TableScreen.tsx +import { selectionAtom } from '../shared/atoms' + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { rowSelection: selectionAtom }, +}) +``` + +```tsx +// SelectionSummary.tsx +import { useSelector } from '@tanstack/preact-store' +import { selectionAtom } from '../shared/atoms' + +function SelectionSummary() { + const sel = useSelector(selectionAtom) + return {Object.keys(sel).length} selected +} +``` + +Module-scope atoms are stable identities — no `useCreateAtom` needed. Don't create module-scope atoms inside a component-render body. + +Source: `docs/framework/preact/guide/table-state.md`. + +## Pattern 3 — Subscribe imperatively for persistence / sync + +`Atom.subscribe` returns an `{ unsubscribe }` handle. Persist to `localStorage`, push to a URL, or fan out to other stores. + +```tsx +import { useEffect } from 'preact/hooks' + +useEffect(() => { + const sub = paginationAtom.subscribe((next) => { + localStorage.setItem('table:pagination', JSON.stringify(next)) + }) + return () => sub.unsubscribe() +}, [paginationAtom]) +``` + +You can also subscribe to `table.atoms.` directly without owning the slice. The subscription fires whenever the slice changes — whoever owns it (your atom or `baseAtoms`). + +Source: `packages/preact-table/src/reactivity.ts`. + +## Pattern 4 — Read inside cells with the standalone `` + +Inside a cell or header render context, `table` is the core `Table`, not `PreactTable`. `table.Subscribe` is undefined — import the standalone component. + +```tsx +import { Subscribe } from '@tanstack/preact-table' + +columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + rs[row.id]}> + {(isSelected) => ( + + )} + + ), +}) +``` + +Source: `packages/preact-table/src/Subscribe.ts`; `examples/preact/basic-subscribe/src/main.tsx`. + +## Common Mistakes + +### CRITICAL Creating an atom inside the render body without `useCreateAtom` + +Wrong: + +```tsx +function MyTable() { + const sortingAtom = createAtom([]) // new atom every render + useTable({ /* … */, atoms: { sorting: sortingAtom } }) +} +``` + +Correct: + +```tsx +function MyTable() { + const sortingAtom = useCreateAtom([]) // stable + useTable({ /* … */, atoms: { sorting: sortingAtom } }) +} +``` + +A fresh atom each render unbinds the slice and resets it to the initial value on every render. +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +### HIGH Writing to `table.baseAtoms.X.set()` when the slice is externally owned + +Wrong: + +```tsx +useTable({ /* … */, atoms: { pagination: paginationAtom } }) +table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) // updates the wrong atom +``` + +Correct: + +```tsx +// Use the feature API (writes to whichever atom owns the slice). +table.setPageIndex(0) +// Or write to your external atom directly. +paginationAtom.set((old) => ({ ...old, pageIndex: 0 })) +``` + +`table.baseAtoms` is the internal writable atom — used only when the slice is internally owned. When you hand a slice to an external atom, the external atom is the source of truth. +Source: `docs/framework/preact/guide/table-state.md`. + +### HIGH Reading `.get()` and expecting re-renders + +Wrong: + +```tsx +function Pager() { + const { pageIndex } = table.atoms.pagination.get() // current-value read + return Page {pageIndex + 1} +} +``` + +Correct: + +```tsx +import { useSelector } from '@tanstack/preact-store' + +function Pager() { + const pageIndex = useSelector(table.atoms.pagination, (p) => p.pageIndex) + return Page {pageIndex + 1} +} +// or +; p.pageIndex}> + {(pageIndex) => Page {pageIndex + 1}} + +``` + +`.get()` returns the current value without subscribing. + +### MEDIUM Passing the same slice via `atoms` AND `state` + +Wrong: + +```tsx +useTable({ + /* … */, + atoms: { pagination: paginationAtom }, + state: { pagination }, // silently ignored + onPaginationChange: setPagination, // silently ignored +}) +``` + +Correct: pick exactly one ownership path per slice. + +## See Also + +- `tanstack-table/preact/table-state` — atoms / Subscribe / FlexRender reference. +- `tanstack-table/preact/compose-with-tanstack-query` — server-side flow keyed by atoms. +- `tanstack-table/preact/production-readiness` — when to reach for narrow subscriptions. diff --git a/packages/preact-table/skills/preact/compose-with-tanstack-virtual/SKILL.md b/packages/preact-table/skills/preact/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..7cbd1d6e0f --- /dev/null +++ b/packages/preact-table/skills/preact/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,275 @@ +--- +name: preact/compose-with-tanstack-virtual +description: > + TanStack Table does NOT include virtualization — pair with TanStack Virtual. + Preact has no dedicated `@tanstack/preact-virtual` adapter yet; use + `@tanstack/virtual-core`'s `Virtualizer` class behind a small hook, or use + the React adapter via `preact/compat`. Pattern: get `rows = table.getRowModel().rows`, + feed `rows.length` to the virtualizer, render only virtual items, and use + CSS transforms for row positioning. Routing keywords: preact virtualization, + large table, virtual rows, virtual-core, getVirtualItems, table-core. +type: composition +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - preact/table-state + - row-expanding +sources: + - TanStack/table:docs/guide/virtualization.md + - TanStack/table:examples/lit/virtualized-rows/src/main.ts + - TanStack/table:examples/react/virtualized-rows/ + - TanStack/table:examples/react/virtualized-columns/ +--- + +TanStack Table is headless — it does not virtualize rows or columns. For long lists, pair the table with TanStack Virtual. + +> **Adapter status:** There is no published `@tanstack/preact-virtual` adapter as of `@tanstack/table` v9.0.0-alpha.47. The two supported paths are: +> +> 1. **Use `@tanstack/virtual-core` directly.** The framework-agnostic `Virtualizer` class wrapped in a small Preact hook is the recommended path. +> 2. **Use `@tanstack/react-virtual` via `preact/compat`.** Works if your project already aliases `react` → `preact/compat`. +> +> The patterns below use path 1. Both paths feed the same `rows = table.getRowModel().rows` array to the virtualizer. + +## Install + +```bash +npm install @tanstack/virtual-core +``` + +## The Pattern (Row Virtualization) + +1. Build the table with `useTable` as usual. +2. Get `const { rows } = table.getRowModel()` — the table is the source of truth for which rows to render (already sorted, filtered, paginated, etc.). +3. Pass `rows.length` to the virtualizer. +4. Render only `virtualizer.getVirtualItems()`. +5. Each virtual row is absolutely positioned via `transform: translateY(${item.start}px)`. + +```tsx +import { useEffect, useMemo, useRef, useState } from 'preact/hooks' +import { + Virtualizer, + observeElementOffset, + observeElementRect, + elementScroll, +} from '@tanstack/virtual-core' +import { + useTable, + createSortedRowModel, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/preact-table' +import type { JSX } from 'preact' + +const _features = tableFeatures({ rowSortingFeature }) + +// Minimal Preact hook around the framework-agnostic Virtualizer. +function useVirtualizer( + options: Omit< + ConstructorParameters>[0], + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + > & { onChange?: (instance: Virtualizer) => void }, +) { + const [, force] = useState(0) + const virtualizer = useMemo( + () => + new Virtualizer({ + ...options, + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + onChange: (inst) => { + options.onChange?.(inst) + force((n) => n + 1) + }, + }), + [], + ) + + // Sync count / estimateSize / overscan when they change. + virtualizer.setOptions({ + ...virtualizer.options, + count: options.count, + estimateSize: options.estimateSize, + overscan: options.overscan, + }) + + useEffect(() => { + return virtualizer._didMount() + }, [virtualizer]) + + useEffect(() => { + virtualizer._willUpdate() + }) + + return virtualizer +} + +function BigTable({ data }) { + const table = useTable( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }, + () => null, // huge table — opt out at the top, subscribe lower down + ) + + const { rows } = table.getRowModel() + + const scrollRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current!, + estimateSize: () => 33, + overscan: 5, + }) + + return ( +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const row = rows[virtualItem.index] + return ( + rowVirtualizer.measureElement(node!)} + data-index={virtualItem.index} + style={{ + display: 'flex', + position: 'absolute', + transform: `translateY(${virtualItem.start}px)`, + width: '100%', + }} + > + {row.getAllCells().map((cell) => ( + + ))} + + ) + })} + +
+ +
+ +
+
+ ) +} +``` + +The structure matches the Lit virtualized-rows example one-for-one; only the host framework changes. +Source: `examples/lit/virtualized-rows/src/main.ts`; `docs/guide/virtualization.md`. + +## Column Virtualization + +Same idea, but the virtualizer's `count` is `columns.length` and you index the visible columns inside each row. Useful for wide kitchen-sink tables. + +## With Pagination / Filtering + +Use the row model that's already been transformed by registered features. The virtualizer count is `rows.length` — the table handles sorting, filtering, and pagination upstream. + +## Combining with `` + +On large tables, pass `() => null` to `useTable` (or use the standalone ``) and wrap the `` in a subscription that re-renders only when the row model can actually change. + +```tsx + ({ + columnFilters: s.columnFilters, + globalFilter: s.globalFilter, + sorting: s.sorting, + })} +> + {() => {/* virtualized rows */}} + +``` + +Source: `examples/preact/basic-subscribe/src/main.tsx`. + +## Common Mistakes + +### CRITICAL Reimplementing virtualization by hand + +Wrong: + +```tsx +// Manual slicing + intersection observer + per-row offset calculation +``` + +Correct: use TanStack Virtual's `Virtualizer` — it handles measurement, overscan, scroll alignment, and dynamic sizing. +Source: `docs/guide/virtualization.md`. + +### HIGH Using the wrong row source + +Wrong: + +```tsx +const virtualizer = useVirtualizer({ count: data.length /* … */ }) // bypasses sort/filter/paginate +``` + +Correct: + +```tsx +const { rows } = table.getRowModel() +const virtualizer = useVirtualizer({ count: rows.length /* … */ }) +``` + +Always feed `table.getRowModel().rows.length` — that's the post-feature row array. + +### HIGH Forgetting `position: relative` on the scroll parent / `position: absolute` on rows + +Wrong: + +```tsx +
+ {/* rows */} +
+``` + +Correct: the absolute rows need a `position: relative` ancestor with the total height set. Without it, rows stack at the top. + +### HIGH Forgetting to set up `measureElement` for dynamic sizing + +Wrong: rows render but `estimateSize` is wrong and rows overlap or leave gaps. + +Correct: attach `ref={(node) => rowVirtualizer.measureElement(node!)}` to each row element so the virtualizer can measure actual size. + +### MEDIUM Mixing virtualization with `manualPagination` + +You usually don't need both — server pagination already limits the row count. Virtualize when the client holds the full dataset. + +## See Also + +- `tanstack-table/preact/table-state` — Subscribe for fine-grained re-renders. +- `tanstack-table/preact/production-readiness` — narrow selectors, stable refs. +- `tanstack-table/row-expanding` — virtualizing rows with sub-component rows requires variable height + measureElement. diff --git a/packages/preact-table/skills/preact/getting-started/SKILL.md b/packages/preact-table/skills/preact/getting-started/SKILL.md new file mode 100644 index 0000000000..205de5c568 --- /dev/null +++ b/packages/preact-table/skills/preact/getting-started/SKILL.md @@ -0,0 +1,371 @@ +--- +name: preact/getting-started +description: > + End-to-end first-table journey for `@tanstack/preact-table` v9: install the + adapter, declare `_features` via `tableFeatures()`, declare `_rowModels` with + their factories and *Fns parameters, build a typed column helper, call + `useTable` with stable references, and render with `table.FlexRender`. + Routing keywords: install preact-table, first table, getting started, + tableFeatures, _features, _rowModels, useTable, FlexRender, basic-use-table. +type: lifecycle +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - setup + - column-definitions + - state-management + - preact/table-state +sources: + - TanStack/table:docs/installation.md + - TanStack/table:docs/framework/preact/preact-table.md + - TanStack/table:examples/preact/basic-use-table/src/main.tsx + - TanStack/table:packages/preact-table/src/useTable.ts +--- + +This skill walks through a first working Preact v9 table end-to-end. It assumes you have read `tanstack-table/setup` and `tanstack-table/state-management` for core concepts and `tanstack-table/preact/table-state` for adapter wiring. + +## Install + +`@tanstack/preact-table` is the Preact adapter. It pulls in `@tanstack/table-core` and `@tanstack/preact-store` (used internally for atom-backed state). + +```bash +npm install @tanstack/preact-table +``` + +Peer dependency: `preact >=10`. + +Source: `packages/preact-table/package.json`. + +## Step 1 — Declare `_features` + +v9 is explicit about what a table uses. `_features` is a registry of every feature the table needs. Use `tableFeatures({...})` to get an object whose TypeScript shape drives state inference, API surface, and tree-shaking. + +```tsx +import { + tableFeatures, + rowPaginationFeature, + rowSortingFeature, +} from '@tanstack/preact-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) +``` + +If `_features` does not include `rowSelectionFeature`, then `table.atoms.rowSelection`, `table.setRowSelection`, `table.getIsAllRowsSelected()`, etc. all become TypeScript errors — and the runtime won't ship that logic. Pass `tableFeatures({})` for a minimum-overhead table with just the core row model. + +Source: `docs/framework/preact/preact-table.md`; `docs/guide/features.md`. + +## Step 2 — Declare `_rowModels` + +Each registered feature that needs a row-model stage maps to a factory under `_rowModels`. The factory takes a record of \*Fns (predicates, comparators, etc.) for that stage. + +```tsx +import { + createPaginatedRowModel, + createSortedRowModel, + sortFns, +} from '@tanstack/preact-table' + +const _rowModels = { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), +} +``` + +The core row model is always included — `_rowModels: {}` is valid for a feature-free table. + +Source: `docs/framework/preact/preact-table.md`. + +## Step 3 — Type your data and build columns + +Declare your row shape once and feed it to `createColumnHelper()`. This is the type-safe path; `ColumnDef[]` also works. + +```tsx +import { createColumnHelper, type ColumnDef } from '@tanstack/preact-table' + +type Person = { + firstName: string + lastName: string + age: number + visits: number + status: string + progress: number +} + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor('lastName', { header: () => Last Name }), + columnHelper.accessor('age', { header: 'Age' }), + columnHelper.accessor('visits', { header: 'Visits' }), + columnHelper.accessor('status', { header: 'Status' }), + columnHelper.accessor('progress', { header: 'Profile Progress' }), +]) +``` + +Source: `examples/preact/basic-use-table/src/main.tsx`. + +## Step 4 — Call `useTable` and render + +`useTable` takes options and an optional selector. Render headers, cells, and footers with `table.FlexRender` so column defs can be strings or Preact components. + +```tsx +import { render } from 'preact' +import { useState } from 'preact/hooks' +import { useTable } from '@tanstack/preact-table' + +const defaultData: Person[] = [ + { + firstName: 'tanner', + lastName: 'linsley', + age: 24, + visits: 100, + status: 'In Relationship', + progress: 50, + }, + { + firstName: 'kevin', + lastName: 'vandy', + age: 12, + visits: 100, + status: 'Single', + progress: 70, + }, +] + +function App() { + const [data] = useState(() => [...defaultData]) + + const table = useTable( + { + _features, + _rowModels, + columns, + data, + debugTable: true, + }, + (state) => state, // default selector + ) + + return ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + + + {table.getFooterGroups().map((fg) => ( + + {fg.headers.map((h) => ( + + ))} + + ))} + +
+ {h.isPlaceholder ? null : } +
+ +
+ {h.isPlaceholder ? null : } +
+ ) +} + +render(, document.getElementById('root')!) +``` + +Source: `examples/preact/basic-use-table/src/main.tsx`. + +## Step 5 — Drive features with feature APIs + +Reach for `table.setSorting(...)`, `table.setPageIndex(...)`, `table.nextPage()`, `column.toggleVisibility()`, `row.toggleSelected()`, etc. — never edit `table.store.state` directly. + +```tsx + + +``` + +For starting values, use `initialState`. For controlled slices, use `atoms` (preferred) or `state` + `on*Change` — see `tanstack-table/preact/table-state`. + +## Common Mistakes + +### CRITICAL Calling a feature API when the feature is not in `_features` + +Wrong: + +```tsx +const _features = tableFeatures({}) // no rowPaginationFeature +const table = useTable({ _features, _rowModels: {}, columns, data }) + +table.setPageIndex(0) // TypeScript error AND runtime no-op +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowPaginationFeature }) +const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data, +}) + +table.setPageIndex(0) +``` + +v9 generates feature APIs and state slices only for registered features. The missing-feature failure mode (calling `setSorting`, accessing `state.pagination`, etc. before registering the feature) is the #1 v9 trap. +Source: `docs/guide/features.md`; `docs/framework/preact/guide/table-state.md`. + +### HIGH Forgetting the matching row-model factory + +Wrong: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ _features, _rowModels: {}, columns, data }) +table.setSorting([{ id: 'age', desc: true }]) +// table.getRowModel().rows is still unsorted — no sortedRowModel registered +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +Each row-model feature (sorting, filtering, pagination, grouping, expanding, faceting) requires its row-model factory in `_rowModels` to actually transform the rows. +Source: `docs/framework/preact/preact-table.md`. + +### HIGH Unstable `_features` / `columns` / `data` references + +Wrong: + +```tsx +function MyTable({ rows }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const columns = [ + /* … */ + ] // new every render + const data = rows ?? [] // new [] every render + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const columns: ColumnDef[] = [ + /* … */ +] +const EMPTY: Person[] = [] + +function MyTable({ rows }) { + const data = rows ?? EMPTY + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Internal memos key off identity. Fresh references bust everything every render. +Source: `docs/framework/preact/guide/table-state.md` (FAQ #1). + +### HIGH Reimplementing built-in feature logic + +Wrong: + +```tsx +const sorted = useMemo(() => [...data].sort(/* … */), [data, sorting]) // duplicates rowSortingFeature +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +const rows = table.getRowModel().rows // already sorted +``` + +v9 ships built-ins for sorting, filtering, pagination, grouping, expanding, faceting, row selection, column visibility/order/pinning/sizing, and row pinning. Use them. +Source: `docs/guide/features.md`. + +### MEDIUM Using v8 hook names (`getCoreRowModel`, `useReactTable`, etc.) + +Wrong: + +```tsx +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, +} from '@tanstack/preact-table' +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), +}) +``` + +Correct: + +```tsx +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/preact-table' + +const _features = tableFeatures({ rowSortingFeature }) + +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +v8 used `useReactTable` and `get*RowModel` options. v9 uses `useTable` plus `_features` + `_rowModels`. See `tanstack-table/preact/migrate-v8-to-v9` for the full mapping. +Source: `docs/framework/preact/preact-table.md`. + +## See Also + +- `tanstack-table/preact/table-state` — atoms, Subscribe, createTableHook. +- `tanstack-table/preact/migrate-v8-to-v9` — moving an existing v8 codebase over. +- `tanstack-table/preact/production-readiness` — perf, tree-shaking, stable refs. diff --git a/packages/preact-table/skills/preact/migrate-v8-to-v9/SKILL.md b/packages/preact-table/skills/preact/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..60dcdd4319 --- /dev/null +++ b/packages/preact-table/skills/preact/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,322 @@ +--- +name: preact/migrate-v8-to-v9 +description: > + Mechanical breaking-change migration from TanStack Table v8 to v9 for + `@tanstack/preact-table`. Maps every old-shaped option, helper, type, and + method an agent will reproduce from v8 muscle memory to its v9 equivalent: + `useReactTable` → `useTable`, per-row-model `get*RowModel` options → + `_features` + `_rowModels`, plain column helpers → typed column helpers, + `state` + `on*Change` → `atoms`, `flexRender` → `table.FlexRender`, and core + type renames. Routing keywords: v8 to v9, migration, useReactTable, table + v8 preact, get*RowModel, _features. +type: lifecycle +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - TanStack/table:docs/framework/preact/preact-table.md + - TanStack/table:docs/framework/preact/guide/table-state.md + - TanStack/table:docs/framework/react/guide/use-legacy-table.md + - TanStack/table:packages/preact-table/src/useTable.ts +--- + +The Preact adapter mirrors the React v9 surface, so any v8 → v9 migration guide for React applies almost line-for-line. There is no `useLegacyTable` shim in `@tanstack/preact-table` — migrate directly. + +## The Core Mapping + +| v8 (Preact / React-compatible) | v9 (`@tanstack/preact-table`) | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useReactTable(opts)` | `useTable(opts, selector?)` | +| `getCoreRowModel: getCoreRowModel()` | included by default — `_rowModels: {}` is valid | +| `getSortedRowModel: getSortedRowModel()` | `_features: { rowSortingFeature }` + `_rowModels: { sortedRowModel: createSortedRowModel(sortFns) }` | +| `getFilteredRowModel: getFilteredRowModel()` | `_features: { columnFilteringFeature, globalFilteringFeature }` + `_rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }` | +| `getPaginationRowModel: getPaginationRowModel()` | `_features: { rowPaginationFeature }` + `_rowModels: { paginatedRowModel: createPaginatedRowModel() }` | +| `getGroupedRowModel: getGroupedRowModel()` | `_features: { columnGroupingFeature }` + `_rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }` | +| `getExpandedRowModel: getExpandedRowModel()` | `_features: { rowExpandingFeature }` + `_rowModels: { expandedRowModel: createExpandedRowModel() }` | +| `getFacetedRowModel`, `getFacetedUniqueValues`, `getFacetedMinMaxValues` | `_features: { columnFacetingFeature, globalFacetingFeature }` + `_rowModels: { facetedRowModel: createFacetedRowModel(facetedFns), facetedUniqueValues: createFacetedUniqueValues(), facetedMinMaxValues: createFacetedMinMaxValues() }` | +| `flexRender(def, ctx)` | `` / `header={...}` / `footer={...}` | +| `state`, `on*Change` (only) | still supported, plus `atoms` (preferred per slice) | +| `createColumnHelper()` | `createColumnHelper()` — both generics required | +| `ColumnDef` | `ColumnDef` — `TFeatures` is now the first generic | +| `Table` | `Table` | + +Source: `docs/framework/preact/preact-table.md`; `docs/framework/preact/guide/table-state.md`. + +## Migration Steps + +### 1. Update the package import + +```tsx +// v8 +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, + type ColumnDef, +} from '@tanstack/preact-table' + +// v9 +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + type ColumnDef, +} from '@tanstack/preact-table' +``` + +### 2. Declare `_features` and `_rowModels` + +Replace each `get*RowModel: get*RowModel()` option with a feature import + a row-model factory. + +```tsx +// v8 +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), +}) + +// v9 +const _features = tableFeatures({ + rowSortingFeature, + columnFilteringFeature, + rowPaginationFeature, +}) + +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) +``` + +Move `_features` to module scope. Reference stability matters — see `tanstack-table/preact/production-readiness`. + +### 3. Update column types and helpers + +`TFeatures` is now the first generic on `ColumnDef`, `Table`, and `createColumnHelper`. + +```tsx +// v8 +const columnHelper = createColumnHelper() +const columns: ColumnDef[] = columnHelper.columns([ + /* … */ +]) + +// v9 +const columnHelper = createColumnHelper() +const columns: Array> = + columnHelper.columns([ + /* … */ + ]) +``` + +### 4. Replace `flexRender` calls with `table.FlexRender` + +```tsx +// v8 +{flexRender(header.column.columnDef.header, header.getContext())} +{flexRender(cell.column.columnDef.cell, cell.getContext())} + +// v9 +{header.isPlaceholder ? null : } + +``` + +`flexRender` is still exported for advanced cases, but `table.FlexRender` (or the standalone `FlexRender` component) handles grouping placeholder/aggregated branches for you. + +Source: `packages/preact-table/src/FlexRender.tsx`. + +### 5. Move external state to atoms (recommended) + +`state` + `on*Change` still works, but v9 prefers slice atoms for fine-grained reactivity. + +```tsx +// v8 / v9 fallback +const [sorting, setSorting] = useState([]) +const table = useTable({ + _features, + _rowModels, + columns, + data, + state: { sorting }, + onSortingChange: setSorting, +}) + +// v9 preferred — external atom +import { useCreateAtom } from '@tanstack/preact-store' +const sortingAtom = useCreateAtom([]) +const table = useTable({ + _features, + _rowModels, + columns, + data, + atoms: { sorting: sortingAtom }, + // no onSortingChange needed +}) +``` + +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +### 6. Drop `onStateChange` + +The v8-style global `onStateChange` is gone. Subscribe per-slice with `on*Change`, an external atom, or `table.store.subscribe(...)` if you really need every change. + +Source: `docs/framework/preact/guide/table-state.md`. + +## Common Mistakes + +### CRITICAL Keeping `get*RowModel` options after upgrading + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + getSortedRowModel: getSortedRowModel(), // v8 leftover — silently ignored +}) +table.setSorting([{ id: 'age', desc: true }]) // rows are NOT sorted +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +v9 doesn't read the `get*RowModel` options. The row model only runs for stages registered in `_rowModels`, and the feature only mounts if it is in `_features`. +Source: `docs/framework/preact/preact-table.md`. + +### CRITICAL Forgetting to register a feature whose API you are calling + +Wrong: + +```tsx +const _features = tableFeatures({}) // no rowSelectionFeature +const table = useTable({ _features, _rowModels: {}, columns, data }) +table.getIsAllRowsSelected() // TypeScript error / runtime no-op +row.toggleSelected(true) // same +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSelectionFeature }) +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + enableRowSelection: true, +}) +table.getIsAllRowsSelected() +``` + +v9 generates feature APIs and state slices only for registered features. This is the #1 v9 trap. +Source: `docs/guide/features.md`. + +### HIGH Re-using `getCoreRowModel: getCoreRowModel()` from v8 + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + getCoreRowModel: getCoreRowModel(), // no-op in v9 +}) +``` + +Correct: + +```tsx +const table = useTable({ _features, _rowModels: {}, columns, data }) +``` + +The core row model is always included in v9. There is no `getCoreRowModel` option. +Source: `docs/framework/preact/preact-table.md`. + +### HIGH Single-generic column helper / `ColumnDef` + +Wrong: + +```tsx +const columnHelper = createColumnHelper() // v8 shape +const columns: ColumnDef[] = [ + /* … */ +] // v8 shape +``` + +Correct: + +```tsx +const columnHelper = createColumnHelper() +const columns: Array> = [ + /* … */ +] +``` + +`TFeatures` is the first generic for nearly every public type in v9. Without it, types degrade to `any` for feature methods. +Source: `docs/framework/preact/preact-table.md`. + +### HIGH Reimplementing built-ins (the #1 AI tell) + +Wrong: + +```tsx +// Manual sorting, manual filtering, manual pagination, manual row-selection objects +``` + +Correct: register the matching feature in `_features`, register its factory in `_rowModels`, and use the feature API. v9 ships built-ins for sorting, filtering, pagination, grouping, expanding, faceting, row selection, column visibility/order/pinning/sizing, and row pinning. +Source: `docs/guide/features.md`. + +### MEDIUM Calling `flexRender` directly when grouping is on + +Wrong: + +```tsx +{flexRender(cell.column.columnDef.cell, cell.getContext())} +``` + +Correct: + +```tsx + + + +``` + +`` handles aggregated / placeholder cells when `columnGroupingFeature` is registered. Raw `flexRender` does not. +Source: `packages/preact-table/src/FlexRender.tsx`. + +## See Also + +- `tanstack-table/preact/getting-started` — green-field v9 setup. +- `tanstack-table/preact/table-state` — atom model and Subscribe patterns. +- `tanstack-table/preact/production-readiness` — perf, tree-shaking, stable refs. diff --git a/packages/preact-table/skills/preact/production-readiness/SKILL.md b/packages/preact-table/skills/preact/production-readiness/SKILL.md new file mode 100644 index 0000000000..61f4c4af85 --- /dev/null +++ b/packages/preact-table/skills/preact/production-readiness/SKILL.md @@ -0,0 +1,278 @@ +--- +name: preact/production-readiness +description: > + Ship-ready optimizations for `@tanstack/preact-table` v9: tree-shake the + bundle by registering ONLY the `_features` you actually use; memoize + `_features`, `data`, and `columns` for stable identity; replace + `(state) => state` with narrow selectors or per-slice `useSelector` + subscriptions; wrap hot subtrees in ``; and prefer slice + atoms over `state` + `on*Change` for fine-grained updates. Routing keywords: + preact-table performance, optimization, tree-shaking, stable refs, Subscribe, + narrow selector. +type: lifecycle +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - preact/table-state +sources: + - TanStack/table:docs/guide/features.md + - TanStack/table:docs/framework/preact/guide/table-state.md + - TanStack/table:examples/preact/basic-subscribe/src/main.tsx + - TanStack/table:examples/preact/basic-external-atoms/src/main.tsx + - TanStack/table:packages/preact-table/src/useTable.ts +--- + +This skill collects the production-readiness levers for a Preact v9 table. Each one is independent — apply only the ones whose problem you actually have. + +## 1. Tree-Shake `_features` + +Only register features the table actually uses. v9's bundle savings come from `_features` controlling which feature code (and which state slices and APIs) get included. + +```tsx +// Bad — pulls every feature into the bundle even if the UI never uses them. +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + rowSelectionFeature, + columnFilteringFeature, + globalFilteringFeature, + columnFacetingFeature, + globalFacetingFeature, + columnGroupingFeature, + rowExpandingFeature, + columnSizingFeature, + columnVisibilityFeature, + columnOrderingFeature, + columnPinningFeature, + rowPinningFeature, +}) + +// Good — feature list matches what the UI exposes. +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + rowSelectionFeature, +}) +``` + +Same idea for `_rowModels` — only register the row-model factory for features that need one and that you have registered. + +Source: `docs/guide/features.md`; `docs/framework/preact/preact-table.md`. + +## 2. Stable References for `_features`, `columns`, `data`, `_rowModels` + +Identity drives every internal memo. Declare these at module scope when possible; otherwise wrap with `useMemo`. + +```tsx +// Best — module scope. Single allocation. +const _features = tableFeatures({ rowSortingFeature }) +const columns: Array> = [ + /* … */ +] +const EMPTY: Person[] = [] + +function MyTable({ rows }: { rows: Person[] | undefined }) { + const data = rows ?? EMPTY + const table = useTable({ _features, _rowModels: {}, columns, data }) +} + +// Okay — useMemo for dynamic columns. +function MyTable({ visibleKeys }: { visibleKeys: string[] }) { + const columns = useMemo( + () => visibleKeys.map((k) => columnHelper.accessor(k as any, {})), + [visibleKeys.join(',')], + ) +} +``` + +Source: `docs/framework/preact/guide/table-state.md` (FAQ #1). + +## 3. Narrow `useTable` Selector + +The default selector `(state) => state` re-renders the component on any registered slice change. Narrow it to just the slices the component reads. The Preact adapter uses `shallow` compare from `@tanstack/preact-store` — projected objects only trigger a render when a member changes. + +```tsx +// All slices — fine for a small table. +const table = useTable(opts, (state) => state) + +// Narrow — re-render only on sorting/pagination changes. +const table = useTable(opts, (state) => ({ + sorting: state.sorting, + pagination: state.pagination, +})) +table.state.pagination + +// Opt-out at the parent; do subscriptions lower in the tree. +const table = useTable(opts, () => null) +``` + +Source: `examples/preact/basic-subscribe/src/main.tsx`. + +## 4. Wrap Hot Subtrees in `` + +Once the parent uses `() => null`, push subscriptions next to the UI that actually reads them. Subscribe to single atoms (`source={table.atoms.X}`) to avoid re-deriving the flat store on unrelated changes. + +```tsx +const table = useTable(opts, () => null) + +// Row body — re-render only when filters/pagination cause the row model to change. + ({ + columnFilters: s.columnFilters, + globalFilter: s.globalFilter, + pagination: s.pagination, + })} +> + {() => ( + + {table.getRowModel().rows.map((row) => ( + {/* … */} + ))} + + )} + + +// Per-row selection checkbox — narrow to that row's selection bit. + rs[row.id]} +> + {(isSelected) => ( + + )} + +``` + +Source: `examples/preact/basic-subscribe/src/main.tsx`. + +## 5. Prefer Slice Atoms over `state` + `on*Change` + +External `state` + `on*Change` re-renders the whole component that owns the `useState`. Slice atoms let `useSelector` / `` subscribe individually. + +```tsx +// Less granular — every slice change re-renders this component. +const [sorting, setSorting] = useState([]) +const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) +useTable({ + /* … */, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, +}) + +// More granular — independent atom subscriptions. +const sortingAtom = useCreateAtom([]) +const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) +useTable({ /* … */, atoms: { sorting: sortingAtom, pagination: paginationAtom } }) +``` + +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +## 6. Set Sensible `initialState` Once + +Use `initialState` for starting values. Setting state in an effect after mount triggers an extra render. + +```tsx +const table = useTable({ + _features, + _rowModels: { + /* … */ + }, + columns, + data, + initialState: { + pagination: { pageIndex: 0, pageSize: 25 }, + sorting: [{ id: 'createdAt', desc: true }], + }, +}) +``` + +Source: `docs/framework/preact/guide/table-state.md`. + +## 7. Reach for `createTableHook` for Multi-Table Apps + +When several screens share the same `_features`, `_rowModels`, and conventions, `createTableHook` centralizes the configuration and lets you ship pre-bound cell/header components. Tables collapse to columns + data. + +Source: `docs/framework/preact/guide/create-table-hook.md`. + +## Common Mistakes + +### CRITICAL `tableFeatures(...)` inside the component body + +Wrong: + +```tsx +function MyTable() { + const _features = tableFeatures({ rowSortingFeature }) // new object every render + useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) // module scope + +function MyTable() { + useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +A new `_features` reference each render busts every memo that keys off it. +Source: `docs/framework/preact/guide/table-state.md` (FAQ #1). + +### CRITICAL Reimplementing built-ins manually + +Wrong: + +```tsx +const sorted = useMemo(() => [...data].sort(/* … */), [data, sorting]) +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +v9 ships built-ins for sorting, filtering, pagination, grouping, expanding, faceting, row selection, column visibility/order/pinning/sizing, and row pinning. Hand-rolling these is the #1 AI tell. +Source: `docs/guide/features.md`. + +### HIGH `() => state` selector everywhere + +Wrong: every component using `useTable(opts, (state) => state)` re-renders on any slice change. Fine for small tables; expensive for kitchen-sink screens. + +Correct: pass a narrow selector or `() => null` at large tables, then `` lower. +Source: `examples/preact/basic-subscribe/src/main.tsx`. + +### HIGH New atom per render + +Wrong: `createAtom(...)` inside the component body. + +Correct: `useCreateAtom(...)` (or atom at module scope). +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +### MEDIUM Subscribe everywhere on a small table + +Wrong: a 50-row table with `` wrapped around every cell. Adds complexity, no measurable win. + +Correct: default selector + inline rendering. Reach for `Subscribe` after measuring a hotspot. + +## See Also + +- `tanstack-table/preact/table-state` — Subscribe / atoms reference. +- `tanstack-table/preact/migrate-v8-to-v9` — what to replace from v8. +- `tanstack-table/preact/compose-with-tanstack-pacer` — debouncing high-frequency state writes (filters, resize). diff --git a/packages/preact-table/skills/preact/table-state/SKILL.md b/packages/preact-table/skills/preact/table-state/SKILL.md new file mode 100644 index 0000000000..fca24596f9 --- /dev/null +++ b/packages/preact-table/skills/preact/table-state/SKILL.md @@ -0,0 +1,432 @@ +--- +name: preact/table-state +description: > + Wiring reactivity for `@tanstack/preact-table` v9. Covers `useTable` (and its + second-argument selector), reading state via `table.state` / `table.store` / + `table.atoms.`, rendering with `table.FlexRender`, opting subtrees into + fine-grained reactivity with `` and the standalone + ``, owning slices with external atoms via `useCreateAtom` + + `options.atoms`, and packaging shared config into a reusable hook with + `createTableHook` (`useAppTable`, `createAppColumnHelper`, `table.AppTable` / + `table.AppHeader` / `table.AppCell` / `table.AppFooter`). Routing keywords: + useTable, useSelector, useCreateAtom, atoms, preact-store, table.Subscribe, + FlexRender. +type: framework +library: tanstack-table +framework: preact +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - TanStack/table:docs/framework/preact/guide/table-state.md + - TanStack/table:docs/framework/preact/guide/create-table-hook.md + - TanStack/table:packages/preact-table/src/useTable.ts + - TanStack/table:packages/preact-table/src/Subscribe.ts + - TanStack/table:packages/preact-table/src/FlexRender.tsx + - TanStack/table:packages/preact-table/src/createTableHook.tsx + - TanStack/table:examples/preact/basic-use-table/src/main.tsx + - TanStack/table:examples/preact/basic-subscribe/src/main.tsx + - TanStack/table:examples/preact/basic-external-atoms/src/main.tsx + - TanStack/table:examples/preact/basic-external-state/src/main.tsx + - TanStack/table:examples/preact/basic-use-app-table/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/setup`. Read those first — `state-management` explains the v9 atom model (per-slice readonly `table.atoms`, internal writable `table.baseAtoms`, flat `table.store`). The Preact adapter closely mirrors the React adapter: `useTable` returns a `PreactTable` whose state is backed by TanStack Store atoms, and `` lets components subscribe to slices fine-grained. + +## Setup + +Every Preact v9 table follows the same shape. Define `_features`, `_rowModels`, and `columns` at module scope so their references are stable, then call `useTable` and render with ``. + +```tsx +import { render } from 'preact' +import { useState } from 'preact/hooks' +import { + createColumnHelper, + createSortedRowModel, + rowSortingFeature, + sortFns, + tableFeatures, + useTable, +} from '@tanstack/preact-table' + +type Person = { firstName: string; lastName: string; age: number } + +// Module-scope = stable identity. Critical for re-render perf. +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +function PeopleTable({ data }: { data: Person[] }) { + const table = useTable( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }, + (state) => state, // default selector + ) + + return ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + +
+ {h.isPlaceholder ? null : } +
+ +
+ ) +} +``` + +Source: `examples/preact/basic-use-table/src/main.tsx`. + +## Core Patterns + +### 1. `useTable` selector (second argument) + +The default selector returns the full `TableState` — the component re-renders on any registered state slice change. Pass a narrower selector once you have a measurable perf problem, or pass `() => null` to opt the parent out at the top level and use `` walls instead. + +The Preact adapter uses `useSelector` from `@tanstack/preact-store` with `shallow` compare under the hood. + +```tsx +// Narrow selector — re-render only on sorting/pagination changes. +const table = useTable( + { + _features, + _rowModels: { + /*…*/ + }, + columns, + data, + }, + (state) => ({ sorting: state.sorting, pagination: state.pagination }), +) + +table.state.sorting // typed to the projected shape + +// Or: subscribe to nothing at the top level; read inside . +const table = useTable(opts, () => null) +``` + +Source: `docs/framework/preact/guide/table-state.md`; `examples/preact/basic-subscribe/src/main.tsx` (uses `() => null`). + +### 2. `` and standalone `` + +Use `` at the component level. Inside cell/header render contexts, `table` is the core `Table` (not `PreactTable`), so `table.Subscribe` is **not on the object** — import the standalone `` and pass `source={table.store}` or `source={table.atoms.X}`. + +```tsx +import { Subscribe } from '@tanstack/preact-table' + +// Component-level: selector against table.store. + s.pagination}> + {(pagination) => Page {pagination.pageIndex + 1}} + + +// Single-atom source — narrower than table.store. + + {(rowSelection) => {Object.keys(rowSelection).length} selected} + + +// Per-row identity projection — re-renders only that row's checkbox. + rs[row.id]} +> + {(isSelected) => ( + + )} + + +// Inside a cell — table here is the CORE Table, no .Subscribe. Use the import. +columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + s[row.id]} + > + {(isSelected) => ( + + )} + + ), +}) +``` + +Source: `packages/preact-table/src/Subscribe.ts`; `examples/preact/basic-subscribe/src/main.tsx`. + +### 3. External atoms with `useCreateAtom` + `options.atoms` + +Move ownership of any slice to an atom you create with `useCreateAtom` from `@tanstack/preact-store`. Pass it via `options.atoms.`. The table writes to your atom when you call `table.setSorting(...)`, `table.setPageIndex(...)`, etc. — **no `on*Change` handler is needed**. + +Precedence: `options.atoms[key]` > `options.state[key]` > internal `baseAtoms[key]`. Don't pass both `state.foo` and `atoms.foo` for the same slice; `atoms` wins silently. + +```tsx +import { useCreateAtom, useSelector } from '@tanstack/preact-store' +import type { PaginationState, SortingState } from '@tanstack/preact-table' + +function MyTable({ data }) { + const sortingAtom = useCreateAtom([]) + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + + // Fine-grained subscriptions independent of the table. + const sorting = useSelector(sortingAtom) + const pagination = useSelector(paginationAtom) + + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + atoms: { sorting: sortingAtom, pagination: paginationAtom }, + // NOTE: no onSortingChange / onPaginationChange — table writes directly to atoms. + }) +} +``` + +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +### 4. External state with `state` + `on*Change` and `createTableHook` + +Classic `useState` + `on*Change` integration (v8 migration paths) and the `createTableHook` factory for packaging shared `_features` / `_rowModels` / cell components into `useAppTable` + `createAppColumnHelper` + `table.AppTable` / `AppHeader` / `AppCell` / `AppFooter` boundaries — see [advanced-state-patterns.md](references/advanced-state-patterns.md). + +## Common Mistakes + +### CRITICAL Reading `table.atoms.X.get()` during render and expecting re-renders + +Wrong: + +```tsx +function Pager({ table }) { + const pagination = table.atoms.pagination.get() // current-value read, NOT a subscription + return Page {pagination.pageIndex + 1} +} +``` + +Correct: + +```tsx +function Pager({ table }) { + return ( + p.pageIndex} + > + {(pageIndex) => Page {pageIndex + 1}} + + ) +} +``` + +`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +Source: `docs/framework/preact/guide/table-state.md`. + +### HIGH Passing both `atoms.X` and `state.X` for the same slice + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + state: { pagination }, // silently ignored + onPaginationChange: setPagination, // silently ignored +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, +}) +``` + +Precedence is `options.atoms[key]` > `options.state[key]` > internal. `state` is dropped without a warning when `atoms` is provided for the same slice. +Source: `docs/framework/preact/guide/table-state.md`. + +### HIGH Using `table.Subscribe` inside a column cell or header render + +Wrong: + +```tsx +cell: ({ row, table }) => ( + s[row.id]} + > + {(isSelected) => } + +) +``` + +Correct: + +```tsx +import { Subscribe } from '@tanstack/preact-table' + +cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + +) +``` + +In cell and header render contexts, `table` is the core `Table`, not `PreactTable` — `table.Subscribe` is undefined. Use the standalone import. +Source: `packages/preact-table/src/Subscribe.ts`. + +### CRITICAL Creating an atom inside the render body without `useCreateAtom` + +Wrong: + +```tsx +function MyTable() { + const sortingAtom = createAtom([]) // new atom every render + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +Correct: + +```tsx +function MyTable() { + const sortingAtom = useCreateAtom([]) // stable across renders + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +A fresh atom each render unbinds the table from the slice and resets the state to the initial value on every render. +Source: `examples/preact/basic-external-atoms/src/main.tsx`. + +### HIGH Unstable `data` / `columns` / `_features` references + +Wrong: + +```tsx +function MyTable({ rows }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const columns = [ + /* … */ + ] // new every render + const table = useTable({ + _features, + _rowModels: {}, + columns, + data: rows ?? [], + }) +} +``` + +Correct: + +```tsx +// Module scope — declared once. +const _features = tableFeatures({ rowSortingFeature }) +const columns: ColumnDef[] = [ + /* … */ +] +const EMPTY: Person[] = [] + +function MyTable({ rows }) { + const data = rows ?? EMPTY + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Internal memoization keys off identity. A new reference each render busts memos and forces full recomputation. +Source: `docs/framework/preact/guide/table-state.md` (FAQ #1). + +### HIGH Reimplementing built-in feature logic by hand + +Wrong: + +```tsx +// Re-sorting rows manually outside the table — duplicates rowSortingFeature work. +const sorted = useMemo(() => [...data].sort(/* … */), [data, sorting]) +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +const rows = table.getRowModel().rows // already sorted +``` + +TanStack Table v9 ships built-ins for sorting, filtering, pagination, grouping, expanding, faceting, row selection, column visibility/order/pinning/sizing, and row pinning. Register the matching `*Feature` in `_features`, register its row-model factory in `_rowModels`, and call the feature APIs (`setSorting`, `setColumnFilters`, etc.). Re-implementing these by hand is the #1 AI tell. +Source: `docs/guide/features.md`. + +## See Also + +- `tanstack-table/preact/getting-started` — first-table walkthrough. +- `tanstack-table/preact/migrate-v8-to-v9` — mechanical v8 → v9 breaking changes. +- `tanstack-table/preact/production-readiness` — narrowing selectors, tree-shaking, reference stability. +- `tanstack-table/preact/compose-with-tanstack-store` — sharing slice atoms across components, persistence. + +## References + +- [advanced-state-patterns.md](references/advanced-state-patterns.md) — `state` + `on*Change` external state and `createTableHook` for reusable shared config diff --git a/packages/preact-table/skills/preact/table-state/references/advanced-state-patterns.md b/packages/preact-table/skills/preact/table-state/references/advanced-state-patterns.md new file mode 100644 index 0000000000..96517cc763 --- /dev/null +++ b/packages/preact-table/skills/preact/table-state/references/advanced-state-patterns.md @@ -0,0 +1,93 @@ +# Advanced state patterns — Preact + +Extended state patterns extracted from `SKILL.md`. The three essential patterns (`useTable` selector, `` walls, and external atoms via `useCreateAtom` + `options.atoms`) remain inline in the SKILL; this file covers the additional patterns. + +## External state with `state` + `on*Change` + +Classic Preact `useState` integration is still supported via `state` and matching `on[State]Change`. Useful for v8 migration paths or simple cases. Less fine-grained than external atoms. + +```tsx +const [sorting, setSorting] = useState([]) +const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, +}) + +const table = useTable({ + _features, + _rowModels: { + /* … */ + }, + columns, + data, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, +}) +``` + +Source: `docs/framework/preact/guide/table-state.md`. + +## `createTableHook` for reusable shared config + +When you ship the same `_features` / `_rowModels` / cell components across many tables, package them with `createTableHook`. You get `useAppTable`, `createAppColumnHelper`, `useTableContext` / `useCellContext` / `useHeaderContext`, and `table.AppTable` / `AppHeader` / `AppCell` / `AppFooter` boundaries. + +```tsx +import { + createTableHook, + rowPaginationFeature, + rowSortingFeature, + createPaginatedRowModel, + createSortedRowModel, + sortFns, + tableFeatures, +} from '@tanstack/preact-table' + +export const { + useAppTable, + createAppColumnHelper, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + _features: tableFeatures({ rowPaginationFeature, rowSortingFeature }), + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + }, + tableComponents: { PaginationControls, RowCount }, + cellComponents: { TextCell, NumberCell }, + headerComponents: { SortIndicator }, +}) + +const columnHelper = createAppColumnHelper() + +function UsersTable({ data }: { data: Person[] }) { + const table = useAppTable({ columns, data }) + + return ( + + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((c) => ( + + {(cell) => ( + + )} + + ))} + + ))} + +
+ +
+ +
+ ) +} +``` + +Source: `docs/framework/preact/guide/create-table-hook.md`; `examples/preact/basic-use-app-table/src/main.tsx`; `packages/preact-table/src/createTableHook.tsx`. diff --git a/packages/react-table-devtools/README.md b/packages/react-table-devtools/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/react-table-devtools/README.md +++ b/packages/react-table-devtools/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/react-table-devtools/package.json b/packages/react-table-devtools/package.json index 35e31a8e5b..a0cc76b646 100644 --- a/packages/react-table-devtools/package.json +++ b/packages/react-table-devtools/package.json @@ -18,7 +18,8 @@ "react", "tanstack", "table", - "devtools" + "devtools", + "tanstack-intent" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", @@ -44,7 +45,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "dependencies": { "@tanstack/devtools-utils": "^0.5.0", diff --git a/packages/react-table-devtools/skills/react/compose-with-tanstack-devtools/SKILL.md b/packages/react-table-devtools/skills/react/compose-with-tanstack-devtools/SKILL.md new file mode 100644 index 0000000000..3475e0582f --- /dev/null +++ b/packages/react-table-devtools/skills/react/compose-with-tanstack-devtools/SKILL.md @@ -0,0 +1,172 @@ +--- +name: react/compose-with-tanstack-devtools +description: > + Wire up TanStack Devtools for TanStack Table in React. Mount `TanStackDevtools` + with `tableDevtoolsPlugin()` once at the app root and call + `useTanStackTableDevtools(table, name?)` after each `useTable` so the table is + registered as a devtools target. Live devtools are tree-shaken to no-ops in + production unless you import from `@tanstack/react-table-devtools/production`. +type: composition +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - state-management + - react/table-state +sources: + - TanStack/table:docs/devtools.md + - TanStack/table:packages/react-table-devtools/src/index.ts + - TanStack/table:packages/react-table-devtools/src/plugin.tsx + - TanStack/table:packages/react-table-devtools/src/useTanStackTableDevtools.ts + - TanStack/table:packages/react-table-devtools/src/production.ts +--- + +This skill builds on `tanstack-table/react/table-state`. Read that first — the devtools panel inspects whatever table instance you hand it, so you need a working `useTable` before this skill is useful. + +## Setup + +Install the TanStack Devtools host and the React Table adapter: + +```sh +pnpm add @tanstack/react-devtools @tanstack/react-table-devtools +``` + +The recommended pattern has two parts: + +1. Mount `` once at the app root with `tableDevtoolsPlugin()`. +2. Call `useTanStackTableDevtools(table, name?)` right after every `useTable()`. + +```tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useTable } from '@tanstack/react-table' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/react-table-devtools' + +function UsersScreen() { + const table = useTable({ + _features, + _rowModels, + columns, + data, + }) + + // Register this table with the devtools panel. + useTanStackTableDevtools(table, 'Users Table') + + return +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + {/* Mount once, anywhere in the tree. */} + + , +) +``` + +`tableDevtoolsPlugin()` returns a plugin descriptor for the multi-panel TanStack Devtools UI. `useTanStackTableDevtools` is the hook that upserts/removes the registration target on mount/unmount and re-runs when `table` or `name` changes. + +## Patterns + +### Naming Tables + +The optional second argument labels the table in the panel selector. Without it, devtools assign fallback names like `Table 1` and `Table 2`. + +```tsx +useTanStackTableDevtools(table, 'Orders Table') +``` + +### Multiple Tables + +You can register as many tables as you like. The Table panel renders a selector so you can switch between them. Always name each one — fallback `Table N` labels are not stable across renders/mounts and make the selector confusing once you have 3+ tables. + +```tsx +function Dashboard() { + const ordersTable = useTable(ordersOptions) + const usersTable = useTable(usersOptions) + + useTanStackTableDevtools(ordersTable, 'Orders') + useTanStackTableDevtools(usersTable, 'Users') + + return +} +``` + +### Disabling Per Table + +`useTanStackTableDevtools` accepts an `enabled` option. When `false`, the registration is removed (so the table disappears from the panel) but the hook still runs cleanly. Useful for feature flags or per-route opt-out. + +```tsx +useTanStackTableDevtools(table, 'Users Table', { + enabled: import.meta.env.DEV && showTableDevtools, +}) +``` + +### Production Builds + +The default `@tanstack/react-table-devtools` entrypoint swaps to no-op implementations when `process.env.NODE_ENV !== 'development'`. To ship the real devtools to production, switch BOTH imports to the `/production` entrypoint: + +```tsx +import { TanStackDevtools } from '@tanstack/react-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/react-table-devtools/production' +``` + +If you mix entrypoints (one from `/production`, one from the default), one side is a no-op in production and the panel will appear empty. + +### Conditional Devtools by Env + +A common pattern is to lazy-load the production entrypoint behind a feature flag so the devtools bundle does not ship to all users: + +```tsx +const TableDevtools = React.lazy(async () => { + const { tableDevtoolsPlugin } = + await import('@tanstack/react-table-devtools/production') + const { TanStackDevtools } = await import('@tanstack/react-devtools') + return { + default: () => , + } +}) + +function Root() { + return ( + <> + + {showDevtools && ( + + + + )} + + ) +} +``` + +## Common Mistakes + +### Forgetting to mount `` at the app root + +Calling `useTanStackTableDevtools(table)` alone does nothing visible — it only registers the table with the devtools target store. Without a `` somewhere in the tree, there is no panel to render the registration. Symptom: hook runs without errors, no devtools button appears. + +### Importing devtools from the default path in a prod-only bundle + +If you only deploy production builds (e.g. an E2E preview, a staging environment that builds with `NODE_ENV=production`), `@tanstack/react-table-devtools` resolves to no-op implementations. The plugin will mount, but the panel will be empty. Use `@tanstack/react-table-devtools/production` instead if you want the real devtools available there. + +### Accidentally shipping devtools to end users via `/production` + +The flip side: importing from `/production` in your default app bundle means every visitor downloads and runs the devtools UI. That is usually not what you want. Restrict `/production` imports to dev/preview entrypoints, code-split them behind a flag, or keep them out of the default app entirely. + +### Calling `useTanStackTableDevtools` outside the component that owns the table + +The hook needs a `Table` instance to register. If you call it in a parent before `useTable` runs, or inside a sibling that does not have access to the table, you pass `undefined` and nothing is registered. Always call it in the same component as `useTable`, immediately after. + +### Multiple tables without names + +Two `useTanStackTableDevtools(table)` calls without a name produces selector entries like `Table 1` / `Table 2` (or fallback labels assigned in registration order). When you have 3+ tables this becomes unusable. Always pass a descriptive name as the second argument. diff --git a/packages/react-table/README.md b/packages/react-table/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/react-table/README.md +++ b/packages/react-table/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/react-table/package.json b/packages/react-table/package.json index 19a68446bb..5675cb9493 100644 --- a/packages/react-table/package.json +++ b/packages/react-table/package.json @@ -18,7 +18,8 @@ "react", "table", "react-table", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "./dist/index.d.cts", @@ -49,7 +50,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/react-table/skills/react/client-to-server/SKILL.md b/packages/react-table/skills/react/client-to-server/SKILL.md new file mode 100644 index 0000000000..42012c77c4 --- /dev/null +++ b/packages/react-table/skills/react/client-to-server/SKILL.md @@ -0,0 +1,377 @@ +--- +name: react/client-to-server +description: > + Convert a client-side `@tanstack/react-table` v9 table to server-side + (manual modes). Pass server-paginated/sorted/filtered rows as `data`, set + `manualPagination` / `manualSorting` / `manualFiltering` / `manualGrouping` / + `manualExpanding` for whatever the server now owns, supply `rowCount` so + `getPageCount()` works, and DROP the matching `_rowModels` entry (no + `paginatedRowModel` if the server paginates). Own the relevant state slices + via external atoms (`useCreateAtom` + `options.atoms`) so a query can key on + the slice and refetch automatically — OR via classic `state` + `on*Change` + controlled state. +type: lifecycle +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - state-management + - pagination + - filtering + - sorting + - react/table-state +sources: + - TanStack/table:examples/react/basic-external-atoms/src/main.tsx + - TanStack/table:examples/react/with-tanstack-query/src/main.tsx + - TanStack/table:examples/react/with-tanstack-query/src/fetchData.ts +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — the atom model is what makes the cleanest server-side wiring possible. + +## Why "client-to-server" + +A client-side table sees every row, sorts/filters/paginates them locally, and renders a slice. A server-side table sees only the slice the server returned for the current request; the table must be told "don't try to slice this again — and here's the total row count so you can render a pager". + +Four moves convert any client table to a server table: + +1. **`manualX: true`** for whichever operations the server owns. +2. **Drop the matching factory** from `_rowModels` so it doesn't ship in your bundle. +3. **Provide `rowCount`** so `table.getPageCount()` / `getCanNextPage()` work. +4. **Own the slice state externally** so your data fetcher can key on it. + +## Setup + +Two state-ownership patterns work; pick one per slice. + +### Pattern A — external atom (cleanest with Query/SWR) + +```tsx +import * as React from 'react' +import { useCreateAtom, useSelector } from '@tanstack/react-store' +import { + useTable, + tableFeatures, + rowPaginationFeature, + createColumnHelper, +} from '@tanstack/react-table' +import type { PaginationState } from '@tanstack/react-table' + +const _features = tableFeatures({ rowPaginationFeature }) +const columnHelper = createColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +const EMPTY: Person[] = [] + +function ServerTable() { + // 1) Own pagination in an external atom. + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + const pagination = useSelector(paginationAtom) + + // 2) Fetch keyed on the atom value. + const [serverPage, setServerPage] = React.useState<{ + rows: Person[] + rowCount: number + } | null>(null) + React.useEffect(() => { + let cancelled = false + fetchPeople(pagination).then((page) => { + if (!cancelled) setServerPage(page) + }) + return () => { + cancelled = true + } + }, [pagination]) + + // 3) Manual pagination + rowCount. No paginatedRowModel in _rowModels. + const table = useTable({ + _features, + _rowModels: {}, // core only — server slices + columns, + data: serverPage?.rows ?? EMPTY, // EMPTY at module scope + rowCount: serverPage?.rowCount, + atoms: { pagination: paginationAtom }, // table writes here directly + manualPagination: true, + }) + // No onPaginationChange — table.setPageIndex(...) writes through the atom. +} +``` + +Source: `examples/react/basic-external-atoms/src/main.tsx` (atoms wiring); `examples/react/with-tanstack-query/src/main.tsx` (rowCount + manualPagination). + +### Pattern B — classic `state` + `on*Change` + +```tsx +const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, +}) + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: serverPage?.rows ?? EMPTY, + rowCount: serverPage?.rowCount, + state: { pagination }, + onPaginationChange: setPagination, // REQUIRED with state.pagination + manualPagination: true, +}) +``` + +Both work. `state` + `on*Change` is familiar from v8; atoms compose more cleanly with Query (the table writes to the atom, the query key includes the atom value, the query refetches automatically). + +## Core Patterns + +### Combining server-side sort + filter + pagination + +Add the matching `manual*` flags for each operation the server now owns. Local features (column visibility, ordering, pinning) still work because they don't depend on the row model. + +```tsx +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnVisibilityFeature, // still local + columnPinningFeature, // still local +}) + +const sortingAtom = useCreateAtom([]) +const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, +}) +const columnFiltersAtom = useCreateAtom([]) + +const sorting = useSelector(sortingAtom) +const pagination = useSelector(paginationAtom) +const columnFilters = useSelector(columnFiltersAtom) + +const serverArgs = { sorting, pagination, columnFilters } +// ... fetch keyed on serverArgs + +const table = useTable({ + _features, + _rowModels: {}, // no sorted/filtered/paginated factories — server owns them + columns, + data: serverPage?.rows ?? EMPTY, + rowCount: serverPage?.rowCount, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + columnFilters: columnFiltersAtom, + }, + manualSorting: true, + manualFiltering: true, + manualPagination: true, +}) +``` + +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### When NOT to manual-mode a slice + +If the server returns the **entire** dataset, leave the table client-side. Manual mode is for slices the server has already trimmed. + +## Common Mistakes + +### CRITICAL Forgetting `manualPagination` / `manualSorting` / `manualFiltering` + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: serverPage.rows, + // missing manualPagination +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, // dropped — server paginates + columns, + data: serverPage.rows, + rowCount: serverPage.rowCount, + manualPagination: true, +}) +``` + +Without `manualPagination: true`, the table tries to slice the already-server-sliced page a second time, producing rows that don't exist (or visibly wrong pagination). +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### CRITICAL Missing `rowCount` + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: serverPage.rows, + manualPagination: true, + // missing rowCount: serverPage.totalRowCount +}) +// table.getPageCount() → 1, pager locks at "Page 1 of 1" +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: serverPage.rows, + rowCount: serverPage.rowCount, // ← required for accurate pager + manualPagination: true, +}) +``` + +Without `rowCount`, `getPageCount()` falls back to `Math.ceil(data.length / pageSize)` — which is 1 if the server returned a single page. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### HIGH `state.pagination` without `onPaginationChange` + +Wrong: + +```tsx +const [pagination] = React.useState({ + pageIndex: 0, + pageSize: 10, +}) +useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, + // missing onPaginationChange — table.setPageIndex is a no-op + manualPagination: true, +}) +``` + +Correct: + +```tsx +const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, +}) +useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, + onPaginationChange: setPagination, // ← required + manualPagination: true, +}) +// OR — use atoms.pagination instead, which doesn't need a writeback handler. +``` + +The library treats `state` as controlled; without a writeback handler, `table.setPageIndex(...)` writes nowhere. +Source: `docs/framework/react/guide/table-state.md`. + +### HIGH Leaving `paginatedRowModel` registered for a server-paginated table + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, // ships for nothing + columns, + data: serverPage.rows, + manualPagination: true, +}) +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, // drop it — server owns pagination + columns, + data: serverPage.rows, + rowCount: serverPage.rowCount, + manualPagination: true, +}) +``` + +The factory ships in your bundle for no reason. Manual mode + the factory will also let the factory re-slice your already-sliced server page if `manualPagination` is ever flipped off. +Source: maintainer guidance. + +### HIGH Mixing `state.X` and `atoms.X` for the same slice + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, // silently ignored + onPaginationChange: setPagination, // silently ignored + atoms: { pagination: paginationAtom }, // wins + manualPagination: true, +}) +``` + +Correct: + +```tsx +// Pick one ownership mechanism per slice. +useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Precedence is `options.atoms[key]` > `options.state[key]` > internal. The `state` plumbing is dead code in this configuration. +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### MEDIUM Recreating `data` array identity in JSX + +Wrong: + +```tsx + +// `?? []` produces a new array reference each render → internal memos bust +``` + +Correct: + +```tsx +const EMPTY: Person[] = [] // module scope + + +// or wrap in useMemo +``` + +Internal memoization keys off identity. A fresh `[]` each render bypasses memos and may force re-computation. +Source: maintainer guidance; `examples/react/with-tanstack-query/src/main.tsx`. + +## See Also + +- `tanstack-table/react/compose-with-tanstack-query` — the canonical Query + server-side pattern. +- `tanstack-table/react/compose-with-tanstack-store` — sharing slice atoms across components. +- `tanstack-table/react/table-state` — selectors, ``, external atoms. +- `tanstack-table/react/production-readiness` — when to narrow selectors as the table grows. diff --git a/packages/react-table/skills/react/compose-with-tanstack-form/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-form/SKILL.md new file mode 100644 index 0000000000..912b4613ab --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-form/SKILL.md @@ -0,0 +1,363 @@ +--- +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 + `{(field) => }`. + Critical typing gotcha: if your row has a recursive `subRows`, use + `Omit` 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.47' +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 + +```bash +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`. + +```tsx +import { createFormHook, createFormHookContexts } from '@tanstack/react-form' + +const { fieldContext, formContext } = createFormHookContexts() + +function TextField() { + /* reads field state from fieldContext */ +} +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 + +```tsx +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' + +// CRITICAL: flatten recursive subRows before handing rows to the form. +// Without Omit, TanStack Form's DeepKeys walks subRows and hits TS2589. +type FormRow = Omit + +const _features = tableFeatures({ + rowPaginationFeature, + columnFilteringFeature, +}) +const columnHelper = createColumnHelper() + +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) }) }, + }) + + // Memo'd columns — field bindings close over `form`, so without memoization + // we'd build new column defs on every keystroke. + const columns = React.useMemo( + () => + columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + columnHelper.accessor('age', { + header: 'Age', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + columnHelper.accessor('status', { + header: 'Status', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + ]), + [form], + ) + + // Subscribe ONLY to length — triggers re-renders on add/remove without infinite loops + // (vs subscribing to data, which fires on every keystroke). + 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, // table reads fresh form values each render + }) + + const addRow = () => + form.pushFieldValue('data', { + firstName: '', + lastName: '', + age: 0, + visits: 0, + progress: 0, + status: 'single', + }) + + const refreshData = () => form.reset({ data: makeData(100) }) + + return ( + <> + + + + {/* … */} + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + +
+ +
+ + + ) +} +``` + +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: + +```tsx +const form = useAppForm({ defaultValues: { data: makeData(100) as Person[] } }) +// TanStack Form's DeepKeys walks Person.subRows recursively → TS2589 +// ("Type instantiation is excessively deep and possibly infinite") +``` + +Correct: + +```tsx +type FormRow = Omit +const initialData: FormRow[] = makeData(100) +const form = useAppForm({ defaultValues: { data: initialData } }) +const columnHelper = createColumnHelper() +``` + +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: + +```tsx +// Every keystroke in any cell re-renders App → recreates form → re-binds every cell. +const data = useStore(form.store, (s) => s.values.data) +``` + +Correct: + +```tsx +// Subscribe to length only — triggers re-renders on add/remove, ignores edits. +const dataLength = useStore(form.store, (state) => state.values.data.length) +void dataLength +// Table reads `data: form.state.values.data` directly on render. +``` + +Source: `examples/react/with-tanstack-form/src/main.tsx`. + +### HIGH Forgetting `useMemo` around columns + +Wrong: + +```tsx +function App() { + const form = useAppForm({ + /* … */ + }) + const columns = columnHelper.columns([ + // new column defs every render + columnHelper.accessor('firstName', { + cell: ({ row }) => ( + + {(field) => } + + ), + }), + ]) +} +``` + +Correct: + +```tsx +const columns = React.useMemo( + () => + columnHelper.columns([ + columnHelper.accessor('firstName', { + cell: ({ row }) => ( + + {(field) => } + + ), + }), + ]), + [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: + +```tsx +const table = useTable({ + _features, + _rowModels: { + /* … */ + }, + columns, + data: form, // wrong — table only needs the row array +}) +``` + +Correct: + +```tsx +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: + +```tsx +// v8 muscle memory: track edits in tableMeta with a per-cell useState. +const table = useReactTable({ + data, + columns, + meta: { + updateData: (rowIndex, columnId, value) => { + /* manual setState dance */ + }, + }, +}) +``` + +Correct: + +```tsx +// v9 idiom: TanStack Form owns the data, table renders it. +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. diff --git a/packages/react-table/skills/react/compose-with-tanstack-pacer/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-pacer/SKILL.md new file mode 100644 index 0000000000..11b30fe61c --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-pacer/SKILL.md @@ -0,0 +1,287 @@ +--- +name: react/compose-with-tanstack-pacer +description: > + Use `@tanstack/react-pacer` to debounce/throttle the high-frequency writes + that drive an interactive `@tanstack/react-table` v9 table: column filter + inputs and column resize state. Pattern: import `useDebouncedCallback` + from `@tanstack/react-pacer/debouncer`, wrap your `onChange` writer in + it, and keep local input state so typing feels instant. For column + resizing, throttle `onColumnResizingChange` so a drag doesn't push 60+ + state updates per second. Pacer is the v9 replacement for the hand-rolled + `DebouncedInput` setTimeout component from v8 examples. +type: composition +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - filtering + - column-layout + - react/table-state +sources: + - TanStack/table:examples/react/basic-subscribe/src/main.tsx + - TanStack/table:examples/react/with-tanstack-form/src/main.tsx + - TanStack/table:examples/react/kitchen-sink/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`, `tanstack-table/react/table-state`, and the `filtering` core skill. Read those first. + +## Why this exists + +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. + +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. + +## Setup + +```bash +pnpm add @tanstack/react-pacer +``` + +```tsx +import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' +import { useThrottledCallback } from '@tanstack/react-pacer/throttler' +``` + +## Core Pattern — `DebouncedInput` for column filters + +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. + +```tsx +import * as React from 'react' +import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' + +function DebouncedInput({ + value: initialValue, + onChange, + debounce = 300, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit, 'onChange'>) { + // Local state so the input feels instant. + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + // Debounced writer — fires once after `debounce` ms of inactivity. + const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) + + return ( + { + setValue(e.target.value) // instant UI update + debouncedOnChange(e.target.value) // debounced store write + }} + /> + ) +} + +// Usage in a column filter: +; column.setFilterValue(v)} + placeholder="Search..." +/> +``` + +Source: `examples/react/basic-subscribe/src/main.tsx`; `examples/react/with-tanstack-form/src/main.tsx`. + +## Debounce vs throttle — choose by intent + +| Pattern | Use case | Typical wait | +| ------------ | ------------------------------------------------------------------------ | ---------------- | +| **Debounce** | "Wait until they stop typing, then commit" — filter inputs, search boxes | 250–500ms | +| **Throttle** | "Fire at most every N ms" — drag-to-resize, scroll-triggered fetches | 16ms (one frame) | + +```tsx +// Throttled column resize +const throttledResize = useThrottledCallback( + (next: ColumnResizingState) => columnResizingAtom.set(next), + { wait: 16 }, +) +``` + +## Global filter with debounce + instant input + +The exact pattern used in `examples/react/basic-subscribe/src/main.tsx`: + +```tsx + + {(globalFilter) => ( + table.setGlobalFilter(value)} + placeholder="Search all columns..." + /> + )} + +``` + +The `` wrapper keeps the input controlled by the table's atom (so external resets propagate), while `DebouncedInput`'s local state keeps the visual update instant. + +## Common Mistakes + +### CRITICAL Writing filter values directly on every keystroke + +Wrong: + +```tsx + column.setFilterValue(e.target.value)} /> +// On a 10k-row table, this recomputes the filtered row model per character — janky. +``` + +Correct: + +```tsx + column.setFilterValue(v)} +/> +``` + +Debounce the store write; keep the input instant via local state. +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### HIGH Rolling your own `setTimeout` debounce + +Wrong: + +```tsx +function DebouncedInput({ value, onChange, debounce = 300 }) { + const [v, setV] = React.useState(value) + React.useEffect(() => { + const t = setTimeout(() => onChange(v), debounce) + return () => clearTimeout(t) + }, [v]) + // Works, but reinvents what useDebouncedCallback already provides with proper + // ref stability, cleanup, and trailing-edge semantics. +} +``` + +Correct: + +```tsx +function DebouncedInput({ + value: initialValue, + onChange, + debounce = 300, + ...props +}) { + const [value, setValue] = React.useState(initialValue) + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) + return ( + { + setValue(e.target.value) + debouncedOnChange(e.target.value) + }} + /> + ) +} +``` + +v8 examples shipped the hand-rolled version. v9 explicitly delegates to Pacer. +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### HIGH Debouncing the local input state instead of (or in addition to) the writer + +Wrong: + +```tsx +const debouncedSetLocal = useDebouncedCallback(setValue, { wait: 300 }) + debouncedSetLocal(e.target.value)} /> +// User sees stale characters in the input box. +``` + +Correct: + +```tsx +const debouncedOnChange = useDebouncedCallback(onChange, { wait: 300 }) + { + setValue(e.target.value) // instant local update + debouncedOnChange(e.target.value) // debounced store write only + }} +/> +``` + +Local state should always be instant. Only the expensive store write should debounce. +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### HIGH Throttling column resize at 250ms + +Wrong: + +```tsx +const throttledResize = useThrottledCallback(setResize, { wait: 250 }) +// 4 fps drag → visibly laggy. +``` + +Correct: + +```tsx +const throttledResize = useThrottledCallback(setResize, { wait: 16 }) +// ~60 fps, smooth. +``` + +Use roughly one frame (16ms). 250ms is fine for filter writes; far too long for drag interactions. +Source: maintainer guidance. + +### MEDIUM Wrapping `column.setFilterValue` directly without local input state + +Wrong: + +```tsx +const debouncedSet = useDebouncedCallback(column.setFilterValue, { wait: 300 }) + debouncedSet(e.target.value)} /> +// Input becomes uncontrolled-feeling because the render goes through the slow path. +``` + +Correct: + +```tsx + column.setFilterValue(v)} +/> +``` + +The `DebouncedInput` pattern combines local state (instant) with debounced commit (cheap). Don't skip the local state. +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### MEDIUM Using `wait: 0` and expecting debouncing + +Wrong: + +```tsx +const debouncedOnChange = useDebouncedCallback(onChange, { wait: 0 }) +// Effectively no debounce — fires synchronously. +``` + +Correct: + +```tsx +const debouncedOnChange = useDebouncedCallback(onChange, { wait: 300 }) +``` + +`wait` is meaningful. 250–500ms is the typical sweet spot for filter inputs; 16ms is the typical sweet spot for resize/scroll throttling. +Source: maintainer guidance. + +## See Also + +- `tanstack-table/react/table-state` — Subscribe boundaries for the debounced writers to feed into. +- `tanstack-table/filtering` — column filter feature API surface. +- `tanstack-table/column-layout` — column resize feature. +- `tanstack-table/react/compose-with-tanstack-query` — debounce filter input that feeds a server-side query. diff --git a/packages/react-table/skills/react/compose-with-tanstack-query/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-query/SKILL.md new file mode 100644 index 0000000000..2a83a6d934 --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-query/SKILL.md @@ -0,0 +1,467 @@ +--- +name: react/compose-with-tanstack-query +description: > + Server-side / async data flow for `@tanstack/react-table` v9 with + `@tanstack/react-query`. Canonical pattern: external pagination atom via + `useCreateAtom` + `options.atoms` (NOT `state + on*Change`), + pagination object as part of `queryKey`, `manualPagination: true`, + `placeholderData: keepPreviousData` to avoid the 0-rows flash, and + `defaultData = useMemo(() => [], [])` to keep `data` reference stable + between fetches. `rowCount` from the API response so `getPageCount()` works. +type: composition +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - react/client-to-server + - pagination + - react/table-state +sources: + - TanStack/table:examples/react/with-tanstack-query/src/main.tsx + - TanStack/table:examples/react/with-tanstack-query/src/fetchData.ts +--- + +This skill builds on `tanstack-table/state-management`, `tanstack-table/react/table-state`, and `tanstack-table/react/client-to-server`. Read those first — Query composition is `client-to-server` with a specific server. + +## Why this pattern + +A v9 React table written against TanStack Query has three load-bearing decisions: + +1. **External pagination atom**, not `state` + `onPaginationChange`. Cleaner because the table writes to the atom directly; the query's `queryKey` watches the atom; refetches happen automatically. +2. **`placeholderData: keepPreviousData`** so the previous page stays visible while the next page fetches. Without it the table collapses to 0 rows on every page change and the scroll position jumps. +3. **Stable `data` fallback** (`defaultData = useMemo(() => [], [])`). `data: dataQuery.data?.rows ?? []` in JSX produces a new array each render and busts internal memos. + +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +## Setup + +```bash +pnpm add @tanstack/react-table @tanstack/react-query @tanstack/react-store +``` + +Mount one `` at the root: + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient() + +ReactDOM.createRoot(rootElement).render( + + + + + , +) +``` + +## Core Pattern — canonical server-paginated table + +```tsx +import * as React from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useCreateAtom, useSelector } from '@tanstack/react-store' +import { + useTable, + tableFeatures, + rowPaginationFeature, + createColumnHelper, +} from '@tanstack/react-table' +import type { PaginationState } from '@tanstack/react-table' +import { fetchData } from './fetchData' // returns { rows, rowCount } +import type { Person } from './fetchData' + +const _features = tableFeatures({ rowPaginationFeature }) + +const columnHelper = createColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: (i) => i.getValue(), + }), + columnHelper.accessor('lastName', { + header: 'Last Name', + cell: (i) => i.getValue(), + }), + columnHelper.accessor('age', { header: 'Age' }), + columnHelper.accessor('visits', { header: 'Visits' }), + columnHelper.accessor('status', { header: 'Status' }), + columnHelper.accessor('progress', { header: 'Profile Progress' }), +]) + +function App() { + // 1) Pagination atom — stable identity via useCreateAtom. + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + // 2) Subscribe so the query refetches on pagination changes. + const pagination = useSelector(paginationAtom, (s) => s) + + // 3) Query keyed on the pagination object — refetch on every page/size change. + const dataQuery = useQuery({ + queryKey: ['data', pagination], + queryFn: () => fetchData(pagination), + placeholderData: keepPreviousData, // 4) avoid 0-rows flash + }) + + // 5) Stable fallback — fresh `[]` in JSX would bust internal memos. + const defaultData = React.useMemo(() => [], []) + + // 6) Manual pagination + rowCount; no paginatedRowModel. + const table = useTable( + { + _features, + _rowModels: {}, + columns, + data: dataQuery.data?.rows ?? defaultData, + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, // table writes here directly + manualPagination: true, + }, + (state) => state, + ) + + return ( + <> + + {/* table.FlexRender header={h} */} + {/* table.FlexRender cell={c} */} +
+
+ + + + + + Page{' '} + + {pagination.pageIndex + 1} of {table.getPageCount()} + + + + {dataQuery.isFetching ? 'Loading...' : null} +
+ + ) +} +``` + +Source: `examples/react/with-tanstack-query/src/main.tsx` (this is the canonical example, near-verbatim). + +## Adding sort + filter + +The same pattern extends to multiple slices. Key the query on each, set the matching `manual*` flag, drop the matching `_rowModels` factory. + +```tsx +const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = useCreateAtom([]) +const columnFiltersAtom = useCreateAtom([]) + +const pagination = useSelector(paginationAtom) +const sorting = useSelector(sortingAtom) +const columnFilters = useSelector(columnFiltersAtom) + +const dataQuery = useQuery({ + queryKey: ['data', { pagination, sorting, columnFilters }], + queryFn: () => fetchData({ pagination, sorting, columnFilters }), + placeholderData: keepPreviousData, +}) + +const table = useTable({ + _features: tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + }), + _rowModels: {}, // server owns sort/filter/paginate + columns, + data: dataQuery.data?.rows ?? defaultData, + rowCount: dataQuery.data?.rowCount, + atoms: { + pagination: paginationAtom, + sorting: sortingAtom, + columnFilters: columnFiltersAtom, + }, + manualSorting: true, + manualFiltering: true, + manualPagination: true, +}) +``` + +## Mutations and invalidation + +TanStack Table is a downstream consumer — it has no way to know the server data changed. Call `queryClient.invalidateQueries` after mutations: + +```tsx +const queryClient = useQueryClient() +const addPerson = useMutation({ + mutationFn: createPerson, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }), +}) +``` + +## Common Mistakes + +### CRITICAL Forgetting `manualPagination` / `manualSorting` / `manualFiltering` + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: query.data?.rows ?? [], + // missing manualPagination +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, // drop paginatedRowModel + columns, + data: query.data?.rows ?? defaultData, + rowCount: query.data?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Without `manualPagination: true`, the table re-paginates the server-already-paginated 10-row "dataset" — `getPageCount()` returns 1, and the pager locks at "Page 1 of 1". +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### CRITICAL Missing `rowCount` + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: query.data?.rows ?? defaultData, + atoms: { pagination: paginationAtom }, + manualPagination: true, + // missing rowCount +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: query.data?.rows ?? defaultData, + rowCount: query.data?.rowCount, // ← required for accurate pager + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +`getPageCount()` falls back to `Math.ceil(data.length / pageSize)` — which equals 1 when the server returned one page. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### CRITICAL `queryKey` doesn't include the pagination state + +Wrong: + +```tsx +useQuery({ + queryKey: ['data'], // never changes + queryFn: () => fetchData(pagination), +}) +``` + +Correct: + +```tsx +useQuery({ + queryKey: ['data', pagination], // refetch on pagination change + queryFn: () => fetchData(pagination), + placeholderData: keepPreviousData, +}) +``` + +Query has no way to know its inputs changed unless they're in `queryKey`. Pager button clicks update the atom but the query never refetches. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### HIGH Skipping `placeholderData: keepPreviousData` + +Wrong: + +```tsx +useQuery({ + queryKey: ['data', pagination], + queryFn: () => fetchData(pagination), +}) +// Between pages: table renders 0 rows, container collapses, scroll position jumps. +``` + +Correct: + +```tsx +useQuery({ + queryKey: ['data', pagination], + queryFn: () => fetchData(pagination), + placeholderData: keepPreviousData, // previous page stays visible while fetching +}) +``` + +The previous page renders during the fetch — no flash, no jump. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### HIGH Recreating `data: query.data?.rows ?? []` in JSX + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: query.data?.rows ?? [], // new identity every render + // ... +}) +``` + +Correct: + +```tsx +const defaultData = React.useMemo(() => [], []) +// or: const EMPTY: Person[] = [] at module scope + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: query.data?.rows ?? defaultData, + // ... +}) +``` + +`?? []` creates a fresh array reference each render, busting internal memos that depend on `data` identity. +Source: `examples/react/with-tanstack-query/src/main.tsx` (uses `useMemo`). + +### HIGH Mixing `state.pagination` + `onPaginationChange` AND `atoms.pagination` + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { pagination }, // silently ignored + onPaginationChange: setPagination, // silently ignored + atoms: { pagination: paginationAtom }, // wins + manualPagination: true, +}) +``` + +Correct: + +```tsx +// Pick one. The atom pattern is canonical for Query. +useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Precedence is `atoms` > `state` > internal. The `state` plumbing is dead. +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### HIGH Forgetting `invalidateQueries` after mutations + +Wrong: + +```tsx +const addPerson = useMutation({ + mutationFn: createPerson, + // missing onSuccess invalidation +}) +// Table never sees the new row. +``` + +Correct: + +```tsx +const queryClient = useQueryClient() +const addPerson = useMutation({ + mutationFn: createPerson, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }), +}) +``` + +The table is downstream of Query. Mutations must invalidate the relevant query keys. +Source: docs/framework/react/react-query. + +### MEDIUM Leaving `paginatedRowModel` registered when the server paginates + +Wrong: + +```tsx +_rowModels: { + paginatedRowModel: createPaginatedRowModel() +} // ships unused code +``` + +Correct: + +```tsx +_rowModels: { +} // server paginates; drop the factory +``` + +Bundle waste plus a foot-gun if `manualPagination` is ever flipped off. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +## See Also + +- `tanstack-table/react/client-to-server` — the underlying manual-mode mechanics. +- `tanstack-table/react/compose-with-tanstack-store` — owning state slices via atoms. +- `tanstack-table/react/compose-with-tanstack-virtual` — infinite scroll = Virtual + `useInfiniteQuery`. +- `tanstack-table/react/compose-with-tanstack-pacer` — debounce filter writes that feed the query. diff --git a/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md new file mode 100644 index 0000000000..66452a6948 --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-store/SKILL.md @@ -0,0 +1,347 @@ +--- +name: react/compose-with-tanstack-store +description: > + `@tanstack/react-table` v9 is built on TanStack Store. Each state slice + (sorting, pagination, rowSelection, columnFilters, …) is a separate atom. + The table exposes three READ surfaces — `table.atoms.` (per-slice + readonly), `table.store` (flat readonly view), `table.state` (selector + output from `useTable`) — and two WRITE paths — internal + `table.baseAtoms.` OR YOUR `options.atoms[slice]` if you opt to own + the slice. Use `useCreateAtom` from `@tanstack/react-store` for stable + identity, `useSelector` for fine-grained reads, and pass the atom in + `options.atoms` so the table writes through it directly — no `on*Change` + handler required. +type: composition +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - react/table-state + - state-management +sources: + - TanStack/table:docs/framework/react/guide/table-state.md + - TanStack/table:examples/react/basic-external-atoms/src/main.tsx + - TanStack/table:examples/react/basic-subscribe/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — `state-management` explains the atom model conceptually; `table-state` covers the basic React adapter. This skill goes deeper into composition with `@tanstack/react-store`. + +## What "compose with TanStack Store" means in practice + +A v9 React table is already a Store consumer — every slice (`sorting`, `pagination`, `rowSelection`, `columnFilters`, `columnVisibility`, …) is an atom managed by Store. Composing with Store means **opting to own one or more of those atoms yourself** so you can: + +- Share a slice across multiple components without prop-drilling the table instance. +- Share a slice across multiple **tables** (e.g. a global filter atom). +- Persist a slice (subscribe to localStorage outside the table). +- Integrate with other atom-based code (atoms in your data layer). +- Skip the `on*Change` callback dance — the table writes through your atom directly. + +## Setup + +Install `@tanstack/react-store` if you don't already have it (it's a peer of `@tanstack/react-table`): + +```bash +pnpm add @tanstack/react-store +``` + +Three APIs do the work: + +```tsx +import { useCreateAtom, useSelector } from '@tanstack/react-store' +``` + +- `useCreateAtom(initial)` — create an atom with stable identity inside a component (React-safe replacement for `useRef(createAtom(...))`). +- `useSelector(atomOrStore, selector?)` — subscribe a component to an atom or store. +- The table's `options.atoms` option — hand ownership of named slices to your atoms. + +## Core Patterns + +### 1. Own a slice externally + +```tsx +import { useCreateAtom, useSelector } from '@tanstack/react-store' +import { + useTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + createSortedRowModel, + createPaginatedRowModel, + sortFns, +} from '@tanstack/react-table' +import type { PaginationState, SortingState } from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + +function MyTable({ columns, data }) { + const sortingAtom = useCreateAtom([]) + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + + // Fine-grained reads — each component re-renders independently. + const sorting = useSelector(sortingAtom) + const pagination = useSelector(paginationAtom) + + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + atoms: { sorting: sortingAtom, pagination: paginationAtom }, + // NOTE: no onSortingChange / onPaginationChange — table writes to atoms directly. + }) + + // You own the atom → you own its reset. + const resetMyState = () => { + sortingAtom.set([]) + paginationAtom.set({ pageIndex: 0, pageSize: 10 }) + } +} +``` + +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### 2. Read a table-owned atom from a sibling component + +You don't have to own a slice to read it surgically — `table.atoms.` works with `useSelector` too. + +```tsx +function SelectedCount({ table }) { + // Re-renders ONLY when rowSelection changes. + const selection = useSelector(table.atoms.rowSelection) + return {Object.keys(selection).length} selected +} +``` + +### 3. Persist a slice to localStorage + +Because you own the atom, you can do anything you want outside the table render path: + +```tsx +const visibilityAtom = useCreateAtom>(() => + JSON.parse(localStorage.getItem('cv') ?? '{}'), +) + +React.useEffect(() => { + return visibilityAtom.subscribe(() => { + localStorage.setItem('cv', JSON.stringify(visibilityAtom.get())) + }) +}, [visibilityAtom]) + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { columnVisibility: visibilityAtom }, +}) +``` + +### 4. Share one slice across multiple tables + +Create the atom outside any component (or in a parent and pass down): + +```tsx +import { createAtom } from '@tanstack/store' + +const globalFilterAtom = createAtom('') + +function UsersTable() { + return +} +function OrdersTable() { + return
+} + +function Table({ data, filter }) { + const table = useTable({ + _features, + _rowModels: { + /* … */ + }, + columns, + data, + atoms: { globalFilter: filter }, + }) +} +``` + +## Read surfaces and write paths — cheat sheet + +| Surface | Reactive | Use case | +| ----------------------------------- | --------------------------- | --------------------------------------------------------------- | +| `table.state` | ✓ (via `useTable` selector) | Default top-level reads in the component that called `useTable` | +| `` / `` | ✓ | Surgical re-render boundaries inside the tree | +| `useSelector(table.atoms.X)` | ✓ | Narrowest possible subscription to one slice | +| `table.atoms.X.get()` | ✗ current-value read | Inside event handlers / effects | +| `table.store.state` | ✗ current-value read | Debugging / one-shot reads | + +| Write path | Owner | Effect | +| ------------------------------- | ----------------- | ---------------------------------------------------------------------------------- | +| Internal `table.baseAtoms.X` | The table | Used when you provide neither `options.atoms.X` nor `options.state.X` | +| `options.atoms.X` (yours) | You | Table writes through; you can `.set()` from anywhere | +| `options.state.X` + `onXChange` | You (React state) | Classic controlled state. Cannot coexist with `options.atoms.X` for the same slice | + +Precedence: `options.atoms[key]` > `options.state[key]` > internal. + +## Common Mistakes + +### CRITICAL Creating the atom with `createAtom(...)` inside the component body + +Wrong: + +```tsx +function MyTable() { + const sortingAtom = createAtom([]) // new atom every render + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +Correct: + +```tsx +function MyTable() { + const sortingAtom = useCreateAtom([]) // stable across renders + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +A fresh atom every render rebinds the table to a new atom whose state is the initial value — the slice resets on every render. +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### HIGH Passing the same slice via `state` AND `atoms` + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + state: { sorting: localSorting }, // silently ignored + onSortingChange: setLocalSorting, // silently ignored + atoms: { sorting: sortingAtom }, // wins +}) +``` + +Correct: + +```tsx +// Pick exactly one ownership mechanism per slice. +useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, +}) +``` + +`options.atoms[key]` > `options.state[key]`. Confusing to debug because the `state` plumbing looks live but does nothing. +Source: `docs/framework/react/guide/table-state.md`. + +### HIGH Pairing external atoms with `on*Change` handlers + +Wrong: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + onSortingChange: (next) => sortingAtom.set(next), // redundant + confusing +}) +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, +}) +``` + +The table writes directly to the atom you provided via the atom's `set()`. Adding an `on*Change` does nothing useful and confuses readers. +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### HIGH Expecting `table.reset()` to clear externally-owned atoms + +Wrong: + +```tsx + +// External atoms remain at their current values — table can't reset what it doesn't own. +``` + +Correct: + +```tsx + +``` + +The table only resets slices it manages internally. Your atoms are yours to reset. +Source: `docs/framework/react/guide/table-state.md`. + +### MEDIUM Reading via `table.state.sorting` in deeply-nested components + +Wrong: + +```tsx +function SortIndicator({ table }) { + const { sorting } = table.state // re-renders when ANY state slice changes + return {sorting.length} cols +} +``` + +Correct: + +```tsx +import { useSelector } from '@tanstack/react-store' + +function SortIndicator({ table }) { + const sorting = useSelector(table.atoms.sorting) // re-renders only on sorting changes + return {sorting.length} cols +} +``` + +`useSelector(table.atoms.X)` is the narrowest subscription surface — skips constructing a state snapshot. +Source: `docs/framework/react/guide/table-state.md`. + +## See Also + +- `tanstack-table/react/table-state` — the base API, includes `` and `` shapes. +- `tanstack-table/react/compose-with-tanstack-query` — Query queryKey keyed on a Store atom is the canonical pattern. +- `tanstack-table/react/production-readiness` — narrowing selectors and per-slice subscriptions. +- `tanstack-table/react/client-to-server` — atoms make manual-mode wiring trivial. diff --git a/packages/react-table/skills/react/compose-with-tanstack-virtual/SKILL.md b/packages/react-table/skills/react/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..547ff50cdf --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,388 @@ +--- +name: react/compose-with-tanstack-virtual +description: > + `@tanstack/react-table` v9 does NOT include virtualization — pair with + `@tanstack/react-virtual`. Standard row-virtualization pattern: get the row + array from `table.getRowModel().rows`, feed `rows.length` to + `useVirtualizer({ count, estimateSize, getScrollElement, ... })` in the + DEEPEST possible component (a `TableBody`, NOT `App`), iterate + `rowVirtualizer.getVirtualItems()` instead of `rows.map`, absolute-position + each row with `transform: translateY(virtualRow.start)px`, and render + `` as a CSS grid with a fixed total height. Column virtualization + uses `horizontal: true` plus padding-left/right placeholder cells. An + experimental ref-mutation variant skips React reconciliation for ~10% + extra perf but the standard pattern is the default. +type: composition +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - react/table-state + - row-expanding +sources: + - TanStack/table:docs/guide/virtualization.md + - TanStack/table:examples/react/virtualized-rows/src/main.tsx + - TanStack/table:examples/react/virtualized-columns/src/main.tsx + - TanStack/table:examples/react/virtualized-infinite-scrolling/src/main.tsx + - TanStack/table:examples/react/virtualized-rows-experimental/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — the table's row model is what feeds the virtualizer. + +## Why this skill exists + +TanStack Table renders every row in its `getRowModel().rows` array. For 50 rows that's fine; for 50k or 500k it crashes the browser. `@tanstack/react-virtual` only renders the rows that fit inside the scroll container, recycling DOM nodes as the user scrolls. + +## Setup + +```bash +pnpm add @tanstack/react-table @tanstack/react-virtual +``` + +The two pieces: + +```tsx +import { useTable } from '@tanstack/react-table' +import { useVirtualizer } from '@tanstack/react-virtual' +``` + +## Core Pattern — row virtualization (standard) + +The single most important rule: **keep `useVirtualizer` in the deepest component possible.** Any state change in the component that owns the virtualizer re-runs it, blowing away scroll position and measurement cache. + +```tsx +import * as React from 'react' +import { + useTable, + tableFeatures, + columnSizingFeature, + rowSortingFeature, + createSortedRowModel, + sortFns, + createColumnHelper, +} from '@tanstack/react-table' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { ReactTable, Row } from '@tanstack/react-table' +import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' + +const features = { columnSizingFeature, rowSortingFeature } +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('id', { header: 'ID', size: 60 }), + columnHelper.accessor('firstName', { cell: (info) => info.getValue() }), + columnHelper.accessor('lastName', { + id: 'lastName', + cell: (info) => info.getValue(), + }), +]) + +function App() { + // 1) Scroll container ref + table at App level. + const tableContainerRef = React.useRef(null) + const [data] = React.useState(() => makeData(200_000)) + + const table = useTable({ + _features: features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }) + + return ( +
+ {/* 2) display: grid — required for absolute positioning + dynamic heights */} +
+ + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + {/* 3) Virtualizer lives inside TableBody, NOT here. */} + +
+
+ +
+
+ + ) +} + +interface TableBodyProps { + table: ReactTable + tableContainerRef: React.RefObject +} + +function TableBody({ table, tableContainerRef }: TableBodyProps) { + const { rows } = table.getRowModel() + + // 4) useVirtualizer in the deepest body component. + const rowVirtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 33, + getScrollElement: () => tableContainerRef.current, + // 5) Skip dynamic measurement on Firefox — it measures border height wrong. + measureElement: + typeof window !== 'undefined' && + navigator.userAgent.indexOf('Firefox') === -1 + ? (el) => el.getBoundingClientRect().height + : undefined, + overscan: 5, + }) + + return ( + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] + return ( + rowVirtualizer.measureElement(node)} + style={{ + display: 'flex', + position: 'absolute', + transform: `translateY(${virtualRow.start}px)`, + width: '100%', + }} + > + {row.getAllCells().map((cell) => ( + + + + ))} + + ) + })} + + ) +} +``` + +Source: `examples/react/virtualized-rows/src/main.tsx`. + +## Column virtualization and infinite scroll + +Column virtualization (`horizontal: true` + placeholder padding cells) and infinite scroll via `useInfiniteQuery` + `manualSorting: true` — see [column-virtualization-and-infinite-scroll.md](references/column-virtualization-and-infinite-scroll.md). That file also covers the HIGH-priority `manualSorting` failure mode and the column-virt padding-cells failure mode. + +## Experimental ref-mutation variant + +`examples/react/virtualized-rows-experimental/` and `virtualized-columns-experimental/` mutate row `style` directly via the virtualizer's `onChange` callback, skipping React reconciliation on scroll. Roughly **10% rendering perf gain** in maintainer benchmarks. The pattern is valid but the standard pattern above is the documented default; reach for the experimental version only when measured perf demands it. + +## Common Mistakes + +### CRITICAL `useVirtualizer` in the same component as `useTable` + +Wrong: + +```tsx +function App() { + const rowVirtualizer = useVirtualizer({ + /* … */ + }) // virtualizer too high + const table = useTable(opts) + return +} +``` + +Correct: + +```tsx +function App() { + const tableContainerRef = React.useRef(null) + const table = useTable(opts) + return ( +
+ +
+ ) +} +function TableBody({ table, tableContainerRef }) { + const rowVirtualizer = useVirtualizer({ + /* … */ + }) // virtualizer deepest + /* … */ +} +``` + +Any state change in the component owning the virtualizer re-runs it — losing scroll position and remeasuring every row. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +### CRITICAL Rendering `rows.map` directly on a large dataset + +Wrong: + +```tsx + + {rows.map((row) => ( + ... + ))} + +// 200k DOM rows — browser crashes. +``` + +Correct: + +```tsx + + {rowVirtualizer.getVirtualItems().map((vr) => { + const row = rows[vr.index] + return ( + + {/* … */} + + ) + })} + +``` + +Use `getVirtualItems()` so only the visible window renders. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +### CRITICAL Missing `display: grid` + absolute positioning + +Wrong: + +```tsx + + {rowVirtualizer.getVirtualItems().map((vr) => ( + {/* no transform, no absolute */} + ))} + +``` + +Correct: + +```tsx + + {rowVirtualizer.getVirtualItems().map((vr) => ( + + {/* … */} + + ))} + +``` + +The semantic `` layout collides with absolute positioning. CSS grid lets the rows position themselves freely while keeping semantic tags. Without `transform: translateY(start)px` all rows render at `top: 0`. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +### HIGH Using `measureElement` on Firefox + +Wrong: + +```tsx +const rowVirtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 33, + getScrollElement: () => ref.current, + measureElement: (el) => el.getBoundingClientRect().height, // jitters in Firefox +}) +``` + +Correct: + +```tsx +const rowVirtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 33, + getScrollElement: () => ref.current, + measureElement: + typeof window !== 'undefined' && + navigator.userAgent.indexOf('Firefox') === -1 + ? (el) => el.getBoundingClientRect().height + : undefined, +}) +``` + +Firefox returns inconsistent row heights for table rows, causing flicker. Guard the option. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +### HIGH Storing the ref instead of using the callback-ref form + +Wrong: + +```tsx +const rowRef = React.useRef(null) + +// rowVirtualizer can't remeasure when row content changes height +``` + +Correct: + +```tsx + rowVirtualizer.measureElement(node)} /*…*/ /> +``` + +The pattern is a ref callback that calls `measureElement(node)` — passing a stored ref means the virtualizer never gets a chance to remeasure. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +For HIGH-priority failure modes specific to column virtualization (missing padding placeholders) and infinite scroll (`manualSorting` requirement), see [column-virtualization-and-infinite-scroll.md](references/column-virtualization-and-infinite-scroll.md). + +## See Also + +- `tanstack-table/react/production-readiness` — keep virtualizers in deepest components. +- `tanstack-table/react/compose-with-tanstack-query` — `useInfiniteQuery` integration. +- `tanstack-table/react/table-state` — the row model API the virtualizer reads from. + +## References + +- [column-virtualization-and-infinite-scroll.md](references/column-virtualization-and-infinite-scroll.md) — `horizontal: true` column virtualization with placeholder padding cells, `useInfiniteQuery` + `manualSorting: true` integration, plus HIGH-priority failure modes for both diff --git a/packages/react-table/skills/react/compose-with-tanstack-virtual/references/column-virtualization-and-infinite-scroll.md b/packages/react-table/skills/react/compose-with-tanstack-virtual/references/column-virtualization-and-infinite-scroll.md new file mode 100644 index 0000000000..a957867541 --- /dev/null +++ b/packages/react-table/skills/react/compose-with-tanstack-virtual/references/column-virtualization-and-infinite-scroll.md @@ -0,0 +1,136 @@ +# Column virtualization and infinite scroll — React + TanStack Virtual + +Extended composition patterns extracted from `SKILL.md`. The primary row-virtualization pattern and the experimental ref-mutation variant remain inline in the SKILL; this file covers column virtualization and the `useInfiniteQuery` integration. + +## Column virtualization + +Same shape as row virtualization with `horizontal: true` and left/right placeholder cells so unrendered columns still take up scroll width: + +```tsx +const columnVirtualizer = useVirtualizer({ + count: table.getVisibleLeafColumns().length, + estimateSize: (i) => table.getVisibleLeafColumns()[i].getSize(), + getScrollElement: () => tableContainerRef.current, + horizontal: true, + overscan: 3, +}) + +const virtualColumns = columnVirtualizer.getVirtualItems() +const virtualPaddingLeft = virtualColumns[0]?.start ?? 0 +const virtualPaddingRight = + columnVirtualizer.getTotalSize() - + (virtualColumns[virtualColumns.length - 1]?.end ?? 0) + +// In each row: + + {virtualPaddingLeft > 0 ? + })} + {virtualPaddingRight > 0 ? +``` + +Source: `examples/react/virtualized-columns/src/main.tsx`. + +## Infinite scroll — Virtual + `useInfiniteQuery` + +```tsx +const dataQuery = useInfiniteQuery({ + queryKey: ['people', sorting], + queryFn: ({ pageParam = 0 }) => fetchPage(pageParam, sorting), + getNextPageParam: (lastPage, allPages) => allPages.length, + placeholderData: keepPreviousData, +}) + +const flatRows = React.useMemo( + () => dataQuery.data?.pages.flatMap((p) => p.rows) ?? [], + [dataQuery.data], +) + +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: {}, // server sorts each page + columns, + data: flatRows, + manualSorting: true, + // ... +}) + +// Inside TableBody, scroll handler: +React.useEffect(() => { + const el = tableContainerRef.current + if (!el) return + const onScroll = () => { + if ( + el.scrollHeight - el.scrollTop - el.clientHeight < 500 && + !dataQuery.isFetching + ) { + dataQuery.fetchNextPage() + } + } + el.addEventListener('scroll', onScroll) + return () => el.removeEventListener('scroll', onScroll) +}, [dataQuery]) +``` + +Source: `examples/react/virtualized-infinite-scrolling/src/main.tsx`. + +## Common Mistakes (column virt + infinite scroll) + +### HIGH For column virtualization: missing padding placeholder cells + +Wrong: + +```tsx + + {virtualColumns.map((vc) => ( + + ))} + +// Unrendered columns aren't taking up scroll space → visible columns slide left. +``` + +Correct: + +```tsx + + {virtualPaddingLeft > 0 ? + ))} + {virtualPaddingRight > 0 ? ( + +``` + +Source: `examples/react/virtualized-columns/src/main.tsx`. + +### HIGH Infinite scroll without `manualSorting` + +Wrong: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + data: flatRows, +}) +// Each new page arrives → table re-sorts everything → row order scrambles between pages. +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: {}, // server sorts each page + data: flatRows, + manualSorting: true, +}) +``` + +With `useInfiniteQuery`, you must fire a fresh query on sort changes (key your `queryKey` on `sorting`) and set `manualSorting: true` so the table doesn't re-sort accumulated pages. +Source: `examples/react/virtualized-infinite-scrolling/src/main.tsx`. diff --git a/packages/react-table/skills/react/getting-started/SKILL.md b/packages/react-table/skills/react/getting-started/SKILL.md new file mode 100644 index 0000000000..3827f4c592 --- /dev/null +++ b/packages/react-table/skills/react/getting-started/SKILL.md @@ -0,0 +1,388 @@ +--- +name: react/getting-started +description: > + End-to-end first-table journey for `@tanstack/react-table` v9. Install the + React adapter, declare `_features` via `tableFeatures()`, declare `_rowModels` + factories with their *Fns parameters (`createSortedRowModel(sortFns)` etc.), + create a column helper with both `TFeatures` and `TData` generics, instantiate + `useTable`, and render with ``. New users land here, not on + `useLegacyTable`. +type: lifecycle +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - setup + - column-definitions + - state-management + - react/table-state +sources: + - TanStack/table:docs/installation.md + - TanStack/table:docs/framework/react/react-table.md + - TanStack/table:examples/react/basic-use-table/src/main.tsx + - TanStack/table:examples/react/basic-use-app-table/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — `_features` + `_rowModels` come from the core state-management concept, and `table-state` covers how reactivity flows in React. + +## Install + +```bash +pnpm add @tanstack/react-table +# or +npm install @tanstack/react-table +``` + +`@tanstack/react-table` v9 requires React 18+ and TypeScript 5.4+ if you use TS. + +## Minimum-viable v9 table + +Three things are non-negotiable, even for the simplest possible table: + +1. `_features: tableFeatures({...})` — required even if empty (`tableFeatures({})`). +2. `_rowModels: {...}` — required even if empty (`_rowModels: {}`). The **core** row model is automatic; you only register sorted/filtered/paginated/grouped/etc. when you use them. +3. `createColumnHelper()` — generic order is `` in v9 (changed from v8). + +```tsx +import * as React from 'react' +import { useTable, tableFeatures } from '@tanstack/react-table' +import type { ColumnDef } from '@tanstack/react-table' + +type Person = { + firstName: string + lastName: string + age: number + visits: number + status: string + progress: number +} + +// 1. _features — required option, even if empty. +const _features = tableFeatures({}) + +// 2. Columns — defined at module scope for stable identity. +const columns: Array> = [ + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + }, + { accessorKey: 'lastName', header: 'Last Name' }, + { accessorKey: 'age', header: 'Age' }, + { accessorKey: 'visits', header: 'Visits' }, + { accessorKey: 'status', header: 'Status' }, + { accessorKey: 'progress', header: 'Profile Progress' }, +] + +function App({ initialData }: { initialData: Person[] }) { + const [data] = React.useState(() => initialData) + + // 3. Build the table — `_rowModels: {}` is required. + const table = useTable( + { + _features, + _rowModels: {}, + columns, + data, + }, + (state) => state, // default selector + ) + + return ( +
: null} + {virtualColumns.map((vc) => { + const cell = row.getVisibleCells()[vc.index] + return : null} +
...
: null} + {virtualColumns.map((vc) => ( + ... + ) : null} +
+ + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + +
+ {h.isPlaceholder ? null : } +
+ +
+ ) +} +``` + +Source: `examples/react/basic-use-table/src/main.tsx`. + +## Adding sorting + +Register the feature in `_features`, the factory in `_rowModels`, and wire a click handler: + +```tsx +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + createColumnHelper, +} from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +function App({ data }: { data: Person[] }) { + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }) + + return ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + {/* tbody same as above */} +
+ + {{ asc: ' 🔼', desc: ' 🔽' }[ + h.column.getIsSorted() as string + ] ?? null} +
+ ) +} +``` + +`createSortedRowModel` REQUIRES `sortFns` (and the equivalents for `createFilteredRowModel(filterFns)`, `createGroupedRowModel(aggregationFns)`). The factory parameter is what makes the registry tree-shakeable. + +## Layering features + +Adding pagination and filtering is purely additive — register the feature + factory, and call the built-in APIs: + +```tsx +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, +}) + +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) + +// Built-in APIs you should reach for, NOT reimplement: +table.setSorting([{ id: 'age', desc: true }]) +table.nextPage() +table.setColumnFilters([{ id: 'firstName', value: 'tan' }]) +column.toggleSorting() +row.toggleSelected() +``` + +Source: `docs/framework/react/react-table.md`; `examples/react/basic-use-table/src/main.tsx`. + +## Optional: `createTableHook` for shared config + +If you ship the same `_features` / `_rowModels` / cell components across many tables, package them once: + +```tsx +import { createTableHook } from '@tanstack/react-table' + +const { useAppTable, createAppColumnHelper } = createTableHook({ + _features: {}, + _rowModels: {}, + debugTable: true, +}) + +const columnHelper = createAppColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { cell: (info) => info.getValue() }), +]) + +function App({ data }) { + const table = useAppTable({ columns, data }) + // ... same FlexRender markup +} +``` + +Source: `examples/react/basic-use-app-table/src/main.tsx`. + +## Common Mistakes + +### CRITICAL Forgetting `_features: tableFeatures({})` + +Wrong: + +```tsx +const table = useTable({ + _rowModels: {}, + columns, + data, +}) +// TS: Property '_features' is missing in type +``` + +Correct: + +```tsx +const _features = tableFeatures({}) +const table = useTable({ _features, _rowModels: {}, columns, data }) +``` + +The option is required even for a "no features" table — pass `tableFeatures({})` or `stockFeatures` if you want v8-style "everything on". +Source: `examples/react/basic-use-table/src/main.tsx`. + +### CRITICAL Reimplementing what built-in APIs already provide + +Wrong: + +```tsx +// Reimplements sorting state manually instead of using the API. +const [sorting, setSorting] = useState([]) +const sortedData = useMemo( + () => [...data].sort((a, b) => /* custom */), + [data, sorting], +) +// uses sortedData directly, bypassing the table +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +// Then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() +``` + +Maintainer flags this as the #1 tell that "an AI wrote this." The built-ins handle reset semantics, multi-sort, internal invariants. +Source: maintainer interview (Phase 4). + +### CRITICAL API "missing" because the feature was not registered in `_features` + +Wrong: + +```tsx +const _features = tableFeatures({}) // empty +const table = useTable({ _features, _rowModels: {}, columns, data }) +table.setSorting([{ id: 'age', desc: true }]) // TS error — does not exist on this table type +``` + +Correct: + +```tsx +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +table.setSorting([{ id: 'age', desc: true }]) // ✓ +``` + +In v9, `_features` is a tree-shakeable registry. If a feature isn't listed, TypeScript hides its APIs and the runtime atom is never created — the feature isn't broken, it's just not on. +Source: maintainer interview (Phase 4); `docs/framework/react/react-table.md`. + +### HIGH Wrong generic order on `createColumnHelper` + +Wrong: + +```tsx +const columnHelper = createColumnHelper() // v8 arity +``` + +Correct: + +```tsx +const _features = tableFeatures({ + /* … */ +}) +const columnHelper = createColumnHelper() // v9: +``` + +v9 added `TFeatures` as the first generic across `Column`, `Row`, `ColumnDef`, `ColumnMeta`, etc. Use `typeof _features` so the same feature set drives types and runtime. +Source: `docs/framework/react/react-table.md`. + +### HIGH Defining `_features` / `columns` / `data` inside the render body + +Wrong: + +```tsx +function MyTable({ rows }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const columns = [/* … */] // new every render + return +} +``` + +Correct: + +```tsx +// Module scope = stable identity. +const _features = tableFeatures({ rowSortingFeature }) +const columns: ColumnDef[] = [ + /* … */ +] +``` + +Internal memoization keys off identity. A new object every render forces full recomputation and can cause subtle re-render issues. +Source: `examples/react/basic-use-table/src/main.tsx`; FAQ #1. + +### HIGH Reaching for `useLegacyTable` for a new project + +Wrong: + +```tsx +import { useLegacyTable, getCoreRowModel } from '@tanstack/react-table/legacy' +const table = useLegacyTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), +}) +``` + +Correct: + +```tsx +import { useTable, tableFeatures } from '@tanstack/react-table' +const _features = tableFeatures({}) +const table = useTable({ _features, _rowModels: {}, columns, data }) +``` + +`useLegacyTable` is a migration shim for incrementally upgrading v8 codebases. It bundles every feature, lacks `table.Subscribe`, and is deprecated in v9 / scheduled for removal in v10. New code uses `useTable`. +Source: `docs/framework/react/guide/use-legacy-table.md`. + +## See Also + +- `tanstack-table/react/table-state` — selectors, ``, external atoms, `createTableHook`. +- `tanstack-table/react/migrate-v8-to-v9` — for codebases upgrading from `useReactTable`. +- `tanstack-table/react/production-readiness` — once it works, optimize for shipping. +- `tanstack-table/react/client-to-server` — when you outgrow client-side row processing. diff --git a/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..2b400dcffa --- /dev/null +++ b/packages/react-table/skills/react/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,488 @@ +--- +name: react/migrate-v8-to-v9 +description: > + Mechanical breaking-change migration from `@tanstack/react-table` v8 to v9. + Every v8-shaped option, type, or method an agent will reproduce from muscle + memory has a v9 equivalent enumerated below: `useReactTable` → `useTable`, + root `get*RowModel` options → `_rowModels` with factory + *Fns parameter, + `createColumnHelper` → `createColumnHelper`, + `table.getState()` → `table.store.state` / `table.state` / `table.atoms.X.get()`, + `sortingFn` → `sortFn`, `enablePinning` → split, `_`-prefixed APIs unprefixed, + `ColumnSizing` split into `columnSizingFeature` + `columnResizingFeature`. + For incremental migration, `useLegacyTable` from `@tanstack/react-table/legacy` + accepts the v8 API on the v9 engine — deprecated, larger bundle, no + `table.Subscribe`. Long-term you migrate every table off it. +type: lifecycle +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - TanStack/table:docs/framework/react/guide/migrating.md + - TanStack/table:docs/framework/react/guide/use-legacy-table.md + - TanStack/table:packages/react-table/src/legacy.ts + - TanStack/table:examples/react/basic-use-legacy-table/src/main.tsx + - TanStack/table:examples/react/basic-use-table/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — `state-management` explains _why_ v9 split out `_features` / `_rowModels`, and `table-state` shows the new reactivity model. + +## Two migration paths + +| Path | Use when | Cost | +| ---------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| **Direct: `useReactTable` → `useTable`** | New code, small/medium codebase, or you want React Compiler + Subscribe perf | Per-table refactor; no `getState()` reads in render | +| **Bridge: `useLegacyTable`** | Large v8 codebase you can't refactor in one PR | Bigger bundle (ships every feature), no `table.Subscribe`, deprecated — pay back later | + +The bridge is React-only. Angular projects must migrate directly. + +## Setup + +Imports change for v9. The legacy shim lives under `/legacy`. + +```tsx +// v9 (new code) +import { + useTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnSizingFeature, + columnResizingFeature, + createColumnHelper, + createSortedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + sortFns, + filterFns, +} from '@tanstack/react-table' + +// Legacy shim (migration aid only) +import { + useLegacyTable, + legacyCreateColumnHelper, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, +} from '@tanstack/react-table/legacy' +``` + +## Direct migration: v8 → v9 line-by-line + +### Hooks and helpers + +```tsx +// v8 +import { + useReactTable, + createColumnHelper, + getCoreRowModel, + getSortedRowModel, +} from '@tanstack/react-table' +const columnHelper = createColumnHelper() +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), +}) + +// v9 +import { + useTable, + tableFeatures, + rowSortingFeature, + createColumnHelper, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, // factory takes *Fns + columns, + data, +}) +``` + +### State reads + +```tsx +// v8 +const state = table.getState() +const cells = row._getAllCellsByColumnId() + +// v9 +const all = table.store.state // flat snapshot +const sorting = table.atoms.sorting.get() // per-slice atom +const cells = row.getAllCellsByColumnId() // no underscore — APIs unprefixed +``` + +In components, prefer `` over `table.store.state` for reactivity (see `tanstack-table/react/table-state`). + +### Renames + +| v8 | v9 | +| -------------------------------- | ----------------------------------------------------------- | +| `sortingFn` (column def) | `sortFn` | +| `sortingFns` (registry) | `sortFns` | +| `getSortingFn()` | `getSortFn()` | +| `getAutoSortingFn()` | `getAutoSortFn()` | +| `SortingFn` / `SortingFns` types | `SortFn` / `SortFns` | +| `enablePinning: true` | `enableColumnPinning: true` AND/OR `enableRowPinning: true` | +| `columnSizingInfo` state | `columnResizing` | +| `onColumnSizingInfoChange` | `onColumnResizingChange` | +| `table._getFacetedRowModel` etc. | `table.getFacetedRowModel` etc. (underscore dropped) | +| `row._getAllCellsByColumnId()` | `row.getAllCellsByColumnId()` | + +### Column resizing split + +```tsx +// v8 +useReactTable({ + /* ColumnSizing feature handles BOTH widths AND drag */ +}) + +// v9 — explicit +const _features = tableFeatures({ + columnSizingFeature, // fixed widths + columnResizingFeature, // drag-to-resize (separate feature) +}) +``` + +### Type generics — `TFeatures` first + +```tsx +// v8 +type MyDef = ColumnDef +declare module '@tanstack/react-table' { + interface ColumnMeta { + customProp: string + } +} + +// v9 +type MyDef = ColumnDef +declare module '@tanstack/react-table' { + interface ColumnMeta< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData = CellData, + > { + customProp: string + } +} +``` + +`RowData` was also tightened from `unknown | object | any[]` to `Record | Array`. + +### Mutability + +`data` and `columns` are `readonly` in v9. Any code that mutates the array in place (`data.push(...)`) will fail at the TS layer; flow changes through `setData(prev => [...prev, row])`. + +## Bridge migration: `useLegacyTable` + +When you need to keep one or many tables on the v8 API while you upgrade others, switch the import path: + +```tsx +// Before: v8 import +import { useReactTable, getCoreRowModel, getSortedRowModel, getPaginationRowModel, getFilteredRowModel, createColumnHelper, flexRender } + from '@tanstack/react-table' + +// After: legacy shim, same call shape +import { + useLegacyTable, + legacyCreateColumnHelper, + getCoreRowModel, getSortedRowModel, getPaginationRowModel, getFilteredRowModel, +} from '@tanstack/react-table/legacy' +import { flexRender } from '@tanstack/react-table' + +const columnHelper = legacyCreateColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { cell: (info) => info.getValue() }), + // ... +]) + +function App({ data }) { + const [sorting, setSorting] = React.useState([]) + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 }) + + const table = useLegacyTable({ + columns, + data, + // v8-style root options — mapped to v9 _rowModels under the hood + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + }) + + // Rendering: with useLegacyTable, prefer `flexRender(header.column.columnDef.header, header.getContext())`. + return (/* same JSX shape as v8 */) +} +``` + +Source: `examples/react/basic-use-legacy-table/src/main.tsx`; `packages/react-table/src/legacy.ts`. + +Tradeoffs of the bridge: + +- Bundles every feature (no tree-shaking benefit). +- No `table.Subscribe`, no `table.atoms`, no fine-grained reactivity — subscribes to all state like v8. +- **Deprecated in v9, removed in v10.** Use it to unblock incremental migration; don't ship new features against it. + +## Common Mistakes + +### CRITICAL Keeping `useReactTable` + `get*RowModel` options on v9 + +Wrong: + +```tsx +import { useReactTable, getCoreRowModel } from '@tanstack/react-table' +const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), +}) +``` + +Correct: + +```tsx +import { useTable, tableFeatures } from '@tanstack/react-table' +const _features = tableFeatures({}) +const table = useTable({ _features, _rowModels: {}, data, columns }) +``` + +`useReactTable` is the v8 entry point and won't have `table.Subscribe` / `table.atoms`. `getCoreRowModel()` as an option was removed — core is automatic; non-core models move into `_rowModels` as factories that take their \*Fns parameter. +Source: PR #6202; `packages/react-table/src/useTable.ts`. + +### CRITICAL `createSortedRowModel()` without `sortFns` + +Wrong: + +```tsx +_rowModels: { + sortedRowModel: createSortedRowModel() +} +``` + +Correct: + +```tsx +import { createSortedRowModel, sortFns } from '@tanstack/react-table' +_rowModels: { + sortedRowModel: createSortedRowModel(sortFns) +} +``` + +Each row-model factory in v9 takes its functions registry as a parameter so they can be tree-shaken: `createFilteredRowModel(filterFns)`, `createGroupedRowModel(aggregationFns)`, `createSortedRowModel(sortFns)`. +Source: `docs/framework/react/guide/migrating.md`. + +### CRITICAL `createColumnHelper()` (v8 arity) + +Wrong: + +```tsx +const columnHelper = createColumnHelper() +``` + +Correct: + +```tsx +const columnHelper = createColumnHelper() +``` + +v9 requires ``. `typeof _features` is the standard idiom — declare features once and reuse the type. +Source: `docs/framework/react/guide/migrating.md`. + +### CRITICAL `table.getState()` reads on v9 + +Wrong: + +```tsx +function Toolbar({ table }) { + const { rowSelection } = table.getState() // exists on v8, removed on v9 + return
{Object.keys(rowSelection).length} selected
+} +``` + +Correct: + +```tsx +function Toolbar({ table }) { + return ( + Object.keys(s.rowSelection).length}> + {(count) =>
{count} selected
} +
+ ) +} +``` + +`getState` was removed. Use `table.store.state` for a flat snapshot, `table.state` if you passed a `useTable` selector, or `` for reactive reads. +Source: `docs/framework/react/guide/migrating.md`; `examples/react/basic-subscribe/src/main.tsx`. + +### HIGH `enablePinning: true` on v9 + +Wrong: + +```tsx +useTable({ _features, _rowModels: {}, columns, data, enablePinning: true }) +``` + +Correct: + +```tsx +useTable({ + _features, + _rowModels: {}, + columns, + data, + enableColumnPinning: true, + enableRowPinning: true, +}) +``` + +`enablePinning` was split. Pick one or both depending on what you actually want. +Source: `docs/framework/react/guide/migrating.md`. + +### HIGH `_`-prefixed APIs + +Wrong: + +```tsx +row._getAllCellsByColumnId() +table._getFacetedRowModel() +table._getFacetedMinMaxValues() +``` + +Correct: + +```tsx +row.getAllCellsByColumnId() +table.getFacetedRowModel() +table.getFacetedMinMaxValues() +``` + +All went public — drop the underscore. +Source: `docs/framework/react/guide/migrating.md`. + +### HIGH Module augmentation with v8 generic arity + +Wrong: + +```tsx +declare module '@tanstack/react-table' { + interface ColumnMeta { + customProp: string + } +} +``` + +Correct: + +```tsx +declare module '@tanstack/react-table' { + interface ColumnMeta< + TFeatures extends TableFeatures, + TData extends RowData, + TValue extends CellData = CellData, + > { + customProp: string + } +} +``` + +v9 added `TFeatures` as the first generic on `ColumnMeta` / `Column` / `Row` / `ColumnDef`. Wrong arity silently widens the augmentation. +Source: `examples/react/basic-use-legacy-table/src/main.tsx` (correct shape). + +### MEDIUM Mutating `data` or `columns` in place + +Wrong: + +```tsx +const data = [] +function addRow(row) { + data.push(row) + rerender() +} +``` + +Correct: + +```tsx +const [data, setData] = React.useState([]) +function addRow(row: Person) { + setData((prev) => [...prev, row]) +} +``` + +PR #6183 made `data` and `columns` `readonly` to force changes through React state. +Source: `docs/framework/react/guide/migrating.md`. + +### MEDIUM Treating `useLegacyTable` as a long-term answer + +Wrong: + +```tsx +// New feature shipped on the legacy shim — locks in the bigger bundle indefinitely. +import { useLegacyTable } from '@tanstack/react-table/legacy' +``` + +Correct: + +```tsx +// New tables: useTable. Reach for the legacy shim only when migrating an existing v8 table piecemeal. +import { useTable, tableFeatures } from '@tanstack/react-table' +``` + +`useLegacyTable` is deprecated in v9 and scheduled for removal in v10. It exists to unblock incremental migration, not to be a permanent API. +Source: `docs/framework/react/guide/use-legacy-table.md`. + +### CRITICAL Hallucinating react-table v7 / `useTable(opts, useSortBy)` shape + +Wrong: + +```tsx +import { useTable, useSortBy } from 'react-table' // v7 package name + plugin hooks +const table = useTable({ columns, data }, useSortBy) +``` + +Correct: + +```tsx +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +The `react-table` package (v7) was renamed to `@tanstack/react-table` in v8 and reshaped again in v9. Agents trained on pre-v9 data will produce all three shapes — only the v9 shape compiles today. +Source: maintainer interview (Phase 4). + +## See Also + +- `tanstack-table/react/getting-started` — the v9 minimum-viable shape. +- `tanstack-table/react/table-state` — replacing `getState()` with selectors / ``. +- `tanstack-table/react/production-readiness` — tree-shaking with `_features` (the whole point of the v9 redesign). +- `tanstack-table/react/react-subscribe-compiler-compat` — fixes the v8-React-Compiler "incompatible library" warning. diff --git a/packages/react-table/skills/react/production-readiness/SKILL.md b/packages/react-table/skills/react/production-readiness/SKILL.md new file mode 100644 index 0000000000..54c53ebd91 --- /dev/null +++ b/packages/react-table/skills/react/production-readiness/SKILL.md @@ -0,0 +1,341 @@ +--- +name: react/production-readiness +description: > + Ship-ready optimizations for `@tanstack/react-table` v9: tree-shake the + bundle by registering ONLY the `_features` you actually use; memoize + `_features`, `data`, and `columns` for stable identity; replace + `(state) => state` with narrow selectors or per-slice + `useSelector(table.atoms.)` subscriptions; and push state-driven + re-renders down the tree with `` / `` so the + expensive table body doesn't re-render every time you toggle a sort + indicator. Don't over-optimize small tables — the default selector + + inline rendering is fine until measured perf demands more. +type: lifecycle +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - react/table-state +sources: + - TanStack/table:docs/guide/features.md + - TanStack/table:docs/framework/react/guide/table-state.md + - TanStack/table:examples/react/basic-subscribe/src/main.tsx + - TanStack/table:examples/react/basic-external-atoms/src/main.tsx + - TanStack/table:examples/react/kitchen-sink/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — `_features` tree-shaking and the atom reactivity model are the foundation; this skill is about _which_ of the patterns introduced there you actually need in production. + +## When to optimize + +The default `useTable` selector is `(state) => state` — the component re-renders on any state change. That's correct and ergonomic, and for tables with a few hundred rows and basic features it's the right default. Don't reach for `` walls or per-slice atom subscriptions until you've **measured** a problem (slow keystrokes in a filter input, dropped frames during scrolling, long-running renders). On small tables the optimization noise costs more than it saves. + +## Setup — stable references + +The biggest single perf win is keeping `_features`, `_rowModels`, `columns`, and `data` references stable across renders. Internal memoization keys off identity, so a new object every render forces full recomputation. + +```tsx +// ✓ Module scope = stable identity +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) +const _rowModels = { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), +} +const columnHelper = createColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +// Module-scope empty array for the "no data yet" branch. +const EMPTY: Person[] = [] + +function MyTable({ data }: { data: Person[] | undefined }) { + const table = useTable({ + _features, + _rowModels, + columns, + data: data ?? EMPTY, + }) +} +``` + +## Core Patterns + +### 1. Tree-shake `_features` to only what you use + +Avoid `stockFeatures` in production. A sort-only table is ~6–7kb registered explicitly versus ~15–20kb if you import the whole stock set. + +```tsx +// ✓ Pay only for what you render +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, +}) + +// ✗ Ships filtering, faceting, grouping, pinning, expanding, sizing, +// resizing, visibility, ordering, row-selection, row-pinning… +const _features = tableFeatures(stockFeatures) +``` + +Source: `docs/guide/features.md`; maintainer guidance. + +### 2. Narrow the `useTable` selector + +`(state) => state` re-renders the holding component on any state change. If only one component cares about one slice, pass a narrow selector — or pass `() => null` and rely on `` walls inside. + +```tsx +// Narrow to specific slices at the table level. +const table = useTable({ _features, _rowModels, columns, data }, (state) => ({ + sorting: state.sorting, + pagination: state.pagination, +})) + +// Or: opt out completely at the top, subscribe surgically inside. +const table = useTable(opts, () => null) +``` + +Source: `examples/react/basic-subscribe/src/main.tsx` (uses `() => null`). + +### 3. Push re-renders down with `` + +A noisy footer that re-renders on every keystroke in a filter doesn't need to re-render the whole ``. Wrap each consumer in `` with its own selector. + +```tsx +function MyTable({ data, columns }) { + const table = useTable( + { _features, _rowModels, columns, data }, + () => null, // top-level opt-out + ) + return ( + <> + {/* heavy — keep stable */} + {/* Footer re-renders only on pagination changes */} + s.pagination}> + {(pagination) => } + + + ) +} +``` + +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### 4. Per-slice `useSelector(table.atoms.)` for narrowest scope + +Even narrower than ``: subscribe a leaf component to a single atom. Skips constructing a state snapshot entirely. + +```tsx +import { useSelector } from '@tanstack/react-store' + +function SelectedCount({ table }) { + // Re-renders ONLY when rowSelection changes — not sorting / pagination / etc. + const selection = useSelector(table.atoms.rowSelection) + return {Object.keys(selection).length} selected +} +``` + +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### 5. React Compiler — read state via `` in nested components + +The compiler can't see through the `table` closure, so reads via builder APIs (`column.getIsPinned()`, `row.getIsSelected()`) in memoized child components go stale. Wrap them in `` (see `tanstack-table/react/react-subscribe-compiler-compat`). + +### 6. Virtualization in the deepest possible component + +Keep `useVirtualizer` in the deepest component (`TableBody`, not `App`). Any state change in the holder of the virtualizer re-runs it and tanks scroll perf. See `tanstack-table/react/compose-with-tanstack-virtual`. + +## Common Mistakes + +### HIGH Using `stockFeatures` in production + +Wrong: + +```tsx +import { useTable, stockFeatures, tableFeatures } from '@tanstack/react-table' +const _features = tableFeatures(stockFeatures) // ships every feature +``` + +Correct: + +```tsx +import { + useTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, +} from '@tanstack/react-table' +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) +``` + +Tree-shaking via `_features` is one of the headline reasons for the v9 rewrite. `stockFeatures` exists for migration / "everything on" smoke tests, not production. +Source: maintainer guidance; `docs/guide/features.md`. + +### HIGH Unstable `_features` / `_rowModels` / `columns` references + +Wrong: + +```tsx +function MyTable({ data }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const _rowModels = { sortedRowModel: createSortedRowModel(sortFns) } // new every render + const table = useTable({ _features, _rowModels, columns, data }) +} +``` + +Correct: + +```tsx +// Module scope — declared once. +const _features = tableFeatures({ rowSortingFeature }) +const _rowModels = { sortedRowModel: createSortedRowModel(sortFns) } + +function MyTable({ data }) { + const table = useTable({ _features, _rowModels, columns, data }) +} +``` + +Internal memoization keys off identity. A new object every render busts memos and forces full recomputation. +Source: FAQ #1; `examples/react/basic-use-table/src/main.tsx`. + +### HIGH `data={rows ?? []}` in JSX + +Wrong: + +```tsx + +``` + +Correct: + +```tsx +const EMPTY: Person[] = [] // module scope + + +// or memoize the fallback: +const data = React.useMemo(() => query.data?.rows ?? [], [query.data]) +``` + +The `?? []` produces a new array identity each render, busting internal memos that depend on `data` reference. +Source: `examples/react/with-tanstack-query/src/main.tsx`. + +### MEDIUM Leaving `(state) => state` when only one component cares + +Wrong: + +```tsx +// Default selector — whole tree re-renders on every state change. +const table = useTable(opts) +return +``` + +Correct: + +```tsx +const table = useTable(opts, () => null) +return ( + <> + + s.pagination}> + {(p) => } + + +) +``` + +Once you've measured a problem, narrow the top selector and add `` walls around the components that actually need state. +Source: `examples/react/basic-subscribe/src/main.tsx`. + +### MEDIUM Subscribing to the whole `table.store` when a single atom would do + +Wrong: + +```tsx + s.rowSelection}> + {(rs) => {Object.keys(rs).length} selected} + +``` + +Correct: + +```tsx +import { useSelector } from '@tanstack/react-store' + +function SelectedCount({ table }) { + const selection = useSelector(table.atoms.rowSelection) + return {Object.keys(selection).length} selected +} +``` + +`` still selects from `table.store.state` (the full state). For a single slice, `useSelector(table.atoms.X)` skips even constructing the snapshot. +Source: `docs/framework/react/guide/table-state.md`. + +### MEDIUM Hoisting heavy table state reads above virtualizers + +Wrong: + +```tsx +function App() { + const rowVirtualizer = useVirtualizer({ + /* … */ + }) // virtualizer too high + const table = useTable(opts) + return +} +``` + +Correct: + +```tsx +function App() { + const tableContainerRef = React.useRef(null) + const table = useTable(opts) + return ( +
+ +
+ ) +} +function TableBody({ table, tableContainerRef }) { + const rowVirtualizer = useVirtualizer({ + /* … */ + }) // virtualizer at the bottom + /* … */ +} +``` + +The virtualizer in the deepest component avoids re-running on unrelated state changes. +Source: `examples/react/virtualized-rows/src/main.tsx`. + +### MEDIUM Premature `` / narrow selectors on small tables + +Wrong: + +```tsx +// 50-row table with Subscribe around every cell. +header: ({ table }) => ( + s.sorting}> + {() => } + +) +``` + +Correct: + +```tsx +const table = useTable({ _features, _rowModels, columns, data }) +// Reach for Subscribe later, scoped to actual hotspots. +``` + +Advanced state-management patterns are for advanced cases. On small tables the boundary churn costs more than it saves. +Source: maintainer guidance (Phase 4). + +## See Also + +- `tanstack-table/react/table-state` — the API surface this skill optimizes against. +- `tanstack-table/react/react-subscribe-compiler-compat` — required reading if React Compiler is on. +- `tanstack-table/react/compose-with-tanstack-store` — fine-grained subscriptions via external atoms. +- `tanstack-table/react/compose-with-tanstack-virtual` — row/column virtualization patterns. +- `tanstack-table/react/compose-with-tanstack-devtools` — `/production` import for live devtools in prod. diff --git a/packages/react-table/skills/react/react-subscribe-compiler-compat/SKILL.md b/packages/react-table/skills/react/react-subscribe-compiler-compat/SKILL.md new file mode 100644 index 0000000000..70a8644b97 --- /dev/null +++ b/packages/react-table/skills/react/react-subscribe-compiler-compat/SKILL.md @@ -0,0 +1,269 @@ +--- +name: react/react-subscribe-compiler-compat +description: > + React Compiler compatibility for `@tanstack/react-table` v9. When you read + table state via builder APIs (`column.getIsPinned()`, `row.getIsSelected()`, + `cell.getIsAggregated()`, `header.column.getIsSorted()`) inside a nested + custom component, React Compiler memoizes the child against the stable + `column` / `row` / `cell` reference and never re-runs when the underlying + atom changes. Symptom: stale checkboxes, frozen sort indicators, dead pin + buttons. Fix: wrap the JSX in `` + or `` so the dependency is visible to the + compiler. Routing keywords: Subscribe, table.Subscribe, React Compiler, + stale checkbox, memoized header/cell, builder API. +type: framework +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - react/table-state +sources: + - TanStack/table:docs/framework/react/guide/table-state.md + - TanStack/table:packages/react-table/src/Subscribe.ts + - TanStack/table:examples/react/basic-subscribe/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/react/table-state`. Read those first — the atom model is what makes builder reads invisible to React Compiler, and `table-state` covers the basics of `` / ``. This skill is laser-focused on the **one** React-specific failure mode that comes up when React Compiler is enabled. + +## Why this exists + +Under React Compiler, JSX is memoized against the props your component receives. A custom `DraggableHeader({ header })` receives a stable `header` reference; the compiler hashes the JSX it produces against that reference. When you call `header.column.getIsPinned()` inside that component, the compiler **cannot see the atom read** hidden behind the method — it returns the cached JSX, and the UI goes stale. + +The fix is to make the dependency visible: read the slice via `` or ``. The compiler sees the selector function, picks up the dependency edge, and re-runs the children whenever the subscribed slice changes. + +## Setup + +You only need `` from `@tanstack/react-table`. It's the same component shown in `table-state`, applied specifically around builder-pattern reads in custom nested components. + +```tsx +import { Subscribe } from '@tanstack/react-table' +``` + +## Core Pattern: wrap nested builder reads in `` + +Whenever a child component reads state via a builder method (`getIs*`, `getCan*`, etc.) inside JSX that the compiler memoizes, wrap it in `` keyed on the relevant slice. + +### Pin / sort indicator on a custom header component + +```tsx +import { Subscribe } from '@tanstack/react-table' + +function DraggableHeader({ header, table }) { + return ( + ({ columnPinning: s.columnPinning, sorting: s.sorting })} + > + {() => { + // Reads run inside the Subscribe child — re-evaluated on selected slice changes. + const isPinned = header.column.getIsPinned() + const sortDir = header.column.getIsSorted() + return ( +
+ ) + }} + + ) +} +``` + +Source: `docs/framework/react/guide/table-state.md` (Subscribe for React Compiler Compatibility); `packages/react-table/src/Subscribe.ts`. + +### Row-selection checkbox inside a cell — narrowest subscription + +```tsx +columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + // Subscribe to the rowSelection ATOM (not table.store) and project to ONE row. + // Re-renders ONLY when this row's selection flips. + rowSelection[row.id]} + > + {(isSelected) => ( + + )} + + ), +}) +``` + +Source: `examples/react/basic-subscribe/src/main.tsx` (this exact pattern). + +### Component-level vs cell-level — which API + +| Context | `table` is | API | +| ------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------- | +| Component receiving `table` from `useTable` | `ReactTable<…>` | `` works | +| Inside `cell: ({ table }) => …` / `header: ({ table }) => …` | core `Table` | `table.Subscribe` is **undefined**. Use the standalone `` import. | + +## Common Mistakes + +### CRITICAL Builder read in a nested component without `` + +Wrong: + +```tsx +function DraggableHeader({ header }) { + const isPinned = header.column.getIsPinned() // hidden atom read + return +} +``` + +Correct: + +```tsx +import { Subscribe } from '@tanstack/react-table' + +function DraggableHeader({ header, table }) { + return ( + s.columnPinning}> + {() => { + const isPinned = header.column.getIsPinned() + return + }} + + ) +} +``` + +React Compiler memoizes the child's JSX against the stable `header` reference. The state-dependent builder method hides its atom dependency, so the memoized JSX never re-runs. +Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx`; `packages/react-table/src/Subscribe.ts`. + +### HIGH Using `table.Subscribe` from inside a cell or header definition + +Wrong: + +```tsx +cell: ({ row, table }) => ( + s[row.id]} + > + {(isSelected) => } + +) +``` + +Correct: + +```tsx +import { Subscribe } from '@tanstack/react-table' + +cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + +) +``` + +Cell and header render contexts type `table` as `Table`, not `ReactTable` — `table.Subscribe` is undefined. Import the standalone ``. +Source: `docs/framework/react/guide/table-state.md` (Tips); `packages/react-table/src/Subscribe.ts`. + +### MEDIUM Wrapping every cell in `` by default + +Wrong: + +```tsx +// Inline cell that already re-runs on every parent render — wrap is unnecessary. +{ + row.getVisibleCells().map((cell) => ( + s.rowSelection}> + {() => ( + + )} + + )) +} +``` + +Correct: + +```tsx +{ + row.getVisibleCells().map((cell) => ( + + )) +} +``` + +`` is overhead. For inline JSX in the parent component the compiler always re-evaluates on parent re-render, so wrapping adds subscription churn without correctness benefit. Reach for `` only at custom-component boundaries that the compiler memoizes. +Source: `docs/framework/react/guide/table-state.md`. + +### MEDIUM Subscribing to the whole `table.store` for one row's checkbox + +Wrong: + +```tsx + s.rowSelection[row.id]}> + {(isSelected) => } + +``` + +Correct: + +```tsx + s[row.id]}> + {(isSelected) => ( + + )} + +``` + +Every change to `table.store` re-runs the Subscribe child. Subscribing to `table.atoms.rowSelection` (a single slice atom) with a per-row projection limits work to actual selection changes for that row. +Source: `examples/react/basic-subscribe/src/main.tsx`; `docs/framework/react/guide/table-state.md` (Tips). + +### CRITICAL Reading raw state with `table.getState()` on v9 instead of `` + +Wrong: + +```tsx +function Toolbar({ table }) { + // v8 muscle memory — does NOT subscribe in v9. + const { rowSelection } = table.getState() + return
{Object.keys(rowSelection).length} selected
+} +``` + +Correct: + +```tsx +function Toolbar({ table }) { + return ( + Object.keys(s.rowSelection).length}> + {(count) =>
{count} selected
} +
+ ) +} +``` + +`table.getState()` is a current-value read in v9; it does not subscribe the component. The default `useTable` selector subscribes the parent, but deeply-nested children should opt in explicitly. +Source: PR #6246; `packages/react-table/src/useTable.ts` JSDoc. + +## See Also + +- `tanstack-table/react/table-state` — base `` / `` API and external atoms. +- `tanstack-table/react/production-readiness` — selector narrowing and per-slice `useSelector(table.atoms.X)`. +- `tanstack-table/react/migrate-v8-to-v9` — replacing `useReactTable` with `useTable` to fix the React Compiler "incompatible library" warning. diff --git a/packages/react-table/skills/react/table-state/SKILL.md b/packages/react-table/skills/react/table-state/SKILL.md new file mode 100644 index 0000000000..8f0bea7585 --- /dev/null +++ b/packages/react-table/skills/react/table-state/SKILL.md @@ -0,0 +1,432 @@ +--- +name: react/table-state +description: > + Wiring reactivity for `@tanstack/react-table` v9. Covers `useTable` (and its + second-argument selector), reading state via `table.state` / `table.store` / + `table.atoms.`, rendering with `table.FlexRender`, opting subtrees into + fine-grained reactivity with `` and the standalone + ``, owning slices with external atoms via `useCreateAtom` + + `options.atoms`, and packaging shared config into a reusable hook with + `createTableHook` (`useAppTable`, `createAppColumnHelper`, `table.AppTable` / + `table.AppHeader` / `table.AppCell` / `table.AppFooter`). Routing keywords: + useTable, useSelector, useCreateAtom, atoms, react-store, table.Subscribe, + FlexRender. +type: framework +library: tanstack-table +framework: react +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - TanStack/table:docs/framework/react/guide/table-state.md + - TanStack/table:packages/react-table/src/useTable.ts + - TanStack/table:packages/react-table/src/Subscribe.ts + - TanStack/table:packages/react-table/src/FlexRender.tsx + - TanStack/table:packages/react-table/src/createTableHook.tsx + - TanStack/table:examples/react/basic-use-table/src/main.tsx + - TanStack/table:examples/react/basic-subscribe/src/main.tsx + - TanStack/table:examples/react/basic-external-atoms/src/main.tsx + - TanStack/table:examples/react/basic-external-state/src/main.tsx + - TanStack/table:examples/react/basic-use-app-table/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/setup`. Read those first — `state-management` explains the v9 atom model (per-slice readonly `table.atoms`, internal writable `table.baseAtoms`, flat `table.store`), and this skill shows how each surface is consumed in React. + +## Setup + +Every React v9 table follows the same shape. Define `_features`, `_rowModels`, and `columns` at module scope so their references are stable, then call `useTable` and render with ``. + +```tsx +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + createColumnHelper, +} from '@tanstack/react-table' +import type { ColumnDef } from '@tanstack/react-table' + +type Person = { firstName: string; lastName: string; age: number } + +// Module-scope = stable identity. Critical for re-render perf. +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +function PeopleTable({ data }: { data: Person[] }) { + const table = useTable( + { + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + }, + (state) => state, // default selector — matches v8 ergonomics + ) + + return ( +
+ {header.column.id} + {sortDir === 'asc' ? ' 🔼' : sortDir === 'desc' ? ' 🔽' : null} + {header.column.id}{header.column.id}{flexRender(cell.column.columnDef.cell, cell.getContext())} + +
+ + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + ))} + + ))} + +
+ {h.isPlaceholder ? null : } +
+ +
+ ) +} +``` + +Source: `examples/react/basic-use-table/src/main.tsx`. + +## Core Patterns + +### 1. `useTable` selector (second argument) + +The default is `(state) => state` — the component re-renders on any state change. Pass a narrower selector once you have a measurable perf problem, or pass `() => null` to opt out at the top level and use `` walls instead. + +```tsx +// Narrow selector — only re-render this component on sorting/pagination changes. +const table = useTable({ _features, _rowModels, columns, data }, (state) => ({ + sorting: state.sorting, + pagination: state.pagination, +})) + +// Or: subscribe to nothing at the top level; do all reads inside . +const table = useTable(opts, () => null) +``` + +Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx` (uses `() => null`). + +### 2. `` and standalone `` + +Use `` at the component level. Inside cell/header render contexts, `table` is the core `Table` (not `ReactTable`), so `table.Subscribe` is **not on the object** — import the standalone `` and pass `source={table.store}` or `source={table.atoms.X}`. + +```tsx +import { Subscribe } from '@tanstack/react-table' + +// Component-level: table.Subscribe with a state selector. + s.pagination}> + {(pagination) => Page {pagination.pageIndex + 1}} + + +// Subscribe to a single atom (narrower than table.store). + + {(rowSelection) => {Object.keys(rowSelection).length} selected} + + +// Inside a cell — table here is the CORE Table, no .Subscribe. Use the import. +columnHelper.display({ + id: 'select', + cell: ({ row, table }) => ( + s[row.id]} + > + {(isSelected) => ( + + )} + + ), +}) +``` + +Source: `packages/react-table/src/Subscribe.ts`; `examples/react/basic-subscribe/src/main.tsx`. + +### 3. External atoms with `useCreateAtom` + `options.atoms` + +Move ownership of any slice to an atom you create with `useCreateAtom` (from `@tanstack/react-store`). Pass it via `options.atoms.`. The table writes to your atom when you call `table.setSorting(...)`, `table.setPageIndex(...)`, etc. — **no `on*Change` handler is needed**. + +Precedence: `options.atoms[key]` > `options.state[key]` > internal `baseAtoms[key]`. Don't pass both `state.foo` and `atoms.foo` for the same slice; `atoms` wins silently. + +```tsx +import { useCreateAtom, useSelector } from '@tanstack/react-store' +import type { PaginationState, SortingState } from '@tanstack/react-table' + +function MyTable({ columns, data }) { + const sortingAtom = useCreateAtom([]) + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + + // Independent fine-grained subscriptions. + const sorting = useSelector(sortingAtom) + const pagination = useSelector(paginationAtom) + + const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + atoms: { sorting: sortingAtom, pagination: paginationAtom }, + // NOTE: no onSortingChange / onPaginationChange — table writes directly to atoms. + }) +} +``` + +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### 4. `createTableHook` for reusable shared config + +When you ship the same `_features` / `_rowModels` / cell components across many tables, package them with `createTableHook`. You get `useAppTable`, `createAppColumnHelper`, and `table.AppTable` / `AppHeader` / `AppCell` / `AppFooter` boundaries. + +```tsx +import { createTableHook } from '@tanstack/react-table' + +const { useAppTable, createAppColumnHelper } = createTableHook({ + _features: {}, + _rowModels: {}, + debugTable: true, +}) + +const columnHelper = createAppColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { cell: (info) => info.getValue() }), + // … +]) + +function App() { + const [data] = React.useState(() => [...defaultData]) + const table = useAppTable({ columns, data }, (state) => state) + return ( + + {/* table.FlexRender header={h} */} + {/* table.FlexRender cell={c} */} +
+ ) +} +``` + +Source: `examples/react/basic-use-app-table/src/main.tsx`; `packages/react-table/src/createTableHook.tsx`. + +## Common Mistakes + +### CRITICAL Reading `table.atoms.X.get()` during render and expecting re-renders + +Wrong: + +```tsx +function Pager({ table }) { + const pagination = table.atoms.pagination.get() // current-value read, NOT a subscription + return Page {pagination.pageIndex + 1} +} +``` + +Correct: + +```tsx +function Pager({ table }) { + return ( + p.pageIndex} + > + {(pageIndex) => Page {pageIndex + 1}} + + ) +} +``` + +`.get()` and `table.store.state` are current-value reads, not subscriptions. The component never re-renders when the atom changes. +Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-subscribe/src/main.tsx`. + +### HIGH Passing both `atoms.X` and `state.X` for the same slice + +Wrong: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, + state: { pagination }, // silently ignored + onPaginationChange: setPagination, // silently ignored +}) +``` + +Correct: + +```tsx +// Pick exactly one source of truth per slice. +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { pagination: paginationAtom }, +}) +``` + +Precedence is `options.atoms[key]` > `options.state[key]` > internal — `state` is dropped without a warning. +Source: `docs/framework/react/guide/table-state.md`; `examples/react/basic-external-atoms/src/main.tsx`. + +### HIGH Using `table.Subscribe` inside a column cell or header render + +Wrong: + +```tsx +cell: ({ row, table }) => ( + s[row.id]} + > + {(isSelected) => } + +) +``` + +Correct: + +```tsx +import { Subscribe } from '@tanstack/react-table' + +cell: ({ row, table }) => ( + s[row.id]}> + {(isSelected) => ( + + )} + +) +``` + +In cell and header render contexts, `table` is the core `Table`, not `ReactTable` — `table.Subscribe` is undefined. Use the standalone import. +Source: `docs/framework/react/guide/table-state.md` (Tips); `packages/react-table/src/Subscribe.ts`. + +### CRITICAL Creating an atom inside the render body without `useCreateAtom` + +Wrong: + +```tsx +function MyTable() { + const sortingAtom = createAtom([]) // new atom every render + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +Correct: + +```tsx +function MyTable() { + const sortingAtom = useCreateAtom([]) // stable across renders + useTable({ + _features, + _rowModels: {}, + columns, + data, + atoms: { sorting: sortingAtom }, + }) +} +``` + +A fresh atom each render unbinds the table from the slice and resets the state to the initial value on every render. +Source: `examples/react/basic-external-atoms/src/main.tsx`. + +### HIGH Unstable `data` / `columns` / `_features` references + +Wrong: + +```tsx +function MyTable({ rows }) { + const _features = tableFeatures({ rowSortingFeature }) // new every render + const columns = [ + /* … */ + ] // new every render + const table = useTable({ + _features, + _rowModels: {}, + columns, + data: rows ?? [], + }) +} +``` + +Correct: + +```tsx +// Module scope — declared once. +const _features = tableFeatures({ rowSortingFeature }) +const columns: ColumnDef[] = [ + /* … */ +] + +function MyTable({ rows }) { + const data = rows ?? EMPTY // EMPTY at module scope + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Internal memoization keys off identity. A new reference each render busts memos and forces full recomputation. +Source: `docs/framework/react/guide/table-state.md` (FAQ #1). + +### MEDIUM Premature `Subscribe` / custom selector on small tables + +Wrong: + +```tsx +// 50-row table with Subscribe wrapped around every cell. +header: ({ table }) => ( + s.sorting}> + {() => } + +) +``` + +Correct: + +```tsx +// Default selector + inline rendering. Reach for Subscribe later, scoped to actual hotspots. +const table = useTable({ _features, _rowModels, columns, data }) +``` + +Subscribe and narrow selectors are for large / expensive tables where full re-renders measurably hurt. On a small table they only add complexity. +Source: maintainer guidance (Phase 4). + +## See Also + +- `tanstack-table/react/react-subscribe-compiler-compat` — when builder reads in nested components break under React Compiler memoization. +- `tanstack-table/react/getting-started` — first-table walkthrough. +- `tanstack-table/react/production-readiness` — narrowing selectors, tree-shaking, reference stability. +- `tanstack-table/react/compose-with-tanstack-store` — sharing slice atoms across components, persistence. diff --git a/packages/solid-table-devtools/README.md b/packages/solid-table-devtools/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/solid-table-devtools/README.md +++ b/packages/solid-table-devtools/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/solid-table-devtools/package.json b/packages/solid-table-devtools/package.json index 55f477032d..f51b4a65a9 100644 --- a/packages/solid-table-devtools/package.json +++ b/packages/solid-table-devtools/package.json @@ -18,7 +18,8 @@ "solid", "tanstack", "table", - "devtools" + "devtools", + "tanstack-intent" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", @@ -44,7 +45,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "dependencies": { "@tanstack/devtools": "^0.12.2", diff --git a/packages/solid-table-devtools/skills/solid/compose-with-tanstack-devtools/SKILL.md b/packages/solid-table-devtools/skills/solid/compose-with-tanstack-devtools/SKILL.md new file mode 100644 index 0000000000..4331b5f467 --- /dev/null +++ b/packages/solid-table-devtools/skills/solid/compose-with-tanstack-devtools/SKILL.md @@ -0,0 +1,176 @@ +--- +name: solid/compose-with-tanstack-devtools +description: > + Wire up TanStack Devtools for TanStack Table in Solid. Mount `TanStackDevtools` + with `tableDevtoolsPlugin()` once at the app root and call + `useTanStackTableDevtools(table, name?)` after each `createTable` so the table + is registered as a devtools target. Live devtools are tree-shaken to no-ops in + production unless you import from `@tanstack/solid-table-devtools/production`. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - state-management + - solid/table-state +sources: + - TanStack/table:docs/devtools.md + - TanStack/table:packages/solid-table-devtools/src/index.ts + - TanStack/table:packages/solid-table-devtools/src/plugin.tsx + - TanStack/table:packages/solid-table-devtools/src/useTanStackTableDevtools.ts + - TanStack/table:packages/solid-table-devtools/src/production.ts +--- + +This skill builds on `tanstack-table/solid/table-state`. Read that first — the devtools panel inspects whatever table instance you register, so you need a working `createTable` before this skill is useful. + +## Setup + +Install the TanStack Devtools host and the Solid Table adapter: + +```sh +pnpm add @tanstack/solid-devtools @tanstack/solid-table-devtools +``` + +The recommended pattern has two parts: + +1. Mount `` once at the app root with `tableDevtoolsPlugin()`. +2. Call `useTanStackTableDevtools(table, name?)` right after every `createTable()`. + +```tsx +import { render } from 'solid-js/web' +import { createTable } from '@tanstack/solid-table' +import { TanStackDevtools } from '@tanstack/solid-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/solid-table-devtools' + +function UsersScreen() { + const table = createTable({ + _features, + _rowModels, + columns, + data, + }) + + // Register this table with the devtools panel. + useTanStackTableDevtools(table, 'Users Table') + + return +} + +render( + () => ( + <> + + {/* Mount once, anywhere in the tree. */} + + + ), + document.getElementById('root')!, +) +``` + +`tableDevtoolsPlugin()` returns a plugin descriptor for the multi-panel TanStack Devtools UI. `useTanStackTableDevtools` registers the table inside a `createRenderEffect` and removes it via `onCleanup`, so it tracks the table's reactive scope. + +## Patterns + +### Naming Tables + +The optional second argument labels the table in the panel selector. Without it, devtools assign fallback names like `Table 1` and `Table 2`. + +```tsx +useTanStackTableDevtools(table, 'Orders Table') +``` + +### Multiple Tables + +Register as many tables as you like. The Table panel renders a selector. Name each one. + +```tsx +function Dashboard() { + const ordersTable = createTable(ordersOptions) + const usersTable = createTable(usersOptions) + + useTanStackTableDevtools(ordersTable, 'Orders') + useTanStackTableDevtools(usersTable, 'Users') + + return +} +``` + +### Disabling Per Table + +`useTanStackTableDevtools` accepts an `enabled` option. When `false`, the registration is removed (the table disappears from the panel) but the hook still runs cleanly. + +```tsx +useTanStackTableDevtools(table, 'Users Table', { + enabled: import.meta.env.DEV && showTableDevtools(), +}) +``` + +### Production Builds + +The default `@tanstack/solid-table-devtools` entrypoint swaps to no-op implementations when `process.env.NODE_ENV !== 'development'`. To ship the real devtools to production, switch BOTH imports to the `/production` entrypoint: + +```tsx +import { TanStackDevtools } from '@tanstack/solid-devtools' +import { + tableDevtoolsPlugin, + useTanStackTableDevtools, +} from '@tanstack/solid-table-devtools/production' +``` + +If you mix entrypoints (one from `/production`, one from the default), one side is a no-op in production and the panel will appear empty. + +### Conditional Devtools by Env + +For a code-split production-only devtools bundle, dynamically import the `/production` entrypoint behind a flag: + +```tsx +import { lazy, Show, Suspense } from 'solid-js' + +const TableDevtoolsRoot = lazy(async () => { + const { tableDevtoolsPlugin } = + await import('@tanstack/solid-table-devtools/production') + const { TanStackDevtools } = await import('@tanstack/solid-devtools') + return { + default: () => , + } +}) + +function Root() { + return ( + <> + + + + + + + + ) +} +``` + +## Common Mistakes + +### Forgetting to mount `` at the app root + +Calling `useTanStackTableDevtools(table)` alone does nothing visible — it only registers the table with the devtools target store. Without a `` somewhere in the tree, there is no panel to render the registration. Symptom: hook runs without errors, no devtools button appears. + +### Importing devtools from the default path in a prod-only bundle + +If you only deploy production builds, `@tanstack/solid-table-devtools` resolves to no-op implementations. The plugin will mount, but the panel will be empty. Use `@tanstack/solid-table-devtools/production` if you want the real devtools available there. + +### Accidentally shipping devtools to end users via `/production` + +The flip side: importing from `/production` in your default app bundle means every visitor downloads and runs the devtools UI. That is usually not what you want. Restrict `/production` imports to dev/preview entrypoints or code-split them behind a flag. + +### Calling `useTanStackTableDevtools` outside a reactive scope + +The hook uses `createRenderEffect` + `onCleanup`. Call it inside a component (or another Solid reactive scope) that owns the `table`. Calling it in a top-level module body bypasses the reactive owner and the cleanup never fires. + +### Multiple tables without names + +Two `useTanStackTableDevtools(table)` calls without a name produce selector entries like `Table 1` / `Table 2`. When you have 3+ tables this becomes unusable. Always pass a descriptive name as the second argument. diff --git a/packages/solid-table/README.md b/packages/solid-table/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/solid-table/README.md +++ b/packages/solid-table/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/solid-table/package.json b/packages/solid-table/package.json index dc115ac51f..da572899cf 100644 --- a/packages/solid-table/package.json +++ b/packages/solid-table/package.json @@ -18,7 +18,8 @@ "solid", "table", "solid-table", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "./dist/index.d.cts", @@ -47,7 +48,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/solid-table/skills/solid/client-to-server/SKILL.md b/packages/solid-table/skills/solid/client-to-server/SKILL.md new file mode 100644 index 0000000000..1fd5d97906 --- /dev/null +++ b/packages/solid-table/skills/solid/client-to-server/SKILL.md @@ -0,0 +1,243 @@ +--- +name: solid/client-to-server +description: > + Convert a client-side `@tanstack/solid-table` to server-side. Lift the + sort/filter/pagination state into Solid signals or external atoms (`createAtom` + + `useSelector` from `@tanstack/solid-store`), set the corresponding + `manual*` options, supply `rowCount`, and skip the matching row-model factory + (the server already did that work). +type: lifecycle +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - state-management + - pagination + - filtering + - sorting + - solid/table-state +sources: + - docs/framework/solid/guide/table-state.md + - examples/solid/basic-external-atoms/ + - examples/solid/with-tanstack-query/ +--- + +# Client-to-Server — `@tanstack/solid-table` + +When the server (not the browser) owns sort/filter/pagination, you need to +(a) lift those slices out of the table, (b) tell the table not to do that work +itself, and (c) keep the same UI APIs. + +## Mental model + +Each row-model stage has a `manual*` switch: + +| Slice | `manual*` option | What the server now owns | +| ---------- | ------------------------ | -------------------------------- | +| Pagination | `manualPagination: true` | Slicing rows to the current page | +| Sorting | `manualSorting: true` | Ordering rows | +| Filtering | `manualFiltering: true` | Column filters + global filter | +| Grouping | `manualGrouping: true` | Group buckets | +| Expanding | `manualExpanding: true` | Subrow expansion | + +When `manual*` is on: + +- The matching row-model factory is **not required** — the server already did + the work. Skip `createPaginatedRowModel()` for a paginated server endpoint. +- The table will not re-derive that slice. It hands you the new state through + `on[State]Change` / external atoms and trusts the next `data` you give it. +- You typically need to provide `rowCount` so APIs like `table.getPageCount()` + return the server's totals. + +## Recommended pattern: external atoms + `useSelector` + +External atoms are the cleanest cross-component pattern in Solid v9 — the +fetcher and the table can both subscribe to the same atoms. + +```tsx +import { + createTable, + createColumnHelper, + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + tableFeatures, + type PaginationState, + type SortingState, + type ColumnFiltersState, +} from '@tanstack/solid-table' +import { createAtom, useSelector } from '@tanstack/solid-store' +import { createResource } from 'solid-js' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +}) + +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = createAtom([]) +const filtersAtom = createAtom([]) + +function ServerTable() { + // Read each atom as an Accessor for fetcher dependency tracking + const pagination = useSelector(paginationAtom) + const sorting = useSelector(sortingAtom) + const filters = useSelector(filtersAtom) + + const [resource] = createResource( + () => ({ + pagination: pagination(), + sorting: sorting(), + filters: filters(), + }), + (params) => + fetch('/api/people?' + serialize(params)).then((r) => + r.json(), + ) as Promise<{ + rows: Array + rowCount: number + }>, + ) + + const table = createTable({ + _features, + _rowModels: {}, // <- no factories needed for the manual slices + columns, + get data() { + return resource()?.rows ?? [] + }, + get rowCount() { + return resource()?.rowCount ?? 0 + }, + atoms: { + pagination: paginationAtom, + sorting: sortingAtom, + columnFilters: filtersAtom, + }, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }) + + return /* same JSX as a client table */ +} +``` + +Anywhere else in the app you can also call `useSelector(paginationAtom)` to +read the same state — for a "Reset filters" header button, a URL-sync hook, +etc. + +## Alternative: `state` + `on*Change` with `createSignal` + +If you prefer not to introduce `@tanstack/solid-store`, use plain Solid signals. +Slightly less ergonomic for cross-component sharing. + +```tsx +const [pagination, setPagination] = createSignal({ + pageIndex: 0, + pageSize: 10, +}) +const [sorting, setSorting] = createSignal([]) +const [filters, setFilters] = createSignal([]) + +const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { + return resource()?.rows ?? [] + }, + get rowCount() { + return resource()?.rowCount ?? 0 + }, + state: { + get pagination() { + return pagination() + }, + get sorting() { + return sorting() + }, + get columnFilters() { + return filters() + }, + }, + onPaginationChange: setPagination, + onSortingChange: setSorting, + onColumnFiltersChange: setFilters, + manualPagination: true, + manualSorting: true, + manualFiltering: true, +}) +``` + +> Don't mix: providing both `atoms.pagination` and `state.pagination`+`onPaginationChange` +> for the same slice is ambiguous. The atom wins. Pick one. + +## Pair with TanStack Query + +`@tanstack/solid-query` is the canonical fetcher. See the +`compose-with-tanstack-query` skill for the full pattern — key the query on +your pagination/sort/filter accessors, use `keepPreviousData` to avoid +"0 rows" flashes between pages, and feed `data.rows` / `data.rowCount` into the +table. + +## Partial server-side + +You don't have to flip every switch. Mixed modes are valid: + +- `manualPagination: true` + client-side sort/filter: server slices the page, browser orders + filters that slice (rare — usually fights you, but supported). +- `manualSorting: true` only: full dataset in the browser, but the server already ordered rows. Useful for very large pre-sorted dumps. +- `manualPagination: true` + `manualFiltering: true`, client-side `sortedRowModel`: filter + paginate server-side, sort the visible page in the browser. + +When in doubt, flip them all and let the server own everything. + +## Failure modes + +### CRITICAL — flipped `manualPagination` without auditing filtering/sorting + +If you only set `manualPagination: true` but still rely on a client `createFilteredRowModel`, the filter runs against the **current server page**, not the full dataset. Either also set `manualFiltering: true`, or have the server do the filtering and remove the client filter row-model. + +### CRITICAL — forgot `rowCount` + +Without `rowCount`, `table.getPageCount()` is computed from the local `data` +length, which under `manualPagination: true` is one page. `lastPage()`, +`canNextPage`, the page input — all wrong. Always supply +`get rowCount() { return resource()?.rowCount ?? 0 }`. + +### HIGH — mixed ownership of the same slice + +Providing both `atoms.pagination` and `state.pagination` / `onPaginationChange` +for the same slice is ambiguous. The atom wins; the `state`/`on*Change` is +silently ignored. Pick one ownership model per slice. + +### HIGH — kept the client row-model factory after going manual + +If you flipped `manualSorting: true`, keeping `_rowModels.sortedRowModel: createSortedRowModel(sortFns)` does no harm but is dead weight in the bundle and confusing to readers. Remove it. + +### MEDIUM — `data: data()` instead of `get data()` + +Same Solid pitfall as a client table: `data` must be a tracked reactive read. +With a resource: `get data() { return resource()?.rows ?? [] }`. + +### MEDIUM — paginating with `manualPagination` but rebuilding `data` reference unnecessarily + +If `data` identity changes on every render (e.g. always returning a new `[]` +when loading), expect spurious row-model recomputes. Memoize a stable empty +fallback: `const empty: Array = []` outside the component, then +`get data() { return resource()?.rows ?? empty }`. + +### MEDIUM — `autoResetPageIndex` surprise on data swap + +When the underlying `data` reference changes, the paginator may reset +`pageIndex` to 0 by default. With a server-driven `pagination` atom you usually +don't want that — set `autoResetPageIndex: false` on the table options. + +### LOW — assuming `getSelectedRowModel()` covers all rows + +Under `manualPagination: true`, `getSelectedRowModel()` only walks the currently +loaded rows. If the user "selected all" across pages, the table cannot know +that — track that intent in your own atom and reconcile server-side. diff --git a/packages/solid-table/skills/solid/compose-with-tanstack-form/SKILL.md b/packages/solid-table/skills/solid/compose-with-tanstack-form/SKILL.md new file mode 100644 index 0000000000..5f6fabf32c --- /dev/null +++ b/packages/solid-table/skills/solid/compose-with-tanstack-form/SKILL.md @@ -0,0 +1,321 @@ +--- +name: solid/compose-with-tanstack-form +description: > + Editable cells in `@tanstack/solid-table` with `@tanstack/solid-form`. The + table is the layout primitive; the form owns the state. Build the form with + `createFormHook` + `createFormHookContexts` to register reusable field + components (`TextField`, `NumberField`, `SelectField`), source `data` from + `form.state.values.data` via a reactive `get data()` getter, and render + `` inside each column's `cell`. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - row-selection + - column-definitions +sources: + - examples/solid/with-tanstack-form/src/App.tsx + - examples/solid/with-tanstack-form/src/form.tsx +--- + +# Compose with `@tanstack/solid-form` + +Editable spreadsheet-style cells. The table doesn't track values; the form +does. The table just lays out which input goes in which cell. + +## Install + +```bash +pnpm add @tanstack/solid-form zod +``` + +## Step 1 — Register field components with `createFormHook` + +```tsx +// form.tsx +import { + createFormHook, + createFormHookContexts, + useStore, +} from '@tanstack/solid-form' +import { For, Show } from 'solid-js' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() + +function TextField() { + const field = useFieldContext() + const errors = useStore(field().store, (s) => s.meta.errors) + return ( +
+ field().handleChange(e.currentTarget.value)} + onBlur={() => field().handleBlur()} + /> + 0}> +
{errors().join(', ')}
+
+
+ ) +} + +function NumberField() { + const field = useFieldContext() + const errors = useStore(field().store, (s) => s.meta.errors) + return ( +
+ field().handleChange(Number(e.currentTarget.value))} + onBlur={() => field().handleBlur()} + /> + 0}> +
{errors().join(', ')}
+
+
+ ) +} + +function SelectField() { + const field = useFieldContext() + return ( + + ) +} + +function SubmitButton(props: { label: string }) { + const form = useFormContext() + return ( + + ) +} + +export const { useAppForm } = createFormHook({ + fieldComponents: { TextField, NumberField, SelectField }, + formComponents: { SubmitButton }, + fieldContext, + formContext, +}) +``` + +`field` is itself an accessor in `@tanstack/solid-form` — it's `field().state.value`, +not `field.state.value`. Same accessor-call pattern as table state. + +## Step 2 — Build the table with form-driven cells + +The trick: `get data()` reads from `form.state.values.data`. The form owns the +rows; the table reflects them. + +```tsx +import { + createTable, + createColumnHelper, + rowPaginationFeature, + columnFilteringFeature, + createPaginatedRowModel, + createFilteredRowModel, + filterFns, + tableFeatures, + FlexRender, +} from '@tanstack/solid-table' +import { z } from 'zod' +import { createMemo, For } from 'solid-js' +import { useAppForm } from './form' + +const _features = tableFeatures({ + rowPaginationFeature, + columnFilteringFeature, +}) + +const columnHelper = createColumnHelper() + +function App() { + const form = useAppForm(() => ({ + defaultValues: { data: makeData(100) }, + onSubmit: ({ value }) => console.log(value), + validators: { onChange: formSchema }, + })) + + // columns depend on `form` (reactive). Wrap in createMemo for stable identity per inputs. + const columns = createMemo(() => + columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + columnHelper.accessor('age', { + header: 'Age', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + columnHelper.accessor('status', { + header: 'Status', + cell: ({ row }) => ( + + {(field) => } + + ), + }), + ]), + ) + + const table = createTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + get columns() { + return columns() + }, + get data() { + return form.state.values.data + }, + }) + + return ( +
{ + e.preventDefault() + void form.handleSubmit() + }} + > + + + + {(hg) => ( + + + {(h) => ( + + )} + + + )} + + + + + {(row) => ( + + + {(c) => ( + + )} + + + )} + + +
+ +
+ +
+ + + +
+ ) +} +``` + +## Why this layering works + +- **`form.state.values.data` is the source of truth.** Editing a `` calls + `field().handleChange(...)`, which mutates the form's `data` array. +- **`get data() { return form.state.values.data }`** subscribes the table to + the form. Each keystroke flows: input → form store → `data()` accessor → table → new row model → `` repaints only what changed (Solid's fine-grained reactivity). +- **The table doesn't know about editing.** No `editingRowId` state, no inline + cell mode toggles. Each row index is always editable; the column `cell` + renderer chooses what to draw. + +## Adding rows + +```tsx +const addRow = () => form.pushFieldValue('data', emptyPerson()) +const removeRow = (i: number) => form.removeFieldValue('data', i) +``` + +Combined with `manualPagination: false` (the default), the table picks up the +new row automatically. + +## Combining with row-selection + +If you want "delete selected rows": + +1. Register `rowSelectionFeature` in `_features`. +2. Add a checkbox display column. Use `row.getIsSelected()` / `row.getToggleSelectedHandler()`. +3. On delete: read `table.getSelectedRowModel().rows`, find each `row.index`, call `form.removeFieldValue('data', index)` (highest index first to avoid shifting). + +## Failure modes + +### CRITICAL — `field.state.value` instead of `field().state.value` + +`field` is an accessor. Call it. Same trap as `table.state()`. + +### CRITICAL — `data: form.state.values.data` without the getter + +Same Solid pitfall as everywhere else. Reads once at construction. Use a +getter so edits flow through. + +### HIGH — `columns` array not memoized when it references `form` + +`columns` literally embeds `` per column. If `columns` is a +plain array re-evaluated per render (or per data change), the table sees a new +columns identity and recomputes column metadata. Wrap in `createMemo`. + +### HIGH — using row identity that doesn't survive add/remove + +If you set `getRowId: (row, index) => String(index)`, then deleting row 0 turns +old row 1 into new row 0 — the input that was focused jumps to a different +person. Either accept that, or give each person a stable id (`row.id`) and +`getRowId: (row) => row.id`. + +### MEDIUM — focus lost on every keystroke + +Almost always caused by inline-defined cell components whose identity changes +each render. Define `TextField` / `NumberField` / `SelectField` once (via +`createFormHook`), reference them via `field.TextField`, and let +`` own the field lifecycle. The example pattern handles this. + +### MEDIUM — re-running validation on every cell render + +`` should reference a +schema that has stable identity. Inline `z.string().min(1)` per render creates +a new schema each time; pull it to module scope or memoize. + +### LOW — submitting with stale rows + +Filtering or sorting affects what the table **displays**, not what the form +**holds**. `form.handleSubmit()` submits all rows in `form.state.values.data` +regardless of the table's current filter. If you only want visible rows, map +through `table.getRowModel().rows` and pull their indexes. diff --git a/packages/solid-table/skills/solid/compose-with-tanstack-pacer/SKILL.md b/packages/solid-table/skills/solid/compose-with-tanstack-pacer/SKILL.md new file mode 100644 index 0000000000..84707ea9c4 --- /dev/null +++ b/packages/solid-table/skills/solid/compose-with-tanstack-pacer/SKILL.md @@ -0,0 +1,207 @@ +--- +name: solid/compose-with-tanstack-pacer +description: > + Debounce or throttle high-frequency writes that drive a `@tanstack/solid-table` + with `@tanstack/solid-pacer`. Typical targets: column filter inputs (debounce + text → `column.setFilterValue`) and column-resize state (throttle pointer + moves). Pattern: wrap the setter in `createDebouncer`/`createThrottler` (or + use the matching hook), call it from input handlers; the table's atoms still + drive the row model. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - filtering + - column-layout +sources: + - examples/solid/filters/ + - examples/solid/column-resizing-performant/ +--- + +# Compose with `@tanstack/solid-pacer` + +`@tanstack/solid-pacer` rate-limits writes. The two table use cases that pay +back the most: + +1. **Filter inputs** — every keystroke triggers a full filter pass. Debounce + the call to `column.setFilterValue` (or to your filter atom's setter). +2. **Column resizing** — drag events fire dozens per second. Throttle the + `columnSizing` write so the row model doesn't re-derive on every pointer + move. + +## Install + +```bash +pnpm add @tanstack/solid-pacer +``` + +## Pattern 1 — Debounced column filter + +```tsx +import { + createTable, + createColumnHelper, + columnFilteringFeature, + createFilteredRowModel, + filterFns, + tableFeatures, + FlexRender, +} from '@tanstack/solid-table' +import { useDebouncedCallback } from '@tanstack/solid-pacer/debouncer' +import { For, createSignal } from 'solid-js' + +const _features = tableFeatures({ columnFilteringFeature }) +const columnHelper = createColumnHelper() + +function FirstNameFilter(props: { + column: Column +}) { + // Local input value updates immediately (good UX). + const [text, setText] = createSignal('') + + // Debounced commit to the table's filter state (expensive — runs after 250ms idle). + const commit = useDebouncedCallback( + (value: string) => props.column.setFilterValue(value), + { wait: 250 }, + ) + + return ( + { + const v = e.currentTarget.value + setText(v) + commit(v) + }} + placeholder="Filter first name..." + /> + ) +} + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: ({ header }) => , + cell: (info) => info.getValue(), + }), + // ... +]) + +function App() { + const [data] = createSignal(makeData(10_000)) + + const table = createTable({ + _features, + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, + get data() { + return data() + }, + }) + + return {/* ... */}
+} +``` + +The local `text()` signal gives the input its responsiveness; the debounced +`commit` keeps the row model from rebuilding on every keystroke. + +### Variant: debounce an external filter atom + +If you've lifted filters into a `@tanstack/solid-store` atom, debounce the +atom write instead: + +```tsx +import { createAtom } from '@tanstack/solid-store' + +const filtersAtom = createAtom([]) + +const commitFilter = useDebouncedCallback( + (id: string, value: string) => { + filtersAtom.set((old) => { + const others = old.filter((f) => f.id !== id) + return value ? [...others, { id, value }] : others + }) + }, + { wait: 250 }, +) +``` + +Combine with `manualFiltering: true` for server-driven filters — the debounce +also gates the network request. + +## Pattern 2 — Throttled column resize + +`columnResizingFeature` drives state updates while the user drags. With many +columns this can cause noticeable lag on slower machines. + +```tsx +import { useThrottledCallback } from '@tanstack/solid-pacer/throttler' + +const table = createTable({ + _features: tableFeatures({ columnSizingFeature, columnResizingFeature }), + _rowModels: {}, + columns, + get data() { + return data() + }, + columnResizeMode: 'onChange', // or 'onEnd' for the simplest perf win +}) + +// Throttle programmatic columnSizing writes (e.g. when synced from a hot atom) +const setColumnSizing = useThrottledCallback( + (sizing: ColumnSizingState) => table.setColumnSizing(sizing), + { wait: 16 }, // ~60fps +) +``` + +If you can accept committing only at the end of the drag, just set +`columnResizeMode: 'onEnd'` — that's a free speedup without pacer at all. + +## When NOT to use pacer + +- **Pagination, sort.** Click-driven, low-frequency. No debounce needed. +- **Row selection.** Click-driven. No debounce. +- **Global filter that's already server-side and uses TanStack Query.** If the + query has its own `staleTime`/debounce hooked up upstream, double-throttling + hurts. +- **Tiny client-side tables.** A 100-row table re-filters in <1ms; debouncing + introduces a perceived lag for no measurable gain. + +## Failure modes + +### CRITICAL — debouncing the source of truth instead of the commit + +If you debounce the _input value_ signal itself, the input feels laggy +(characters trail). Debounce the **commit** (`setFilterValue` / atom set), not +the local input signal. Keep input echo immediate; throttle the work. + +### HIGH — recreating the debounced function per render + +Solid components don't re-render in the React sense, but if you call +`useDebouncedCallback` inside a `` body or a derived JSX expression you +may create new debouncers per row. Keep them at component scope, alongside +`createSignal`. + +### HIGH — debounce wait larger than user's expectation + +250ms is the typical sweet spot for filter inputs. 1000ms feels broken. +50ms gives almost no benefit. Tune to your dataset size. + +### MEDIUM — throttling the wrong layer for resize + +Setting `columnResizeMode: 'onEnd'` already accomplishes what most resize +throttling tries to do. Reach for `useThrottledCallback` only if you need +during-drag visual sync at a lower rate (e.g. virtualized layouts). + +### MEDIUM — debounce on a manual-pagination/query path adds to network jitter + +If `useQuery` is keyed on `filtersAtom` and you debounce the atom write, the +network request waits for the debounce. That's usually desirable, but be +explicit about the total latency budget (debounce + fetch). + +### LOW — using a debouncer where `untrack` is sufficient + +If your problem is "this computation runs too often inside an effect", reach +for Solid's `untrack` or restructure the effect first. Pacer is for +user-driven event streams, not for fixing Solid graph mistakes. diff --git a/packages/solid-table/skills/solid/compose-with-tanstack-query/SKILL.md b/packages/solid-table/skills/solid/compose-with-tanstack-query/SKILL.md new file mode 100644 index 0000000000..ef49af9a4f --- /dev/null +++ b/packages/solid-table/skills/solid/compose-with-tanstack-query/SKILL.md @@ -0,0 +1,291 @@ +--- +name: solid/compose-with-tanstack-query +description: > + Server-side data flow for `@tanstack/solid-table` with `@tanstack/solid-query`. + Lift pagination/sort/filter into atoms (`createAtom` + `useSelector`), key + `useQuery` on those accessors, use `keepPreviousData` to avoid the "0 rows + flash", set `manualPagination` (etc.) on the table, supply `data.rows` via a + reactive `get data()` getter, and feed `data.rowCount` via `get rowCount()`. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - solid/client-to-server + - pagination + - state-management +sources: + - examples/solid/with-tanstack-query/src/App.tsx + - examples/solid/with-tanstack-query/src/fetchData.ts + - docs/framework/solid/guide/table-state.md +--- + +# Compose with `@tanstack/solid-query` + +`@tanstack/solid-query` (`useQuery`, `keepPreviousData`) is the canonical async +fetcher for a server-driven Solid table. The Solid example +`examples/solid/with-tanstack-query/` is the reference pattern. + +## Install + +```bash +pnpm add @tanstack/solid-query @tanstack/solid-store +``` + +Wrap your app in `` once at the root. The table itself +never imports from `@tanstack/solid-query` — it just sees the rows. + +## Pattern + +```tsx +import { keepPreviousData, useQuery } from '@tanstack/solid-query' +import { createAtom, useSelector } from '@tanstack/solid-store' +import { + createTable, + createColumnHelper, + rowPaginationFeature, + tableFeatures, + FlexRender, + type PaginationState, +} from '@tanstack/solid-table' +import { For } from 'solid-js' + +const _features = tableFeatures({ rowPaginationFeature }) +const columnHelper = createColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First Name' }), + columnHelper.accessor('lastName', { header: 'Last Name' }), + columnHelper.accessor('age', { header: 'Age' }), +]) + +// Module-scope atom — every component that needs pagination subscribes here. +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const defaultRows: Array = [] + +function App() { + const pagination = useSelector(paginationAtom) + + // 1. Query keyed on the atom's accessor. Solid Query re-runs on changes. + const dataQuery = useQuery(() => ({ + queryKey: ['people', pagination()], + queryFn: () => fetchData(pagination()), + placeholderData: keepPreviousData, + })) + + // 2. Hand server data + rowCount to the table via getters. + const table = createTable({ + _features, + _rowModels: {}, // no client-side paginatedRowModel; server already paged + columns, + get data() { + return dataQuery.data?.rows ?? defaultRows + }, + get rowCount() { + return dataQuery.data?.rowCount + }, + atoms: { pagination: paginationAtom }, + manualPagination: true, + }) + + return ( +
+ + + + {(hg) => ( + + + {(h) => ( + + )} + + + )} + + + + + {(row) => ( + + + {(c) => ( + + )} + + + )} + + +
+ +
+ +
+ + + + + Page {pagination().pageIndex + 1} of {table.getPageCount()} + + {dataQuery.isFetching ? Loading... : null} +
+ ) +} +``` + +## Why each piece is the way it is + +- **`queryKey: ['people', pagination()]`** — the function-form `useQuery(() => ({ ... }))` is reactive. Calling `pagination()` inside it tracks the atom. When the user clicks "next page", the table writes to the atom, the query key changes, Solid Query fetches. +- **`placeholderData: keepPreviousData`** — without this, switching pages shows the loading state with zero rows, then "pops" to the next page. With it, the previous page stays visible until the new page resolves. +- **`atoms.pagination`** — sharing the atom between the query (read) and the table (read+write) is what wires the two together. No event bus, no `useEffect`. +- **`manualPagination: true`** — tells the table not to slice rows. The server already did. +- **No `paginatedRowModel`** — no factory needed for the manual slice. `_rowModels: {}` is correct. +- **`rowCount`** — necessary so `table.getPageCount()`, `getCanNextPage()`, and `lastPage()` know the true total. Without it the table only sees one page of rows. + +## Adding sorting and filtering + +Same pattern. Lift each slice to its own atom, set the matching `manual*`, key +the query on every atom you depend on. + +```tsx +const sortingAtom = createAtom([]) +const filtersAtom = createAtom([]) +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) + +const sorting = useSelector(sortingAtom) +const filters = useSelector(filtersAtom) +const pagination = useSelector(paginationAtom) + +const dataQuery = useQuery(() => ({ + queryKey: [ + 'people', + { sorting: sorting(), filters: filters(), pagination: pagination() }, + ], + queryFn: () => + fetchData({ + sorting: sorting(), + filters: filters(), + pagination: pagination(), + }), + placeholderData: keepPreviousData, +})) + +const table = createTable({ + _features: tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + }), + _rowModels: {}, + columns, + get data() { + return dataQuery.data?.rows ?? defaultRows + }, + get rowCount() { + return dataQuery.data?.rowCount + }, + atoms: { + sorting: sortingAtom, + columnFilters: filtersAtom, + pagination: paginationAtom, + }, + manualSorting: true, + manualFiltering: true, + manualPagination: true, +}) +``` + +## Mutations + cache invalidation + +```tsx +import { useMutation, useQueryClient } from '@tanstack/solid-query' + +const queryClient = useQueryClient() + +const deleteRow = useMutation(() => ({ + mutationFn: (id: string) => fetch(`/api/people/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['people'] }), +})) +``` + +The table's atoms don't change; the query simply refetches with the same key. + +## Failure modes + +### CRITICAL — forgot `rowCount` + +Without `rowCount`, `table.getPageCount()` returns 1 (it only sees one page's +worth of rows). Pagination controls misbehave. + +### CRITICAL — forgot `manualPagination` (and friends) + +If you don't set `manualPagination: true`, the table will try to paginate the +already-paginated server response (slicing the single page further). Same trap +for `manualSorting` / `manualFiltering`. + +### HIGH — query key doesn't include the slices that drive the request + +If `queryKey: ['people']` is static but pagination changes, the cache returns +the same page forever. Always include every atom value (or the whole filter +object) in the key. + +### HIGH — `data: dataQuery.data?.rows ?? []` without a stable empty fallback + +A fresh `[]` on every read changes the `data` identity → row models recompute. +Module-scope `const EMPTY: Array = []` and use `?? EMPTY`. + +### HIGH — `placeholderData` left off + +Without `keepPreviousData`, every page change blanks the table while the next +page loads. Almost always a regression UX. + +### MEDIUM — reading `dataQuery.data?.rows` directly without the getter + +```tsx +// ❌ Snapshots once +createTable({ /* ... */ data: dataQuery.data?.rows ?? EMPTY }) + +// ✅ +createTable({ + /* ... */ get data() { + return dataQuery.data?.rows ?? EMPTY + }, +}) +``` + +### MEDIUM — `autoResetPageIndex` on + +Default behavior resets `pageIndex` to 0 when `data` reference changes. With a +server-driven pagination atom you usually don't want that. + +```tsx +createTable({ /* ... */ autoResetPageIndex: false }) +``` + +### MEDIUM — calling `useQuery` with a static object + +`useQuery({...})` (static form) doesn't track Solid signals. Use the function +form: `useQuery(() => ({...}))`. This is a Solid Query API rule, not a table +issue, but it's the most common breakage. + +### LOW — `getSelectedRowModel` only walks loaded rows + +Under server-side pagination, the table only knows about the current page. +"Select all" across pages must be tracked separately (e.g. a "select-all-mode" +atom + a list of explicit exclusions). diff --git a/packages/solid-table/skills/solid/compose-with-tanstack-store/SKILL.md b/packages/solid-table/skills/solid/compose-with-tanstack-store/SKILL.md new file mode 100644 index 0000000000..b8ca762734 --- /dev/null +++ b/packages/solid-table/skills/solid/compose-with-tanstack-store/SKILL.md @@ -0,0 +1,241 @@ +--- +name: solid/compose-with-tanstack-store +description: > + Use `@tanstack/solid-store` (`createAtom`, `useSelector`, `shallow`) with + `@tanstack/solid-table` v9. The table is built on TanStack Store: every state + slice is an atom. Three read surfaces — `table.atoms.` (per-slice + readonly memo), `table.store` (flat readonly), and `table.state()` (Solid + accessor from the selector). Own slices externally by passing + `atoms: { sorting: someAtom }`. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - state-management + - solid/table-state +sources: + - docs/framework/solid/guide/table-state.md + - packages/solid-table/src/createTable.ts + - packages/solid-table/src/reactivity.ts + - examples/solid/basic-external-atoms/ +--- + +# Compose with `@tanstack/solid-store` + +v9 is built on TanStack Store. The Solid adapter installs `solidReactivity` so +readonly atoms are `createMemo` and writable atoms are `createSignal` under the +hood. Everything Solid-Store offers — atoms, selectors, shallow comparison — +works directly with the table's state surfaces. + +## The three read surfaces + +| Surface | Shape | Use for | +| --------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `table.atoms.` | `ReadonlyAtom` (memo-backed). `.get()` for current value, `.subscribe(fn)` for raw subscription | Per-slice fine-grained reads, especially with `useSelector`. | +| `table.store` | Readonly flat store; `.state.` for current value | Snapshot reads, debugging, JSON-dumps. | +| `table.state()` | `Accessor` — call it | Solid-idiomatic reactive read of the selector projection. | + +`table.baseAtoms.` exists too — those are the writable internal signals. +Reach for them only if you genuinely need a low-level write; prefer the table's +feature APIs (`table.setSorting(...)`, `table.nextPage()`, etc.). + +## Reading slices reactively + +### Native: just read `table.state()` or `table.atoms..get()` in a tracked scope + +```tsx +// JSX — automatically reactive +Selected: {Object.keys(table.atoms.rowSelection.get()).length} +Page {table.state().pagination.pageIndex + 1} + +// createMemo — also tracked +const pageCount = createMemo(() => table.getPageCount()) +``` + +### `useSelector` from `@tanstack/solid-store` + +`useSelector` returns a Solid accessor with shallow equality by default. + +```tsx +import { useSelector } from '@tanstack/solid-store' + +const pagination = useSelector(table.atoms.pagination) +// pagination is Accessor +const pageIndex = () => pagination().pageIndex +``` + +Narrow further with a selector + optional `shallow`: + +```tsx +import { shallow, useSelector } from '@tanstack/solid-store' + +const visibleColumnIds = useSelector( + table.store, + (s) => + Object.keys(s.columnVisibility ?? {}).filter( + (id) => s.columnVisibility[id] !== false, + ), + { compare: shallow }, +) +``` + +### `table.Subscribe` + +When you want an explicit isolation boundary: + +```tsx + s.rowSelection}> + {(rowSelection) => ( + + )} + +``` + +With a `source`: + +```tsx + !!rs[row.id]} +> + {(isSelected) => ( + + )} + +``` + +## Owning a slice externally with `createAtom` + +This is the recommended cross-component / cross-app pattern in v9. + +```tsx +import { createAtom, useSelector } from '@tanstack/solid-store' +import { + createTable, + type PaginationState, + type SortingState, +} from '@tanstack/solid-table' + +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = createAtom([]) + +const pagination = useSelector(paginationAtom) +const sorting = useSelector(sortingAtom) + +const table = createTable({ + _features, + _rowModels: { + /* ... */ + }, + columns, + get data() { + return data() + }, + atoms: { + pagination: paginationAtom, + sorting: sortingAtom, + }, +}) +``` + +When `atoms.` is provided: + +- The table reads/writes the **external** atom for that slice. +- `table.atoms.` is a readonly derived view over the external atom. +- Don't also supply `state.` + `on[State]Change` for the same slice; + the atom wins. +- `table.setSorting(...)` etc. still work — they call through to the external + atom's setter. + +### Mutating the atom directly + +You don't need `table.setSorting(...)` to update — write to the atom from +anywhere: + +```tsx +paginationAtom.set((old) => ({ ...old, pageIndex: 0 })) +sortingAtom.set([{ id: 'lastName', desc: true }]) +``` + +This is what makes external atoms valuable: a "Clear filters" button in the +page header or a URL-sync effect can talk to the same atom without holding a +reference to the table. + +## Pattern: per-slice readers in other components + +```tsx +// pagination.ts +import { createAtom, useSelector } from '@tanstack/solid-store' +export const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +export const usePagination = () => useSelector(paginationAtom) + +// PageStatus.tsx — no `table` reference at all +import { usePagination } from './pagination' +export function PageStatus() { + const pagination = usePagination() + return Page {pagination().pageIndex + 1} +} + +// UsersTable.tsx +const table = createTable({ + _features, + _rowModels, + columns, + get data() { + return data() + }, + atoms: { pagination: paginationAtom }, +}) +``` + +## When to use atoms vs. `state`+`on*Change` + +| Use atoms when | Use `state`+`on*Change` when | +| -------------------------------------- | ----------------------------------------------------- | +| Sharing the slice across components | One component owns the slice | +| Driving a server fetcher (Query) | Local UI-only state | +| Syncing with URL params / storage | Migrating from v8 | +| You want one source of truth per slice | You want a Solid signal pattern with explicit setters | + +Both work. Atoms are more atomic; `state`+`on*Change` is more familiar. + +## Failure modes + +### CRITICAL — `table.state` treated as a value + +`table.state` is an Accessor in Solid. Call it. Same caveat applies if you used +`useSelector(table.atoms.pagination)` — the return is also an accessor. + +### CRITICAL — supplying both `atoms.pagination` and `state.pagination` + +Both surfaces compete for the same slice. The atom silently wins; the `state` +`on*Change` pair is ignored. Pick one. + +### HIGH — re-creating atoms in render + +`createAtom(...)` inside a component creates a new atom every call. Atoms must +be module-scoped (or memoized at component creation, never inside a JSX render +expression). + +### MEDIUM — passing `table.store` to `useSelector` without a selector + +`useSelector(table.store)` works but subscribes to the whole flat store — +shallow-equality on a big object is wasted work. Pass a selector to narrow. + +### MEDIUM — confusing `table.atoms` with `table.baseAtoms` + +`table.atoms.` is the **readonly** outward-facing atom (memo-backed) — +even when an external atom is supplied, this is what consumers read. +`table.baseAtoms.` is the internal writable signal used when the table +owns the slice. Writes should go through feature APIs or the external atom you +own; not through `baseAtoms` unless you have a specific reason. diff --git a/packages/solid-table/skills/solid/compose-with-tanstack-virtual/SKILL.md b/packages/solid-table/skills/solid/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..2e2103eadb --- /dev/null +++ b/packages/solid-table/skills/solid/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,284 @@ +--- +name: solid/compose-with-tanstack-virtual +description: > + Virtualize a `@tanstack/solid-table` with `@tanstack/solid-virtual`'s + `createVirtualizer`. Take rows from `table.getRowModel().rows`, feed + `() => rows().length` as a reactive `count`, render only the visible rows + inside `` with absolute positioning + a `translateY` per row. Keep + the virtualizer in the same component as the scroll container ref. +type: composition +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - solid/table-state + - row-expanding +sources: + - docs/guide/virtualization.md + - examples/solid/virtualized-rows/src/App.tsx + - examples/solid/virtualized-columns/ + - examples/solid/virtualized-infinite-scrolling/ +--- + +# Compose with `@tanstack/solid-virtual` + +TanStack Table does **not** include virtualization. For >>visible-row datasets, +pair with `@tanstack/solid-virtual`'s `createVirtualizer`. The table still +manages every row model (sort, filter, group, expand); the virtualizer only +decides which rows to **paint**. + +## Install + +```bash +pnpm add @tanstack/solid-virtual +``` + +## Reference pattern (dynamic row height) + +```tsx +import { + createTable, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + tableFeatures, + FlexRender, + type Row, + type SolidTable, +} from '@tanstack/solid-table' +import { + createVirtualizer, + type VirtualItem, + type Virtualizer, +} from '@tanstack/solid-virtual' +import { For, createSignal } from 'solid-js' + +const _features = tableFeatures({ columnSizingFeature, rowSortingFeature }) + +function App() { + const [data] = createSignal(makeData(200_000)) + + const table = createTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + get data() { + return data() + }, + }) + + return +} + +// Keep the virtualizer + scroll container ref in the SAME component. +function VirtualizedTable(props: { + table: SolidTable +}) { + let tableContainerRef: HTMLDivElement | undefined + + const rows = () => props.table.getRowModel().rows + + const rowVirtualizer = createVirtualizer( + { + get count() { + return rows().length + }, + estimateSize: () => 33, + getScrollElement: () => tableContainerRef ?? null, + // Optional: measure for dynamic row height + measureElement: + typeof window !== 'undefined' && + navigator.userAgent.indexOf('Firefox') === -1 + ? (el) => el.getBoundingClientRect().height + : undefined, + overscan: 5, + }, + ) + + return ( +
+ + + + {(hg) => ( + + + {(header) => ( + + )} + + + )} + + + + + {(virtualRow) => ( + + )} + + +
+ +
+
+ ) +} + +function TableBodyRow(props: { + row: Row + virtualRow: VirtualItem + rowVirtualizer: Virtualizer + table: SolidTable +}) { + return ( + props.rowVirtualizer.measureElement(node)} + style={{ + display: 'flex', + position: 'absolute', + transform: `translateY(${props.virtualRow.start}px)`, + width: '100%', + }} + > + + {(cell) => ( + + + + )} + + + ) +} +``` + +## Why the structure looks like this + +- **`display: grid` / `display: flex` on `table`/`thead`/`tbody`/`tr`.** A + classic semantic `` cannot do absolute-positioned rows. CSS grid + + flex preserves the tag tree while letting the browser absolutely position + rows inside the body. +- **`height: rowVirtualizer.getTotalSize()` on ``.** Makes the + scrollbar correctly sized for the **virtual** dataset, not just the painted + rows. +- **`translateY(virtualRow.start)` per row.** Each rendered row jumps to its + virtual position. +- **`ref={(node) => rowVirtualizer.measureElement(node)}`** on each row. + Required for dynamic heights. If your rows are fixed-height, you can drop + `measureElement` and the ref. + +## Reactive `count` is critical + +```tsx +// ❌ Captures length once +createVirtualizer({ count: rows().length /* ... */ }) + +// ✅ Reactive — virtualizer re-derives when rows() changes +createVirtualizer({ + get count() { + return rows().length + } /* ... */, +}) +``` + +Same applies to `getScrollElement: () => tableContainerRef ?? null` (function), +not `getScrollElement: tableContainerRef` (snapshot). + +## Component scoping + +Keep `createVirtualizer` in the lowest component that owns the scroll +container. Putting it high in the tree means every scroll event recomputes +ancestor components. + +The example pulls `TableBodyRow` out into its own component for the same +reason — narrow re-render boundary. + +## Virtualized columns + +Same idea for wide tables: virtualize across `table.getVisibleLeafColumns()`. +See `examples/solid/virtualized-columns/`. The pattern is identical: +`count = columns.length`, `estimateSize = (i) => columns[i].getSize()`, +position cells with `translateX`. + +## Combining with `row-expanding` + +When `rowExpandingFeature` is registered and a row has subrows, the row count +the virtualizer sees is the **flattened** count +(`table.getRowModel().rows.length`, which already includes expanded subrows). +You don't need to compute that manually. + +For variable subrow heights, keep `measureElement` so the virtualizer +remeasures after expand/collapse. + +## Failure modes + +### CRITICAL — virtualizer scoped above the scroll container + +If `createVirtualizer` lives in a parent component, the ref-as-`undefined` +trick in the example only works because Solid evaluates the ref binding before +the JSX returns. Moving it up breaks that. Keep them together. + +### CRITICAL — `count` not reactive + +`count: rows().length` reads once. Use `get count() { return rows().length }`. +Same for `getScrollElement` — it must be a function. + +### HIGH — missing `height: rowVirtualizer.getTotalSize()` on `` + +Without it, the scrollbar is sized only for the painted rows. User scrolls +once and hits the bottom of "the table" even though 99% of rows are off-screen. + +### HIGH — virtualized rows with `display: table-row` + +A semantic `` with `display: table-row` cannot be absolutely positioned. +Use `display: flex` on `` and `display: grid` on `` (as shown). + +### MEDIUM — `measureElement` left enabled in Firefox + +Firefox measures table border heights incorrectly. The example guards this +with a userAgent check — keep that guard for cross-browser correctness. + +### MEDIUM — over-eager `overscan` + +Default `overscan` of 5 is usually fine. Cranking it to 50 paints more rows +than necessary; cranking it to 0 causes blank flashes at scroll boundaries. + +### MEDIUM — reading `table.state()` inside the virtualized row + +Each painted row that calls `table.state()` subscribes to the selected state. +If your row only needs `column.getSize()` and `cell.getValue()`, don't read +state at all — they are already tracking their own atoms. + +### LOW — column sizing without `columnSizingFeature` registered + +The example uses `header.getSize()` and `cell.column.getSize()`, which require +`columnSizingFeature` in `_features`. Without it those APIs are missing. diff --git a/packages/solid-table/skills/solid/getting-started/SKILL.md b/packages/solid-table/skills/solid/getting-started/SKILL.md new file mode 100644 index 0000000000..5fef4dbb96 --- /dev/null +++ b/packages/solid-table/skills/solid/getting-started/SKILL.md @@ -0,0 +1,269 @@ +--- +name: solid/getting-started +description: > + End-to-end first table with `@tanstack/solid-table` v9. Install, declare + `_features` via `tableFeatures()`, declare `_rowModels` with the matching + factories (e.g. `createSortedRowModel(sortFns)`), create a column helper + with `createColumnHelper()`, build the table with + `createTable(options)` using reactive `get data() {...}` getters, and render + rows via `FlexRender` (or `table.FlexRender`). +type: lifecycle +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - setup + - column-definitions + - state-management + - solid/table-state +sources: + - docs/installation.md + - docs/framework/solid/solid-table.md + - docs/framework/solid/guide/table-state.md + - packages/solid-table/src/createTable.ts + - examples/solid/basic-use-table/ + - examples/solid/basic-app-table/ +--- + +# Getting Started — `@tanstack/solid-table` + +A working Solid table from a clean install. The five steps below cover every +concept you will need before reaching for feature-specific skills. + +## 1. Install + +```bash +pnpm add @tanstack/solid-table +# or +npm install @tanstack/solid-table +``` + +`@tanstack/solid-table` re-exports everything from `@tanstack/table-core` plus +the Solid-specific `createTable`, `FlexRender`, and `createTableHook`. You +should not install `@tanstack/table-core` separately. + +## 2. Declare features (`_features`) + +v9 is explicit about what a table uses. Only registered features expose APIs +and state slices. This is what makes the bundle tree-shake. + +```tsx +import { + tableFeatures, + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +} from '@tanstack/solid-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +}) +``` + +`tableFeatures()` is **essential** — it produces a stable typed `TFeatures` +object used everywhere (column helper, table options, etc.). Use it even for a +no-feature table: `tableFeatures({})`. + +## 3. Declare row-model factories (`_rowModels`) + +Each non-core row-model feature needs its factory called and registered. The +core row model is included by default. + +```tsx +import { + createPaginatedRowModel, + createSortedRowModel, + createFilteredRowModel, + sortFns, + filterFns, +} from '@tanstack/solid-table' + +const _rowModels = { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), +} +``` + +`sortFns` and `filterFns` are the bundled `*Fns` registries. Pass only the +registry you need — these are tree-shakeable too. You may also pass a narrowed +object like `{ alphanumeric: sortFns.alphanumeric }`. + +## 4. Define columns + +`createColumnHelper` takes **both** generics: `typeof _features` first, then +`TData`. (This is the v9 ordering. v8 only had `TData`.) + +```tsx +import { createColumnHelper } from '@tanstack/solid-table' + +type Person = { firstName: string; lastName: string; age: number } + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: () => Last Name, + }), + columnHelper.accessor('age', { header: 'Age' }), +]) +``` + +`columnHelper.columns([...])` preserves each column's individual `TValue` type. +Prefer it over a bare array. + +## 5. Create the table and render + +```tsx +import { createTable, FlexRender } from '@tanstack/solid-table' +import { For, createSignal } from 'solid-js' + +function App() { + const [data, setData] = createSignal>([ + { firstName: 'tanner', lastName: 'linsley', age: 24 }, + { firstName: 'kevin', lastName: 'vandy', age: 12 }, + ]) + + const table = createTable({ + _features, + _rowModels, + columns, + get data() { + return data() // reactive getter + }, + }) + + return ( +
+ + + {(hg) => ( + + + {(header) => ( + + )} + + + )} + + + + + {(row) => ( + + + {(cell) => ( + + )} + + + )} + + +
+ {header.isPlaceholder ? null : ( + + )} +
+ +
+ ) +} +``` + +That's a complete v9 Solid table. + +## Alternative entry point: `createTableHook` + +If you have more than one table sharing features and row models, declare them +once with `createTableHook`: + +```tsx +import { createTableHook } from '@tanstack/solid-table' + +const { createAppTable, createAppColumnHelper } = createTableHook({ + _features: tableFeatures({}), + _rowModels: {}, +}) + +const columnHelper = createAppColumnHelper() + +function App(props: { data: Array }) { + const table = createAppTable({ + columns, + get data() { + return props.data + }, + }) + // ... +} +``` + +`createAppTable` returns a normal `SolidTable` plus app-wrapper components +(`AppTable`, `AppCell`, `AppHeader`, `AppFooter`). See the table-state skill. + +## Common pitfalls + +### Forgetting `get` on reactive options + +```tsx +// ❌ Reads once at construction — table never updates when data() changes +createTable({ _features, _rowModels: {}, columns, data: data() }) + +// ✅ Tracked +createTable({ + _features, + _rowModels: {}, + columns, + get data() { + return data() + }, +}) +``` + +Use getters for any option that depends on a reactive source: `data`, dynamic +`columns`, controlled `state` slices, `rowCount`, etc. + +### Calling `table.state` as a value + +`table.state` is an Accessor (a function). Always call it: + +```tsx +// ❌ +table.state.pagination + +// ✅ +table.state().pagination +``` + +### Missing feature → missing API + +If you write `table.setSorting(...)` without `rowSortingFeature` in `_features`, +TS errors and the method is undefined at runtime. The fix is registration, not +a cast. + +### Bundling `stockFeatures` defeats the v9 tree-shake + +Don't import everything. Register only the features you use. A no-feature table +is `tableFeatures({})` — not `stockFeatures`. + +### Reimplementing built-ins + +v9 already exposes `table.setSorting`, `table.nextPage`, `column.toggleVisibility`, +`row.toggleSelected`, `column.setFilterValue`, etc. Reach for the API before +rolling your own state update. + +### Hallucinated names from older versions + +v9 is `createTable`, not `createSolidTable` (that was v8). Row models go under +`_rowModels`, not top-level `getCoreRowModel` / `getSortedRowModel` options. +`createColumnHelper` takes `` (two generics, features +first). diff --git a/packages/solid-table/skills/solid/migrate-v8-to-v9/SKILL.md b/packages/solid-table/skills/solid/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..2013b6df08 --- /dev/null +++ b/packages/solid-table/skills/solid/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,296 @@ +--- +name: solid/migrate-v8-to-v9 +description: > + Mechanical breaking-change migration from `@tanstack/solid-table` v8 to v9. + Renames (`createSolidTable` → `createTable`, `getCoreRowModel`/`getSortedRowModel`/... + → `_rowModels` + factories), new required `_features` registration via + `tableFeatures()`, two-generic `createColumnHelper`, + the v9 atom state model, and the lack of a `/legacy` entrypoint for Solid + (full rewrite, no `useLegacyTable`). +type: lifecycle +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - docs/framework/react/guide/migrating.md + - docs/framework/solid/solid-table.md + - docs/framework/solid/guide/table-state.md + - packages/solid-table/src/createTable.ts +--- + +# Migrate v8 → v9 for `@tanstack/solid-table` + +The Solid adapter has **no `/legacy` entrypoint**. Unlike React (which ships +`useLegacyTable` from `@tanstack/react-table/legacy`), Solid migrations are a +direct rewrite. Plan to do it incrementally per table, not per file. + +## What changed (high-level) + +1. **Adapter API renamed.** `createSolidTable(...)` → `createTable(...)`. +2. **Features must be registered.** v9 introduced `_features` via `tableFeatures({...})`. Without it, feature APIs and state slices don't exist (TS error + runtime undefined). +3. **Row models moved to `_rowModels`.** No more top-level `getCoreRowModel: getCoreRowModel()` options; instead `_rowModels: { paginatedRowModel: createPaginatedRowModel(), ... }`. +4. **State is atom-based.** Powered by TanStack Store. The classic `state`+`on*Change` pattern still works for compatibility, but `atoms` is the new recommended hand-off for per-slice external ownership. +5. **`createColumnHelper` takes two generics.** `createColumnHelper()` (features first). +6. **Some method/option renames.** Most notably `sortingFn` → `sortFn`; the `*Fns` registries (`sortFns`, `filterFns`, `aggregationFns`) are now passed to row-model factories. +7. **No `onStateChange` top-level callback.** Use per-slice `on[State]Change` paired with `state.`, or use `atoms`. + +## Rename map + +| v8 (Solid) | v9 (Solid) | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `createSolidTable(options)` | `createTable(options, selector?)` | +| `getCoreRowModel: getCoreRowModel()` | (core row model included by default; pass `_rowModels: {}`) | +| `getSortedRowModel: getSortedRowModel()` | `_rowModels: { sortedRowModel: createSortedRowModel(sortFns) }` + register `rowSortingFeature` | +| `getFilteredRowModel: getFilteredRowModel()` | `_rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }` + register `columnFilteringFeature` | +| `getPaginationRowModel: getPaginationRowModel()` | `_rowModels: { paginatedRowModel: createPaginatedRowModel() }` + register `rowPaginationFeature` | +| `getGroupedRowModel: getGroupedRowModel()` | `_rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }` + register `columnGroupingFeature` | +| `getExpandedRowModel: getExpandedRowModel()` | `_rowModels: { expandedRowModel: createExpandedRowModel() }` + register `rowExpandingFeature` | +| `getFacetedRowModel` / `getFacetedUniqueValues` / `getFacetedMinMaxValues` | `_rowModels: { facetedRowModel, facetedUniqueValues, facetedMinMaxValues }` + faceting features | +| `createColumnHelper()` | `createColumnHelper()` | +| `sortingFn: 'alphanumeric'` on a column | `sortFn: 'alphanumeric'` on a column | +| `onStateChange` (whole-state) | (gone — use per-slice `on*Change` or `atoms`) | +| `state: { ... }` + `onStateChange` | `state: { ... }` + `on[State]Change`, OR `atoms: { ... }` | +| (no atoms) | `atoms: { sorting: someAtom, pagination: someAtom, ... }` | + +## Before → after + +### v8 Solid table + +```tsx +import { + createSolidTable, + getCoreRowModel, + getSortedRowModel, + getPaginationRowModel, + createColumnHelper, + flexRender, + type SortingState, +} from '@tanstack/solid-table' +import { For, createSignal } from 'solid-js' + +const columnHelper = createColumnHelper() + +const columns = [ + columnHelper.accessor('firstName', { header: 'First Name' }), + columnHelper.accessor('age', { header: 'Age', sortingFn: 'alphanumeric' }), +] + +function App(props: { data: Person[] }) { + const [sorting, setSorting] = createSignal([]) + + const table = createSolidTable({ + get data() { + return props.data + }, + columns, + state: { + get sorting() { + return sorting() + }, + }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + return /* ... flexRender(header.column.columnDef.header, header.getContext()) ... */ +} +``` + +### v9 equivalent + +```tsx +import { + createTable, + FlexRender, + createColumnHelper, + createSortedRowModel, + createPaginatedRowModel, + rowPaginationFeature, + rowSortingFeature, + sortFns, + tableFeatures, + type SortingState, +} from '@tanstack/solid-table' +import { For, createSignal } from 'solid-js' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First Name' }), + columnHelper.accessor('age', { header: 'Age', sortFn: 'alphanumeric' }), +]) + +function App(props: { data: Person[] }) { + const [sorting, setSorting] = createSignal([]) + + const table = createTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + get data() { + return props.data + }, + state: { + get sorting() { + return sorting() + }, + }, + onSortingChange: setSorting, + }) + + // FlexRender component replaces calls to flexRender(def, ctx) + return ( + + {(hg) => ( + + {(header) => ( + + + + )} + + )} + + ) +} +``` + +## Reading state — `table.state()` is now an accessor + +In Solid v9, the `table.state` returned by `createTable` is an **Accessor** — +call it. If you migrated from a non-Solid v8 pattern, double-check this. + +```tsx +// v8 muscle memory: +table.getState().sorting + +// v9 Solid: +table.state().sorting +``` + +`table.getState()` still exists on the table-core surface for parity, but +`table.state()` (the accessor) is the Solid-idiomatic read. + +## State ownership choices in v9 + +- **Default**: Pass `initialState`. Table owns everything. +- **Per-slice signal**: Use `state` getters + `on[State]Change` setters (works with `createSignal`). +- **Per-slice external atom (recommended for cross-component / server-driven)**: + + ```tsx + import { createAtom } from '@tanstack/solid-store' + + const sortingAtom = createAtom([]) + + createTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + get data() { + return data() + }, + atoms: { sorting: sortingAtom }, + }) + ``` + + External atoms take precedence over `state`. Don't supply both for the same slice. + +## Filter / sort / aggregation `*Fns` + +v9 separates the registry (what comparator functions are bundled) from the row +model factory. Pass exactly what you need so the rest tree-shakes. + +```tsx +import { sortFns, filterFns, aggregationFns } from '@tanstack/solid-table' + +createSortedRowModel(sortFns) // all sort fns +createSortedRowModel({ alphanumeric: sortFns.alphanumeric }) // narrowed +createFilteredRowModel(filterFns) +createGroupedRowModel(aggregationFns) +``` + +On columns, the option name is now `sortFn`, `filterFn`, `aggregationFn` +(singular), not `sortingFn` / `filteringFn`. + +## What does NOT change + +- `` style rendering. +- `accessorKey` / `accessorFn` shapes. +- `header`, `cell`, `footer` definitions (modulo using `FlexRender` instead of raw `flexRender`). +- Reactive getters for `data` (you should already be doing this). +- Feature APIs like `table.nextPage`, `column.toggleSorting`, `row.toggleSelected`. + +## Failure modes + +### CRITICAL — `_features` missing → API gone + +The single biggest v8→v9 trap. `table.setSorting` won't exist unless +`rowSortingFeature` is in `_features`. Likewise for every other feature. v8 had +no such requirement. + +### CRITICAL — `getCoreRowModel: getCoreRowModel()` pattern + +v9 has no such option. The core row model is included by default. Move every +`get*RowModel` option to a key under `_rowModels` and call the matching +`create*RowModel()` factory. Don't leave the v8 options in place. + +### CRITICAL — `createColumnHelper()` missing the features generic + +v8: `createColumnHelper()`. v9: `createColumnHelper()`. +Forgetting the first generic gives bad inference and broken cell typing. + +### HIGH — `createSolidTable` no longer exists + +The function is named `createTable` in Solid v9. (`createSolidTable` was a v8 +spelling and is gone.) There is no `useReactTable` analogue on Solid; it's just +`createTable`. + +### HIGH — `sortingFn` / `filteringFn` rename + +Column-level `sortingFn` is now `sortFn`. Filter is `filterFn`. Aggregation is +`aggregationFn`. Old names typecheck-fail. + +### HIGH — top-level `onStateChange` + +There is no whole-state `onStateChange` in v9. Migrate to per-slice +`on[State]Change` paired with `state.`, or move to `atoms`. + +### MEDIUM — `state` shape without getters + +v8 worked with `state: { sorting: sorting() }` if `sorting()` was read in a +tracked scope. v9 still needs reactive getters: `state: { get sorting() { return sorting() } }`. +Plain values disconnect reactivity. + +### MEDIUM — no `/legacy` entrypoint for Solid + +If you're searching for `@tanstack/solid-table/legacy` or `useLegacyTable` (the +React fast-path migration), they don't exist on Solid. There is no escape hatch +that preserves the v8 shape — migrate to v9 directly. + +### MEDIUM — `flexRender(def, ctx)` calls + +Still works, but use the `FlexRender` component for cleaner JSX: + +```tsx +// Old +{ + flexRender(cell.column.columnDef.cell, cell.getContext()) +} + +// New +; +``` diff --git a/packages/solid-table/skills/solid/production-readiness/SKILL.md b/packages/solid-table/skills/solid/production-readiness/SKILL.md new file mode 100644 index 0000000000..4062f9d227 --- /dev/null +++ b/packages/solid-table/skills/solid/production-readiness/SKILL.md @@ -0,0 +1,254 @@ +--- +name: solid/production-readiness +description: > + Ship-ready optimizations for `@tanstack/solid-table` v9. Tree-shake by + registering only the `_features` you use; keep `_features`, `columns`, `data` + stable; prefer per-slice external atoms (`createAtom` + `useSelector`) and + narrow selectors over `(state) => state`; leverage Solid's fine-grained + reactivity (`createMemo`, JSX-level reads) so most components subscribe to + exactly the slice they render; reach for `table.Subscribe` only at coarse + isolation boundaries. +type: lifecycle +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - solid/table-state +sources: + - docs/guide/features.md + - docs/framework/solid/guide/table-state.md + - packages/solid-table/src/createTable.ts + - examples/solid/basic-external-atoms/ + - examples/solid/virtualized-rows/ +--- + +# Production Readiness — `@tanstack/solid-table` + +A v9 table that "works in dev" can still ship slow if you copied the +getting-started shape unchanged into a 10k-row scenario. Solid's fine-grained +reactivity rewards a few production habits. + +## 1. Tree-shake `_features` aggressively + +v9's biggest bundle win. Register only the features you use. + +```tsx +// ❌ Pulls everything; defeats the v9 redesign +import { stockFeatures } from '@tanstack/solid-table' +tableFeatures(stockFeatures) + +// ✅ Only what you use +import { + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +} from '@tanstack/solid-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +}) +``` + +Same rule for the `*Fns` registries: + +```tsx +// Only the comparators you actually use +const sortRegistry = { + alphanumeric: sortFns.alphanumeric, + basic: sortFns.basic, +} +createSortedRowModel(sortRegistry) +``` + +## 2. Keep `_features`, `data`, `columns` stable + +The Solid signal model already protects you from React-style re-creation, but +two patterns still break things: + +- **Re-creating `_features` per render.** Module-scope it. Don't call + `tableFeatures({...})` inside a component. +- **Returning a fresh `[]` on every read.** When `data` is unloaded + (`resource()?.rows ?? []`), the fallback `[]` should be a module-scope + constant so identity is stable. + +```tsx +const _features = tableFeatures({ rowPaginationFeature, rowSortingFeature }) +const EMPTY_ROWS: Array = [] + +function App() { + const table = createTable({ + _features, // stable + _rowModels: { + /* stable factories OK module-scoped */ + }, + columns, // module-scope constant + get data() { + return query.data?.rows ?? EMPTY_ROWS + }, + }) +} +``` + +For `columns` that depend on a reactive source (e.g. a column visibility +preset), wrap them in `createMemo` so identity is stable per inputs: + +```tsx +const columns = createMemo(() => + columnHelper.columns([ + /* ... */ + ]), +) +createTable({ + _features, + _rowModels, + get columns() { + return columns() + }, + get data() { + return data() + }, +}) +``` + +## 3. Prefer narrow selectors over `(state) => state` + +The default `createTable(options)` selector is identity. Anything reading +`table.state()` then depends on every slice. Pass a selector: + +```tsx +const table = createTable( + { + _features, + _rowModels, + columns, + get data() { + return data() + }, + }, + (state) => ({ pagination: state.pagination, sorting: state.sorting }), +) +``` + +For `table.Subscribe` boundaries, the same rule applies — narrow the selector. + +## 4. Trust signal-level reactivity over Subscribe wrappers + +In Solid, a JSX-level read tracks itself. You usually don't need +`table.Subscribe`: + +```tsx +// This updates when pagination changes — fine-grained, no Subscribe needed +Page {table.state().pagination.pageIndex + 1} +``` + +Reach for `table.Subscribe` when: + +- You want an isolated re-render boundary for a large sub-tree. +- You need to subscribe to a single atom/store with `source` (e.g. a specific + `table.atoms.rowSelection` slice for a single row checkbox). + +Not when: + +- It's "what React does." Solid is fine without it most of the time. + +## 5. Use external atoms for cross-component / server-driven state + +`@tanstack/solid-store`'s `createAtom` + `useSelector` is the cleanest pattern +for sharing pagination/sort/filter with TanStack Query, URL params, devtools, +etc. + +```tsx +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 25, +}) + +// In the table: +createTable({ /* ... */ atoms: { pagination: paginationAtom } }) + +// In the URL sync hook elsewhere: +const pagination = useSelector(paginationAtom) +createEffect(() => syncUrl(pagination())) +``` + +This wins over `state`+`on*Change` because every consumer can subscribe to the +atom independently with shallow equality. + +## 6. Virtualize when row count >> visible rows + +TanStack Table does not include virtualization. For 10k+ row tables, pair with +`@tanstack/solid-virtual` (`createVirtualizer`). Keep the virtualizer ref in +the lowest possible component to avoid re-running it on unrelated updates. See +the `compose-with-tanstack-virtual` skill. + +## 7. Use APIs, not handwritten state edits + +The #1 AI tell in v9 code: reimplementing what's already there. Examples: + +| ❌ Handwritten | ✅ Built-in | +| ------------------------------------------------------------------ | ------------------------------------------------------ | +| `setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))` | `table.nextPage()` | +| Recomputing `selectedRows` from `data` and a `rowSelection` object | `table.getSelectedRowModel().rows` | +| Building a "can sort this column?" predicate | `column.getCanSort()` | +| Manual range `setPageIndex(Math.min(...))` clamping | `table.setPageIndex(idx)` (table clamps internally) | +| Tracking expanded ids in a parallel structure | `row.toggleExpanded()` / `table.getExpandedRowModel()` | + +If you find yourself recomputing something the table tracks, look for the +matching API. + +## 8. Use `tableFeatures()` even if you only need one feature + +`tableFeatures()` is the essential, stable wiring for v9. Always go through it. +It's not the "experimental" part — that's only custom-feature **authoring**, +which is excluded from v9 alpha. + +## Failure modes + +### CRITICAL — registering features you don't use + +Every registered feature adds state slices, derivations, and code to the +bundle. Keep `_features` minimal. + +### CRITICAL — recreating `_features` / `columns` / `data` identity on every render + +Solid's reactivity assumes stable references for options that are not behind +getters. `_features` and `columns` should be module-scoped; reactive options +should use getters; fallbacks should be module-scope constants. + +### HIGH — `(state) => state` default selector in a frequently-reading component + +If you pass no selector to `createTable` and you read `table.state()` in many +places, you've coupled every component to every slice. Narrow it. + +### HIGH — premature `table.Subscribe` on small tables + +`Subscribe` is for advanced isolation. A 50-row table doesn't need it. Native +Solid reactivity is already fine-grained. + +### HIGH — `stockFeatures` in production + +A clear "didn't think about the bundle" tell. Use only the features you render. + +### MEDIUM — virtualizer at the wrong scope + +Keep `createVirtualizer` in the component that owns the scroll container, not +high up in the tree. Otherwise scroll-driven recompute fires across the page. + +### MEDIUM — re-reading `table.store.state` in JSX when an atom would do + +`table.store.state.pagination` works, but `table.atoms.pagination.get()` or +`useSelector(table.atoms.pagination)` is the per-slice path. Prefer the slice. + +### MEDIUM — `autoResetPageIndex: true` on a server-driven table + +When `data` changes (a new server page arrives), the auto-reset can fight your +external atom. Set it to `false` for server-side tables. + +### LOW — measuring perf with `debugTable: true` left on + +`debugTable: true` is a development helper that logs row-model rebuilds. Turn +it off in production builds. diff --git a/packages/solid-table/skills/solid/table-state/SKILL.md b/packages/solid-table/skills/solid/table-state/SKILL.md new file mode 100644 index 0000000000..3575a5e3d8 --- /dev/null +++ b/packages/solid-table/skills/solid/table-state/SKILL.md @@ -0,0 +1,426 @@ +--- +name: solid/table-state +description: > + Reactivity, atom subscription, and rendering for `@tanstack/solid-table` v9. + Covers `createTable(options, selector?)`, the `table.state()` accessor (callable, not a value), + `table.Subscribe`, `FlexRender`, native `createSignal`/`createMemo` reactivity, + `solidReactivity` (readonly atoms = memos, writable atoms = signals), and + `@tanstack/solid-store` (`createAtom`, `useSelector`) for external slices. +type: framework +library: tanstack-table +framework: solid +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - docs/framework/solid/guide/table-state.md + - docs/framework/solid/solid-table.md + - packages/solid-table/src/createTable.ts + - packages/solid-table/src/reactivity.ts + - packages/solid-table/src/FlexRender.tsx + - packages/solid-table/src/createTableHook.tsx + - examples/solid/basic-use-table/ + - examples/solid/basic-external-atoms/ + - examples/solid/basic-external-state/ +--- + +# Solid Table State, Subscribe & createTableHook + +TanStack Table v9 is a state-management coordinator. The Solid adapter wires that +coordinator into Solid's fine-grained reactivity. Readonly atoms are backed by +`createMemo`. Writable atoms are backed by `createSignal`. Most Solid tables read +state directly through table APIs inside reactive scopes and never need +`table.Subscribe`. + +## Mental model + +A `createTable(...)` call produces a `SolidTable` with several state surfaces: + +- `table.baseAtoms.` — internal writable atoms (signals). +- `table.atoms.` — readonly derived atoms (memos). One per registered feature slice. +- `table.store` — flat readonly TanStack Store snapshot. `table.store.state.pagination` reads the current value. +- `table.state()` — **a Solid accessor**, not a value. Returns the result of the selector passed as the second argument to `createTable`. Default selector is identity. + +State slices only exist for features registered through `_features`. If +`rowSortingFeature` is not in `_features`, then `table.atoms.sorting`, +`table.store.state.sorting`, and `state.sorting` are all absent (TS error + missing at runtime). + +## Creating a table — native signals + +`createTable(options, selector?)`. Use getters on reactive options like `data` so +the table tracks the upstream signal. + +```tsx +import { createTable, tableFeatures, type ColumnDef } from '@tanstack/solid-table' +import { createSignal, For } from 'solid-js' + +const _features = tableFeatures({}) + +function App() { + const [data, setData] = createSignal>([]) + + const table = createTable({ + _features, + _rowModels: {}, + columns, + // Reactive getter — required so the table re-derives when data() changes. + get data() { + return data() + }, + }) + + return {(row) => /* ... */} +} +``` + +> Missing the getter on `data` is the most common Solid-specific bug. +> `data: data()` reads once at table construction; `get data() { return data() }` +> tracks the signal. + +## Reading state — `table.state()` is an accessor + +This is the **#1 Solid failure mode**: agents who learned React patterns try to +read `table.state.sorting`. In Solid, `table.state` is an `Accessor` — +it must be called. + +```tsx +// ❌ WRONG — `state` is the accessor function itself, not a value +table.state.sorting + +// ✅ CORRECT — call the accessor first, then read the slice +table.state().sorting +``` + +Pass a selector to narrow what `table.state()` returns: + +```tsx +const table = createTable( + { + _features, + _rowModels: {}, + columns, + get data() { + return data() + }, + }, + (state) => ({ pagination: state.pagination }), +) + +// Reactive in JSX / createMemo / createEffect: +const pageIndex = () => table.state().pagination.pageIndex +``` + +`table.state()` is also reactive inside Solid computations — `createMemo`, +`createEffect`, JSX expressions, and `` all track it. + +## Three ways to read state, ranked + +1. **Native Solid reactive read.** Inside any tracking scope (JSX, `createMemo`, + `createEffect`), call any atom-driven API or `table.state()` directly. Solid + handles the dependency. + + ```tsx +
Page {table.state().pagination.pageIndex + 1}
+ + ``` + +2. **Untracked current value.** Read `.get()` or the flat store outside a + tracking scope (e.g. inside a handler) when you only need a snapshot. + + ```tsx + const onClick = () => { + const current = table.atoms.pagination.get() + console.log(current.pageIndex) + } + ``` + +3. **`table.Subscribe`** — explicit subscription boundary. Less common on Solid + because signal-based reactivity handles most cases natively. Useful when you + want a sub-render isolated to one slice. + + ```tsx + s.rowSelection}> + {(rowSelection) => ( + Selected: {Object.keys(rowSelection()).length} + )} + + ``` + + Or with a `source` to subscribe to a single atom/store: + + ```tsx + !!rs[row.id]} + > + {(isSelected) => } + + ``` + + The child function receives a Solid `Accessor` — call it. + +## Setting state — use the feature APIs + +```tsx +table.setPageIndex(0) +table.nextPage() +table.setSorting([{ id: 'age', desc: true }]) +table.setColumnFilters((old) => [...old, { id: 'firstName', value: 'kev' }]) +row.toggleSelected() +column.toggleVisibility() +table.resetSorting() +``` + +Almost never reach for `table.baseAtoms..set(...)` directly. + +## State ownership: `initialState` vs `state`/`on*Change` vs `atoms` + +| Mode | When | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | +| `initialState` only | You only want starting values; table owns state. | +| `state` + `on[State]Change` | Migrating v8 code, or simple Solid signal integration. | +| `atoms: { slice: someAtom }` | App owns the slice. Best for sharing pagination/sort/filter across components (e.g. with Query). | + +External atoms take precedence over `state`. Don't mix them on the same slice. + +### `state` + `on*Change` (signal-backed external state) + +```tsx +const [sorting, setSorting] = createSignal([]) +const [pagination, setPagination] = createSignal({ + pageIndex: 0, + pageSize: 10, +}) + +const table = createTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + get data() { + return data() + }, + state: { + get sorting() { + return sorting() + }, + get pagination() { + return pagination() + }, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, +}) +``` + +> Getters in `state` are required. `state: { sorting: sorting() }` evaluates once. + +### External atoms (recommended for shared state) + +```tsx +import { createAtom, useSelector } from '@tanstack/solid-store' + +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = createAtom([]) + +const pagination = useSelector(paginationAtom) // Accessor + +const table = createTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + get data() { + return data() + }, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + }, +}) +``` + +Pair with `@tanstack/solid-store`'s `useSelector` anywhere else in the app to +read the same atom. + +## Rendering headers, cells, and footers + +`FlexRender` (top-level) handles plain values and Solid components. `table.FlexRender` +is the same component attached to the instance: + +```tsx +import { FlexRender } from '@tanstack/solid-table' + + + + {(hg) => ( + + + {(header) => ( + {header.isPlaceholder ? null : } + )} + + + )} + + + + + {(row) => ( + + + {(cell) => } + + + )} + + +``` + +`FlexRender` automatically handles grouped/aggregated/placeholder cells when the +column-grouping feature is registered. + +## `createTableHook` — app-level table conventions + +When multiple tables share `_features`, `_rowModels`, default options, and +component conventions, use `createTableHook` to register them once. + +```tsx +import { + createTableHook, + tableFeatures, + rowPaginationFeature, + createPaginatedRowModel, +} from '@tanstack/solid-table' + +const { + createAppTable, + createAppColumnHelper, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + tableComponents: { PaginationControls }, + cellComponents: { TextCell, NumberCell }, + headerComponents: { SortIndicator }, +}) + +const columnHelper = createAppColumnHelper() + +function UsersTable(props: { data: Array }) { + const table = createAppTable({ + columns, + get data() { + return props.data + }, + }) + + return ( + + + + + {(hg) => ( + + + {(h) => ( + + {(header) => ( + + )} + + )} + + + )} + + + + + {(row) => ( + + + {(c) => ( + + {(cell) => ( + + )} + + )} + + + )} + + +
+ + +
+ +
+ +
+ ) +} + +function PaginationControls() { + const table = useTableContext() // SolidTable + return ( +
+ + Page {table.state().pagination.pageIndex + 1} + +
+ ) +} +``` + +Use plain `createTable` when one feature config doesn't cover most of your +tables. Use `createTableHook` when it does. + +## How the Solid binding works (reference) + +`solidReactivity(owner)` is installed automatically by `createTable` as +`coreReativityFeature`. Don't pass your own. + +- `createReadonlyAtom(fn)` → `createMemo(fn, { equals, name })` +- `createWritableAtom(value)` → `createSignal(value, { equals, name })` +- Each atom exposes `.get()`, `.subscribe()`, and (for writable) `.set()`. +- Subscriptions run with the captured owner so atoms can be read inside Solid + computations safely. + +## Failure modes + +### CRITICAL — `table.state` vs `table.state()` + +In Solid, `table.state` is an **accessor** (a function). Agents who copied React +patterns will write `table.state.sorting` and get `undefined`. Always call it: +`table.state().sorting`. Same for any selected sub-state. + +### CRITICAL — feature not registered → API missing + +If you reach for `table.atoms.sorting`, `table.setSorting`, `column.getCanSort`, +etc., the matching feature (`rowSortingFeature`) must be in `_features`. +Otherwise TS errors and runtime `undefined`. v9 features are explicit. + +### CRITICAL — reactive `data` without a getter + +`data: someSignal()` reads once at construction. `get data() { return someSignal() }` +tracks. The same applies to any reactive option (`columns` when computed, +`state.sorting`, etc.). When in doubt: getter. + +### HIGH — wrong API name from a previous version + +There is no `createSolidTable` (v8 name). The Solid v9 API is `createTable`. +There is no `getCoreRowModel`/`getSortedRowModel` factory option pattern — pass +row models under `_rowModels` (e.g. `_rowModels: { sortedRowModel: createSortedRowModel(sortFns) }`). diff --git a/packages/svelte-table/README.md b/packages/svelte-table/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/svelte-table/README.md +++ b/packages/svelte-table/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/svelte-table/package.json b/packages/svelte-table/package.json index de017b6a4d..3b0ed9abe6 100644 --- a/packages/svelte-table/package.json +++ b/packages/svelte-table/package.json @@ -18,7 +18,8 @@ "svelte", "table", "svelte-table", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "dist/index.d.ts", @@ -47,7 +48,8 @@ }, "files": [ "dist", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/svelte-table/skills/svelte/client-to-server/SKILL.md b/packages/svelte-table/skills/svelte/client-to-server/SKILL.md new file mode 100644 index 0000000000..eaa12bd711 --- /dev/null +++ b/packages/svelte-table/skills/svelte/client-to-server/SKILL.md @@ -0,0 +1,238 @@ +--- +name: svelte/client-to-server +description: > + Convert a client-side Svelte table to server-side (manual) modes. Toggle `manualPagination`, + `manualSorting`, `manualFiltering`, `manualGrouping`, `manualExpanding` for whatever the server + owns, drop the matching `_rowModels` factories and `_features` you no longer need, supply + `rowCount` for the pager, then drive the request from `table.atoms.pagination` / + `table.atoms.sorting` / etc. (or external atoms you own) — using rune-aware getters + (`get data()`, `get rowCount()`) so the table re-syncs in `$effect.pre`. Svelte 5+ only. +type: lifecycle +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - state-management + - pagination + - filtering + - sorting + - svelte/table-state +sources: + - TanStack/table:examples/svelte/basic-external-atoms/ + - TanStack/table:examples/svelte/basic-external-state/ + - TanStack/table:examples/svelte/with-tanstack-query/ + - TanStack/table:docs/framework/svelte/guide/table-state.md +--- + +# Client → Server (Svelte) + +You have a working client-side table. The dataset is too big to ship to the browser, or it +lives behind an API. You want sorting / filtering / pagination to run on the server while the +table still feels the same in the UI. + +## Mental model + +Each "manual mode" flag tells the table: **don't run this stage of the pipeline; trust the data +you receive.** You can mix modes freely — manual pagination + client-side sorting on the +already-paged window is perfectly valid for medium datasets. + +| Flag | Meaning | What you must provide | +| ------------------ | -------------------------------------------- | ----------------------------------------------------------- | +| `manualPagination` | Server owns slicing; do not paginate locally | `rowCount` (or `pageCount`) | +| `manualSorting` | Server owns ordering | Sort the server query by `sorting` state | +| `manualFiltering` | Server owns row filtering | Filter the server query by `columnFilters` / `globalFilter` | +| `manualGrouping` | Server returns already-grouped rows | Pre-shaped data | +| `manualExpanding` | Server resolves sub-rows | Server-provided sub-row tree | + +When a stage is manual, you can drop its row-model factory. `manualPagination: true` does not +need `paginatedRowModel: createPaginatedRowModel()`. + +## Step 1 — Identify what's moving server-side + +For a typical "search and paginate against a database" screen: + +- Pagination → server +- Filtering (column filter inputs + a global search box) → server +- Sorting → server (usually, since a partial page can't be sorted client-side meaningfully) +- Selection / visibility / column ordering → still client + +So the table keeps `rowSelectionFeature` etc., drops `columnFilteringFeature` / +`rowPaginationFeature` / `rowSortingFeature` _row models_ but keeps the _features_ so the +state slices and UI APIs still exist. + +> Subtle point: keep the **feature** even if you drop the row model. The feature is what gives +> you `column.getCanSort()`, `table.setPageIndex()`, `column.setFilterValue()` — all the +> control-surface APIs. Dropping it kills the UI. + +## Step 2 — Own the relevant state with external atoms + +External atoms make state portable: the data layer (a fetch / query / store) can read the +same atoms the table writes to. Use `@tanstack/svelte-store`: + +```ts +import { createAtom, useSelector } from '@tanstack/svelte-store' +import type { + ColumnFiltersState, + PaginationState, + SortingState, +} from '@tanstack/svelte-table' + +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sortingAtom = createAtom([]) +const filtersAtom = createAtom([]) + +// For Svelte markup that should react to changes: +const pagination = useSelector(paginationAtom) +const sorting = useSelector(sortingAtom) +const filters = useSelector(filtersAtom) +``` + +## Step 3 — Configure the table + +```svelte + +``` + +`rowCount` is what makes `table.getPageCount()` / `table.getCanNextPage()` correct under +manual pagination. Without it the pager has no idea how many pages exist. + +## Step 4 — Drive the fetch from those atoms + +Wire whatever data layer you use (TanStack Query, a raw `fetch`, SvelteKit `load`, etc.) to +read the atoms. With TanStack Query: + +```ts +import { createQuery, keepPreviousData } from '@tanstack/svelte-query' + +const dataQuery = createQuery<{ rows: Array; rowCount: number }>( + () => ({ + queryKey: ['people', pagination.current, sorting.current, filters.current], + queryFn: () => + fetch('/api/people', { + method: 'POST', + body: JSON.stringify({ + pageIndex: pagination.current.pageIndex, + pageSize: pagination.current.pageSize, + sorting: sorting.current, + filters: filters.current, + }), + }).then((r) => r.json()), + placeholderData: keepPreviousData, + }), +) +``` + +`placeholderData: keepPreviousData` is what kills the "rows blank for one tick on every +page change" flash. + +## Step 5 — Reset behavior + +When the user changes a filter, you usually want to jump back to page 0. The table does this +automatically when client-side filtering owns the data, but with manual mode the data layer +controls it. Simplest fix: explicitly reset. + +```ts +$effect(() => { + // re-runs whenever filters.current identity changes + filters.current + table.setPageIndex(0) +}) +``` + +Or wrap your filter `onChange` handlers to also call `table.setPageIndex(0)`. + +## Step 6 — A note on global filtering + +If you also support `globalFilterFeature`, debounce the input. `column.setFilterValue` and +`table.setGlobalFilter` fire per keystroke; without debouncing you fire one request per typed +character. See the `compose-with-tanstack-pacer` skill for the pattern. + +## Hybrid example — manual pagination only + +Sometimes you only paginate server-side and let the page-sized window sort/filter on the +client. + +```ts +const table = createTable({ + _features: tableFeatures({ + columnFilteringFeature, + rowPaginationFeature, + rowSortingFeature, + }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), // client filters the page + sortedRowModel: createSortedRowModel(sortFns), // client sorts the page + }, + columns, + get data() { + return query.data?.rows ?? [] + }, + get rowCount() { + return query.data?.rowCount + }, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Only the manual flag for the stage you're moving server-side. + +## Common failure modes + +- **Forgot `rowCount`.** `table.getPageCount()` returns `-1`, the pager looks broken. +- **Dropped the feature, not just the row model.** Lost `column.getCanSort()` and friends. + Keep the feature when you still need its UI APIs; only drop the row-model factory. +- **Both `state.pagination` and `atoms.pagination`.** Atoms silently win; the `on*Change` + callback never fires. +- **Re-creating atoms inside reactive blocks.** Atoms must be stable across renders. Declare + them at module / component-init scope, not inside `$derived` or `$effect`. +- **Forgetting to reset page on filter change.** Stay on page 12 of a now-2-page result set. +- **Plain `data: query.data?.rows`.** No getter, no reactivity. Use `get data()`. +- **Reimplementing pagination math.** `table.setPageIndex / nextPage / previousPage / +firstPage / lastPage / setPageSize / getCanNextPage / getCanPreviousPage / getPageCount` + already exist and respect manual mode. + +## Related skills + +- `tanstack-table/svelte/compose-with-tanstack-query` — the same flow with a Query data layer. +- `tanstack-table/svelte/compose-with-tanstack-pacer` — debouncing filter inputs. +- `tanstack-table/svelte/compose-with-tanstack-store` — atom interop and per-slice subscription. +- `tanstack-table/core/pagination` / `filtering` / `sorting` — feature deep dives. diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-form/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-form/SKILL.md new file mode 100644 index 0000000000..ccb2a50bb4 --- /dev/null +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-form/SKILL.md @@ -0,0 +1,295 @@ +--- +name: svelte/compose-with-tanstack-form +description: > + Editable cells in `@tanstack/svelte-table` powered by `@tanstack/svelte-form`. The table is the + layout primitive; the form owns the state. Use `createFormHook` to register reusable field + components (`TextField`, `NumberField`, `SelectField`), then in each column's `cell` renderer + return `renderComponent(MyFieldCell, { form, rowIndex, fieldName })` and inside that cell call + `form.Field` (or an `AppField`) with `name="data[${rowIndex}].${fieldName}"`. Drive the table's + `data` from `form.state.values.data`. Svelte 5+ only. +type: composition +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - row-selection + - column-definitions +sources: + - TanStack/table:examples/svelte/with-tanstack-form/ + - TanStack/table:docs/framework/svelte/svelte-table.md +--- + +# Compose with TanStack Form (Svelte) + +Editable tables are a classic source of state-management chaos. With v9 + TanStack Form, the +division of labor is crisp: + +- **Form** owns the editable values (per-row, per-field). +- **Table** owns the layout (columns, filtering, pagination of the same form data). +- **Cells** are just field renderers — they read and write through Form's field APIs. + +## Install + +```bash +pnpm add @tanstack/svelte-form @tanstack/svelte-table +``` + +## Set up a field-component-rich Form hook + +Define a `createAppForm` once with the reusable field components. This is the form-side +equivalent of `createTableHook`. + +```ts +// hooks/form.ts +import { createFormHook, createFormHookContexts } from '@tanstack/svelte-form' +import TextField from '../components/TextField.svelte' +import NumberField from '../components/NumberField.svelte' +import SelectField from '../components/SelectField.svelte' +import SubmitButton from '../components/SubmitButton.svelte' +import FormStateIndicator from '../components/FormStateIndicator.svelte' + +export const { fieldContext, formContext } = createFormHookContexts() + +export const { useAppForm: createAppForm } = createFormHook({ + fieldComponents: { TextField, NumberField, SelectField }, + formComponents: { SubmitButton, FormStateIndicator }, + fieldContext, + formContext, +}) +``` + +## Reusable field cell components + +Each cell type is a small Svelte component that knows which row + field it edits and uses +`form.Field`. The shape is the same across types — `TextFieldCell`, `NumberFieldCell`, +`SelectFieldCell`. + +```svelte + + + + + {#snippet children(field)} + field.handleChange((e.target as HTMLInputElement).value)} + onblur={field.handleBlur} + /> + {#if field.state.meta.errors?.length} + {field.state.meta.errors.join(', ')} + {/if} + {/snippet} + +``` + +## Wire the table to form state + +```svelte + + +
{ + e.preventDefault() + void form.handleSubmit() + }} +> + + {#snippet children()} + + + {/snippet} + + + + + + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getAllCells() as cell (cell.id)} + + {/each} + + {/each} + +
+
+``` + +## Why `row.index` and not `row.id`? + +Form indexes its arrays positionally. `row.index` is the position inside the **current** row +model (after filter + sort + paging). If you want a positional address into `form.state.values.data`, +you usually want the **original** index — pass `row.original` somewhere that exposes it, or +store an `id` field and look it up. + +For the common case where the table renders the array in its natural order (no sort, no +filter that reorders), `row.index` matches the form-array position. + +## Add Row / Remove Row + +Use the form's array helpers; the table re-renders because its `data` getter points at +`form.state.values.data`. + +```ts +form.pushFieldValue('data', newPerson) +form.removeFieldValue('data', rowIndex) +form.replaceFieldValue('data', rowIndex, updatedPerson) +``` + +## Pairing with selection + +Add `rowSelectionFeature` to enable row checkboxes, then a "delete selected" button can use +`table.getSelectedRowModel()` to collect rows and `form.removeFieldValue` to remove them. + +```ts +const selectedRows = table.getSelectedRowModel().rows +const indexesDesc = selectedRows.map((r) => r.index).sort((a, b) => b - a) +for (const i of indexesDesc) { + form.removeFieldValue('data', i) +} +table.resetRowSelection() +``` + +Remove in descending order so earlier removals don't shift later indexes. + +## Pairing with virtualization + +You can virtualize the rows even with editable cells — but be aware that **virtualized rows +unmount when scrolled out of view**, taking their inline form fields with them. If a cell has +unsaved local-only state, you'll lose it. Use Form fields (which live on the form's state) +and you're fine — the field state survives the unmount. + +## Common failure modes + +- **`renderComponent` from React docs.** Use the Svelte adapter's `renderComponent` from + `@tanstack/svelte-table`. The signature is the same shape but the runtime is different. +- **`form` not reactive in cells.** Pass `form` as a prop; don't reach for it via context + unless you set up `formContext`. +- **Wrong field name.** `data[${row.index}].firstName` — string template, not a plain join. +- **Reordering / filtering breaks `row.index`.** As above. Either keep a stable id and resolve + back to the form-array index, or accept that `row.index` only addresses the visible window. +- **Editing inside virtualized rows without form state.** Field values lost on scroll. +- **Reimplementing form state with `$state` per cell.** Defeats the whole point — Form already + owns this state and runs validation. + +## Related skills + +- `tanstack-table/core/row-selection` — checkbox column patterns. +- `tanstack-table/core/column-definitions` — accessor / display columns. +- `tanstack-table/svelte/table-state` — `getRowModel()` and reactivity. diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-pacer/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-pacer/SKILL.md new file mode 100644 index 0000000000..99e9d723e0 --- /dev/null +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-pacer/SKILL.md @@ -0,0 +1,176 @@ +--- +name: svelte/compose-with-tanstack-pacer +description: > + Use `@tanstack/svelte-pacer` to debounce / throttle high-frequency writes that drive a + `@tanstack/svelte-table` v9 instance — column filter inputs and column resize state are the + two hot paths. Import `createDebouncer` (or `createThrottler`) from + `@tanstack/svelte-pacer/debouncer`, wrap the call site that hits `column.setFilterValue`, + `table.setGlobalFilter`, or commits a resize, and call `.maybeExecute(value)` on each event. + Svelte 5+ only — pacer instances live at component-init scope. +type: composition +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - filtering + - column-layout +sources: + - TanStack/table:examples/svelte/with-tanstack-form/ + - TanStack/table:docs/framework/svelte/guide/table-state.md +--- + +# Compose with TanStack Pacer (Svelte) + +Two places in a v9 table take state writes at event-loop rate: **filter inputs** (one +keystroke = one `setFilterValue`) and **column resizing** (one pointermove = one +`columnSizing` write). Without rate-limiting they either flood the network (server-side +filtering) or burn CPU on tens of thousands of re-renders per drag. + +`@tanstack/svelte-pacer` gives you `createDebouncer`, `createThrottler`, and friends. Wrap +the call site and you're done. + +## Install + +```bash +pnpm add @tanstack/svelte-pacer +``` + +## Debounce a column filter input + +Client-side debouncing reduces re-render churn. Server-side debouncing also kills request +storms. + +```svelte + + + +``` + +Why the `localValue`? So the input stays snappy (controlled by `$state`) while the table only +sees the debounced commit. + +## Debounce a global filter + +Same shape, calling `table.setGlobalFilter`: + +```svelte + + + { + search = (e.target as HTMLInputElement).value + debouncedSetGlobalFilter.maybeExecute(search) + }} +/> +``` + +## Throttle column resizing + +`columnResizingFeature` writes to `columnSizing` continuously. v9 supports `columnResizeMode: +'onChange' | 'onEnd'` — the default is `'onChange'` (commit per-frame). For very heavy tables, +either: + +1. Set `columnResizeMode: 'onEnd'` so the commit only happens at pointerup. +2. Or, keep `'onChange'` for the visual handle but throttle the side effects you fire off it. + +Throttle a side effect: + +```ts +import { createThrottler } from '@tanstack/svelte-pacer/throttler' + +const throttledSaveSizing = createThrottler( + (sizing: ColumnSizingState) => persistColumnSizingToStorage(sizing), + { wait: 100 }, +) + +$effect(() => { + const sizing = table.atoms.columnSizing.get() + throttledSaveSizing.maybeExecute(sizing) +}) +``` + +## Patterns to avoid + +### Don't debounce inside a `$effect` + +```ts +// WRONG — new debouncer instance every effect re-run +$effect(() => { + const d = createDebouncer((v) => column.setFilterValue(v), { wait: 200 }) + d.maybeExecute(value) +}) +``` + +Pacer instances must be stable. Declare them once at component init scope. + +### Don't debounce things that should be immediate + +Selection toggles, page changes, sort clicks — these are user-driven discrete events. Don't +debounce them. The user expects instant feedback. + +### Don't double-commit + +```ts +// WRONG — local state syncs immediately AND the debounced commit fires +oninput={(e) => { + column.setFilterValue(e.currentTarget.value) + debouncedSetFilter.maybeExecute(e.currentTarget.value) +}} +``` + +Pick one. If you want a snappy input, store the input value in local `$state` and only call +the debounced commit. + +## Coordinating with TanStack Query + +If your filter triggers a server query, debouncing the filter handler is necessary but not +sufficient — `placeholderData: keepPreviousData` is what keeps the UI from flashing. See the +`compose-with-tanstack-query` skill. + +## Common failure modes + +- **Recreating pacer instances inside `$effect` / `$derived`.** Each re-run produces a fresh + debouncer; nothing is ever delayed. +- **Hand-rolled `setTimeout` debounce.** Loses leading-edge / trailing-edge guarantees; harder + to cancel on unmount. Use pacer. +- **Debouncing a value that drives layout.** Causes user-visible lag where there shouldn't + be. Keep the input controlled by local state and only debounce the table-write call. +- **Forgetting to call `.maybeExecute`.** Constructing a debouncer doesn't enqueue anything; + you have to call `.maybeExecute(value)` per event. +- **Reimplementing pacer with `$effect` timers.** Don't. + +## Related skills + +- `tanstack-table/core/filtering` — column / global filter mechanics. +- `tanstack-table/core/column-layout` — column resizing modes (`onChange` vs `onEnd`). +- `tanstack-table/svelte/compose-with-tanstack-query` — pairing pacer with server queries. +- `tanstack-table/svelte/production-readiness` — where pacer fits in the perf checklist. diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-query/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-query/SKILL.md new file mode 100644 index 0000000000..b765df0f88 --- /dev/null +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-query/SKILL.md @@ -0,0 +1,299 @@ +--- +name: svelte/compose-with-tanstack-query +description: > + Server-side / async data flow with `@tanstack/svelte-query` and `@tanstack/svelte-table`. + Key the `createQuery` on the table state that drives the request (pagination + sort + + filters), pass `placeholderData: keepPreviousData` to avoid a "0 rows flash" between pages, + set `manualPagination` (and optionally `manualSorting` / `manualFiltering`), supply + `rowCount`, and feed the query result through reactive getters (`get data()`, + `get rowCount()`). Own driver state with `$state` or `@tanstack/svelte-store` atoms. + Svelte 5+ only. +type: composition +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - svelte/client-to-server + - pagination + - state-management +sources: + - TanStack/table:examples/svelte/with-tanstack-query/ + - TanStack/table:docs/framework/svelte/guide/table-state.md +--- + +# Compose with TanStack Query (Svelte) + +`@tanstack/svelte-query` and `@tanstack/svelte-table` complement each other naturally: + +- **Query** owns server data — fetching, caching, retries, placeholder data. +- **Table** owns view state — pagination, sort, filters, selection. + +The integration is short and predictable: drive the query key from the view state, manual-mode +the affected pipeline stages, pipe the result back through reactive getters. + +## The pattern in 30 seconds + +```svelte + +``` + +Three things to notice: + +1. `queryKey` includes the driver state (`pagination`). Query re-fetches when the page or page + size changes. +2. `placeholderData: keepPreviousData` keeps the previous page visible while the next page + loads. Without it, `dataQuery.data?.rows` is `undefined` for one tick on every page change + and the table flashes empty. +3. `manualPagination: true` tells the table the data is already paged. Without `rowCount` the + pager has no idea how many pages exist. + +## Driver-state ownership choices + +You can drive the query from either: + +- **Component `$state` + `state` + `on[State]Change`** (shown above) — simplest, mirrors + what most v8 codebases look like after migration. +- **External `@tanstack/svelte-store` atoms + `atoms`** — preferable when the same state + drives multiple components (a toolbar, a sidebar, a URL syncer). + +```ts +import { createAtom, useSelector } from '@tanstack/svelte-store' + +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const pagination = useSelector(paginationAtom) + +const dataQuery = createQuery(() => ({ + queryKey: ['people', pagination.current], + queryFn: () => fetchPeople(pagination.current), + placeholderData: keepPreviousData, +})) + +const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { + return dataQuery.data?.rows ?? [] + }, + get rowCount() { + return dataQuery.data?.rowCount + }, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +`table.setPageIndex(2)` writes through `paginationAtom`, which invalidates `queryKey`, which +fetches page 3. + +## Adding sort and filters + +```ts +import type { ColumnFiltersState, SortingState } from '@tanstack/svelte-table' + +let sorting: SortingState = $state([]) +let filters: ColumnFiltersState = $state([]) +let pagination: PaginationState = $state({ pageIndex: 0, pageSize: 10 }) + +const dataQuery = createQuery(() => ({ + queryKey: ['people', pagination, sorting, filters], + queryFn: () => fetchPeople({ pagination, sorting, filters }), + placeholderData: keepPreviousData, +})) + +const table = createTable({ + _features: tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, + }), + _rowModels: {}, + columns, + get data() { + return dataQuery.data?.rows ?? [] + }, + get rowCount() { + return dataQuery.data?.rowCount + }, + state: { + get pagination() { + return pagination + }, + get sorting() { + return sorting + }, + get columnFilters() { + return filters + }, + }, + onPaginationChange: (u) => + (pagination = typeof u === 'function' ? u(pagination) : u), + onSortingChange: (u) => (sorting = typeof u === 'function' ? u(sorting) : u), + onColumnFiltersChange: (u) => + (filters = typeof u === 'function' ? u(filters) : u), + manualPagination: true, + manualSorting: true, + manualFiltering: true, +}) +``` + +## Reset page on filter / sort change + +Otherwise a user filters from "all 5000 people" to "5 named Alice" and stays on page 12. + +```ts +$effect(() => { + // re-run on identity change + filters + sorting + table.setPageIndex(0) +}) +``` + +## Debounce keystroke-driven filters + +Without debouncing, a search input fires one request per character. + +```ts +import { createDebouncer } from '@tanstack/svelte-pacer/debouncer' + +const debouncedSetGlobalFilter = createDebouncer( + (value: string) => table.setGlobalFilter(value), + { wait: 250 }, +) +``` + +```svelte + debouncedSetGlobalFilter.maybeExecute(e.currentTarget.value)} +/> +``` + +See the `compose-with-tanstack-pacer` skill for the full pacer pattern. + +## Loading and empty states + +`createQuery` exposes `isFetching`, `isPending`, `isError`, `data`. Use them around the +table, not inside the row loop. + +```svelte +{#if dataQuery.isPending} +
Loading…
+{:else if dataQuery.isError} +
Failed: {dataQuery.error.message}
+{:else} + ...
+{/if} + +{#if dataQuery.isFetching} + Refreshing… +{/if} +``` + +`isFetching` is helpful for the "loading next page" indicator while +`placeholderData: keepPreviousData` still shows the old rows. + +## Optimistic updates (when you also mutate) + +```ts +import { createMutation, useQueryClient } from '@tanstack/svelte-query' + +const queryClient = useQueryClient() + +const updatePerson = createMutation(() => ({ + mutationFn: (input: Partial) => + fetch(`/api/people/${input.id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['people'] }), +})) +``` + +After the mutation succeeds, `invalidateQueries` re-fetches; `placeholderData` keeps the old +rows visible during the refresh. + +## SvelteKit `load` integration (a sketch) + +If your table is on a SvelteKit page, `+page.ts` can hydrate the query cache with the first +page so SSR renders rows immediately. Subsequent pages still go through `createQuery`. + +```ts +// +page.ts +export const load = async ({ fetch }) => { + const initial = await fetchPeople({ pageIndex: 0, pageSize: 10 }, fetch) + return { initial } +} +``` + +```svelte + +``` + +## Common failure modes + +- **Forgot `rowCount`.** Pager shows zero pages. +- **No `placeholderData: keepPreviousData`.** Empty-table flash on every page change. +- **Forgot `manualPagination: true`.** Table tries to paginate the already-paged window. + `getPageCount()` returns 1. +- **Driver state in `queryKey` is stale.** Always pass the current value, not a captured one. +- **No reset on filter change.** Stays on dead pages. +- **Plain `data: dataQuery.data?.rows`.** No reactivity — must be a getter. +- **Re-creating `createQuery` inside `$effect`.** It's a one-time call; create it at component init. +- **Reimplementing pagination math against `query.data` instead of calling + `table.nextPage()`.** Don't. + +## Related skills + +- `tanstack-table/svelte/client-to-server` — base server-side pattern (without Query). +- `tanstack-table/svelte/compose-with-tanstack-store` — atom-based driver state. +- `tanstack-table/svelte/compose-with-tanstack-pacer` — debounce / throttle high-frequency inputs. +- `tanstack-table/core/pagination` — manual mode semantics. diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md new file mode 100644 index 0000000000..100944fe65 --- /dev/null +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-store/SKILL.md @@ -0,0 +1,277 @@ +--- +name: svelte/compose-with-tanstack-store +description: > + TanStack Table v9 is built on TanStack Store. Each state slice (sorting, pagination, + rowSelection, columnFilters, ...) is a separate atom. In Svelte, `@tanstack/svelte-store` + exposes `createAtom`, `useSelector`, `shallow`. Read `table.atoms.` per slice, + `table.store` flat, or `table.state` for the selector projection. Subscribe with + `subscribeTable(atom, selector?)` (returns `.current`). Own a slice externally with + `createAtom` + `atoms: { sorting: sortingAtom }`. Svelte 5+ only — `$state` / `$derived.by` / + `$effect.pre` reactivity. +type: composition +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/framework/svelte/guide/table-state.md + - TanStack/table:packages/svelte-table/src/reactivity.svelte.ts + - TanStack/table:packages/svelte-table/src/subscribe.ts + - TanStack/table:examples/svelte/basic-external-atoms/ +--- + +# Compose with TanStack Store (Svelte) + +`@tanstack/svelte-store` is the reactive primitive under `@tanstack/svelte-table` v9. The +table doesn't merely _use_ Store — its entire reactivity model is built from Store atoms with +rune backings. + +## Mental model — three read surfaces + +A registered v9 table exposes: + +| Surface | Shape | When to use | +| --------------------- | --------------------------- | ------------------------------------------ | +| `table.atoms.` | `ReadonlyAtom` | Per-slice subscription / `.get()` snapshot | +| `table.store` | `ReadonlyStore` | Flat snapshot across registered slices | +| `table.state` | `TSelected` (from selector) | The selector projection (Svelte-only) | + +Plus the writable internals: + +- `table.baseAtoms.` — writable atom for state the table owns. + +If a slice is supplied externally via `atoms`, `table.atoms.` reads from your atom and +`table.baseAtoms.` is unused for that slice. + +## The Svelte bindings (what `svelteReactivity()` actually does) + +The Svelte adapter ships `svelteReactivity()` and installs it as `coreReativityFeature`. It +maps Store primitives to runes: + +- Readonly atoms → `$derived.by(fn)` +- Writable atoms → `$state(initialValue)` +- Subscriptions → `$effect.root` + `$effect` +- Batch → `flushSync` + +This is why simple atom reads inside `.svelte` components (templates, `$derived`, `$effect`) +participate in reactivity automatically. There is no React-style `useStore` requirement. + +## Pattern 1 — Read a slice without subscribing + +For event handlers, async work, exports, anything outside of reactive markup. Cheap, no +subscription setup. + +```ts +import type { SortingState } from '@tanstack/svelte-table' + +function logSort() { + const sorting: SortingState = table.atoms.sorting.get() + console.log(sorting) +} +``` + +`table.store.state` is the full snapshot equivalent. + +## Pattern 2 — Reactive selector via `createTable` + +The second argument to `createTable` is a TanStack Store selector. The result is exposed on +`table.state`. The default selector is `(state) => state`. + +```svelte + + +Page {table.state.pageIndex + 1} +``` + +The narrower the selector, the less your markup re-renders. + +## Pattern 3 — Per-block subscription with `subscribeTable` + +`subscribeTable(source, selector?)` is the dedicated per-component subscription. It uses +`shallow` compare and exposes a `.current` accessor. + +```svelte + + +Page {pagination.current.pageIndex + 1} ({pageSize.current} per page) +``` + +Inside per-row components, `subscribeTable(table.atoms.rowSelection, (s) => !!s[row.id])` keeps +that row's checkbox reactive without subscribing to the entire selection map. + +## Pattern 4 — Own a slice externally with `createAtom` + +When the app should own a slice — share across components, sync with URL, persist to storage — +create a stable atom and hand it to the table via `atoms`. + +```ts +import { createAtom, useSelector } from '@tanstack/svelte-store' +import { + createTable, + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + type PaginationState, + type SortingState, +} from '@tanstack/svelte-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +const sortingAtom = createAtom([]) +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) + +// Optional: a Svelte-reactive view onto each atom for use in markup. +const sorting = useSelector(sortingAtom) +const pagination = useSelector(paginationAtom) + +const table = createTable({ + _features, + _rowModels: { + /* ... */ + }, + columns, + get data() { + return data + }, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + }, +}) + +// table.setPageIndex(2) writes through paginationAtom. +// paginationAtom.set(...) updates table.atoms.pagination immediately. +``` + +Atom precedence: external `atoms.` wins over external `state.` which writes +into the internal `baseAtoms.`. **Never combine them on the same slice.** + +## Pattern 5 — Cross-component / cross-module state + +Because atoms are first-class subscribable values, you can read them outside the table component. + +```ts +// stores/table-state.ts +import { createAtom } from '@tanstack/svelte-store' +import type { RowSelectionState } from '@tanstack/svelte-table' + +export const rowSelectionAtom = createAtom({}) +``` + +```svelte + + + + +``` + +```svelte + + +``` + +## Pattern 6 — `useSelector` with custom equality + +`useSelector(source, selector, { compare })` lets you switch comparison strategies — useful +for object selectors so you don't re-fire on every reference change. + +```ts +import { shallow, useSelector } from '@tanstack/svelte-store' + +const filterValues = useSelector( + table.atoms.columnFilters, + (filters) => Object.fromEntries(filters.map((f) => [f.id, f.value])), + { compare: shallow }, +) +``` + +`subscribeTable` already uses `shallow` by default, so prefer it for table sources unless you +need a custom compare. + +## Pattern 7 — Direct base-atom writes (last resort) + +When a slice is internally owned and you really need to write outside a feature API: + +```ts +table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) +``` + +Do not do this for externally-owned slices — write to your external atom instead. The base +atom is dormant in that case and your write will be silently ignored next sync. + +## Common failure modes + +- **Reading a slice that wasn't registered.** `table.atoms.rowSelection` is `undefined` if + `rowSelectionFeature` isn't in `_features`. TS will catch it if you used `tableFeatures()`. +- **Creating atoms inside reactive blocks.** Atoms must be stable. Module scope or top-level + component scope, never inside `$derived` / `$effect`. +- **`useSelector` without `.current`.** `selection.pageIndex` is wrong — `selection.current.pageIndex`. +- **Mixing `atoms.X` and `state.X`.** Atom wins, callback never fires. +- **`tableState` as a plain object.** No reactivity. Use `subscribeTable`, `useSelector`, or + the `createTable` selector. +- **Reimplementing `useSelector` with `$effect`.** Built-in is more efficient and uses + shallow compare. + +## Related skills + +- `tanstack-table/svelte/table-state` — full reactivity model and selector patterns. +- `tanstack-table/core/state-management` — atom precedence rules. +- `tanstack-table/svelte/client-to-server` — atoms as the data-driver for server queries. +- `tanstack-table/svelte/production-readiness` — selector / subscription tuning. diff --git a/packages/svelte-table/skills/svelte/compose-with-tanstack-virtual/SKILL.md b/packages/svelte-table/skills/svelte/compose-with-tanstack-virtual/SKILL.md new file mode 100644 index 0000000000..1b5b8f2f15 --- /dev/null +++ b/packages/svelte-table/skills/svelte/compose-with-tanstack-virtual/SKILL.md @@ -0,0 +1,286 @@ +--- +name: svelte/compose-with-tanstack-virtual +description: > + `@tanstack/svelte-table` does not include virtualization — pair it with + `@tanstack/svelte-virtual`. Use `createVirtualizer({ count, estimateSize, getScrollElement, + ... })`, feed `table.getRowModel().rows.length` as `count`, render only + `$rowVirtualizer.getVirtualItems()`, position rows with `transform: translateY(...)` and a + container of `getTotalSize()`. Use `measureElement` actions for dynamic row heights. Svelte 5+ + only — `$state` for refs, `$effect` to sync count. +type: composition +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - svelte/table-state + - row-expanding +sources: + - TanStack/table:docs/guide/virtualization.md + - TanStack/table:examples/svelte/virtualized-rows/ + - TanStack/table:examples/svelte/virtualized-columns/ + - TanStack/table:examples/svelte/virtualized-infinite-scrolling/ +--- + +# Compose with TanStack Virtual (Svelte) + +TanStack Table is **not** a virtualizer. For lists / grids past a few thousand rows (or with +heavy per-row markup), pair it with `@tanstack/svelte-virtual`. + +## Install + +```bash +pnpm add @tanstack/svelte-virtual +``` + +## Core mental model + +- TanStack Table gives you `rows: row[]` (already filtered / sorted / paged / grouped). +- TanStack Virtual takes `count` (the length) and returns `virtualItems` (the slice currently + in view). +- You render only those virtual items, absolutely positioned, inside a container sized to + `getTotalSize()` pixels. + +`createVirtualizer` returns a Svelte store. Read its current value with `$rowVirtualizer` or +`get(rowVirtualizer)` (from `svelte/store`). + +## Basic row virtualization + +```svelte + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {/each} + + {/each} + + + {#each $rowVirtualizer.getVirtualItems() as virtualRow (virtualRow.index)} + {@const row = rows[virtualRow.index]} + + {#each row.getAllCells() as cell (cell.id)} + + {/each} + + {/each} + +
+ +
+ +
+
+``` + +Why `display: grid` / `flex` instead of native table layout? Because the rows are absolutely +positioned, the browser's native table layout algorithm can't size columns from non-flowing +rows. CSS layout takes over. + +## Dynamic row heights (`measureElement`) + +For variable row heights (multi-line cells, expanding rows), measure rendered nodes with the +virtualizer's `measureElement` API. + +```svelte + + +... +``` + +`data-index` is required — the virtualizer uses it to map a measured element back to its +virtual item. + +> Firefox measures table-border rows incorrectly. The above guards against measuring there and +> falls back to the estimate. + +## Column virtualization + +`createVirtualizer` with `horizontal: true` against `table.getVisibleLeafColumns()`. Same +pattern — only render `getVirtualItems()` cells per row, position with `translateX`. + +```ts +const columnVirtualizer = createVirtualizer({ + get count() { + return visibleColumns.length + }, + estimateSize: (index) => visibleColumns[index].getSize(), + getScrollElement: () => tableContainerRef ?? null, + horizontal: true, + overscan: 3, +}) +``` + +For combined row + column virtualization, render the row virtualizer's items, and inside each +row render the column virtualizer's items. See `examples/svelte/virtualized-columns/`. + +## Infinite scroll (load more on near-bottom) + +Subscribe to the virtualizer's `getVirtualItems()` and check the last one's index against your +total available count. + +```ts +import { createInfiniteQuery } from '@tanstack/svelte-query' + +const infiniteQuery = createInfiniteQuery(() => ({ + queryKey: ['people-infinite'], + queryFn: ({ pageParam }) => fetchPeople({ cursor: pageParam, pageSize: 50 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => last.nextCursor, +})) + +const flatData = $derived( + infiniteQuery.data?.pages.flatMap((p) => p.rows) ?? [], +) + +const table = createTable({ + _features: tableFeatures({}), + _rowModels: {}, + columns, + get data() { + return flatData + }, +}) + +const rows = $derived(table.getRowModel().rows) + +const rowVirtualizer = createVirtualizer({ + get count() { + return rows.length + }, + estimateSize: () => 33, + getScrollElement: () => tableContainerRef ?? null, + overscan: 10, +}) + +$effect(() => { + const items = $rowVirtualizer.getVirtualItems() + const last = items[items.length - 1] + if ( + last && + last.index >= rows.length - 1 && + infiniteQuery.hasNextPage && + !infiniteQuery.isFetchingNextPage + ) { + infiniteQuery.fetchNextPage() + } +}) +``` + +## Interaction with row expanding + +If `rowExpandingFeature` is registered, `table.getRowModel().rows` already flattens expanded +sub-rows into a single sequential list. The virtualizer just sees a longer list — no special +handling needed. + +For variable row heights driven by expand state, you'll want `measureElement` so the +container resizes when a row expands. + +## Pagination vs. virtualization + +Pick one. Virtualization is for "render all rows but render only the visible window". +Pagination is for "the user navigates discrete pages". Combining them usually means you don't +need either — drop pagination and let the virtualizer handle the rendering window. + +## Common failure modes + +- **Forgot to push `count` updates.** `svelte-virtual` does not auto-track `get count()` — + use `$effect` + `setOptions({ count })`. +- **Native table layout with virtualized rows.** Columns collapse because absolutely + positioned rows don't contribute to layout. Use `display: grid` / `flex`. +- **No `data-index` on ``.** `measureElement` can't map back to virtual items. +- **No `transform: translateY`.** Rows render at `top: 0` and stack visually. +- **Missing container `height`.** No overflow, no scroll, no virtualization. +- **Calling `get(rowVirtualizer).getVirtualItems()` in template.** Wrong access pattern; + use `$rowVirtualizer.getVirtualItems()` (store auto-subscribe) or be sure to + `import { get } from 'svelte/store'`. +- **Reimplementing windowing manually.** Don't. + +## Related skills + +- `tanstack-table/svelte/table-state` — `getRowModel()` and the reactivity model. +- `tanstack-table/core/row-expanding` — flattening sub-rows for virtualization. +- `tanstack-table/svelte/compose-with-tanstack-query` — infinite-scroll data source. diff --git a/packages/svelte-table/skills/svelte/getting-started/SKILL.md b/packages/svelte-table/skills/svelte/getting-started/SKILL.md new file mode 100644 index 0000000000..4c7df38c7a --- /dev/null +++ b/packages/svelte-table/skills/svelte/getting-started/SKILL.md @@ -0,0 +1,340 @@ +--- +name: svelte/getting-started +description: > + End-to-end first-table journey for `@tanstack/svelte-table@9` on Svelte 5. Install the adapter, + declare `_features` with `tableFeatures()`, register `_rowModels` factories with their `*Fns` + parameters, build a typed column helper with both `TFeatures` and `TData` generics, instantiate + the table with `createTable(options)` using `$state` data and `get data()` reactive option getters, + and render with `FlexRender`. Svelte 5+ only — Svelte 3/4 must use v8. +type: lifecycle +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - setup + - column-definitions + - state-management + - svelte/table-state +sources: + - TanStack/table:docs/installation.md + - TanStack/table:docs/framework/svelte/svelte-table.md + - TanStack/table:examples/svelte/basic-create-table/ + - TanStack/table:examples/svelte/basic-app-table/ + - TanStack/table:examples/svelte/basic-snippets/ + - TanStack/table:packages/svelte-table/src/index.ts +--- + +# Getting Started — Svelte + +A first working `@tanstack/svelte-table` v9 table from a blank Svelte 5 project. Read this +end-to-end before searching the docs — the v9 shape diverges enough from v8 (and from your +muscle memory) that skimming will produce broken code. + +## CRITICAL: Svelte version + +**`@tanstack/svelte-table@9` requires Svelte 5 or newer.** The adapter is built on runes +(`$state`, `$derived.by`, `$effect.pre`). If your project is on Svelte 3 or 4, do **one** of: + +- Upgrade the project to Svelte 5, then install v9. +- Stay on `@tanstack/svelte-table@8` for that project. + +There is no shim, no `/legacy` export, and no support path that runs v9 on Svelte 4. + +## 1. Install + +```bash +pnpm add @tanstack/svelte-table +# optional: external atoms / fine-grained selectors +pnpm add @tanstack/svelte-store +``` + +You do **not** install `@tanstack/table-core` separately — the Svelte adapter re-exports +everything you need (column helpers, feature objects, row-model factories, types). + +## 2. Define `_features` and `_rowModels` + +v9 is explicit. You opt in to every feature and every row model. The core row model is +included by default, so the minimum viable table is: + +```ts +const _features = tableFeatures({}) +const _rowModels = {} +``` + +For anything beyond a flat table, register the features you'll use **and** the matching +row-model factories. Row-model factories take `*Fns` registries as parameters: + +```ts +import { + columnFilteringFeature, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + rowPaginationFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/svelte-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, + columnFilteringFeature, +}) + +const _rowModels = { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), +} +``` + +**Skipping a feature** in `_features` means its state slice does not exist on `table.atoms`, +its options on `createTable` do nothing, and its derived APIs (`table.setSorting`, +`column.getCanSort`) are not on the instance. + +## 3. Type your data and define columns + +```ts +type Person = { + firstName: string + lastName: string + age: number + visits: number + status: 'relationship' | 'complicated' | 'single' + progress: number +} +``` + +Both `ColumnDef` and the column helper take the two generics ``: + +```ts +import { createColumnHelper, type ColumnDef } from '@tanstack/svelte-table' + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + header: 'First Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: () => 'Last Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor('age', { header: 'Age' }), + columnHelper.accessor('visits', { header: 'Visits' }), + columnHelper.accessor('status', { header: 'Status' }), + columnHelper.accessor('progress', { header: 'Profile Progress' }), +]) +``` + +Or use raw `ColumnDef` arrays if you don't want the helper: + +```ts +const columns: Array> = [ + { + accessorKey: 'firstName', + header: 'First Name', + cell: (info) => info.getValue(), + }, +] +``` + +## 4. Create the table + +Use Svelte 5 `$state` for the data and pass it through a **reactive getter** so the table +re-evaluates `data` when the rune changes. The same pattern applies for any other reactive +option (`columns`, `rowCount`, `state.*`). + +```svelte + +``` + +`createTable` syncs options inside `$effect.pre`, so external `$state` updates flow into the +table **before** the DOM reads `getRowModel()` — no stale-frame bugs. + +## 5. Render with `FlexRender` + +`FlexRender` handles plain strings, function renderers, component renderers +(`renderComponent`), and snippet renderers (`renderSnippet`). + +```svelte + + + + + + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getAllCells() as cell (cell.id)} + + {/each} + + {/each} + +
+ {#if !header.isPlaceholder} + + {/if} +
+``` + +**Key the `{#each}` blocks on stable ids.** Without keys, Svelte recreates nodes and loses +focus, scroll, and any per-row component state. + +## 6. Adding a feature — pagination + +```svelte + + +
+ + Page {table.atoms.pagination.get().pageIndex + 1} of {table.getPageCount()} + +
+``` + +To make pagination reactive in the controls, either pass a selector to `createTable` or use +`subscribeTable`: + +```ts +import { subscribeTable } from '@tanstack/svelte-table' + +const pagination = subscribeTable(table.atoms.pagination) +// pagination.current.pageIndex is reactive +``` + +## 7. `createTableHook` (when you have more than one table) + +For apps with multiple tables, define the `_features`, `_rowModels`, and shared components +once: + +```ts +// hooks/table.ts +import { + createPaginatedRowModel, + createSortedRowModel, + createTableHook, + rowPaginationFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/svelte-table' + +export const { createAppTable, createAppColumnHelper } = createTableHook({ + _features: tableFeatures({ rowPaginationFeature, rowSortingFeature }), + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + }, +}) +``` + +```svelte + +``` + +## Common failure modes + +- **Svelte 3/4.** Adapter will not work. See top of file. +- **`createSvelteTable` / `useSvelteTable` / `getCoreRowModel`** — all v8 names. v9 uses + `createTable` and `_rowModels: { paginatedRowModel: createPaginatedRowModel(), ... }`. +- **Plain `data` instead of `get data()` getter.** Table will not see data updates. Always + pass a reactive getter for state that lives in `$state`. +- **Missing feature in `_features`.** `table.setSorting` / `column.getCanSort` won't exist. + TypeScript will tell you; if it doesn't, you're missing the `` + generics on the column helper or `ColumnDef`. +- **Plain object instead of `tableFeatures({...})`.** Loses typed atom keys; you'll get + `unknown` state shapes everywhere. +- **Unkeyed `{#each}` blocks.** Reuse bugs (focus jumps, wrong row selected). +- **Reimplementing built-ins.** If you write a manual sort comparator across rows, you're + re-doing `rowSortingFeature`. Register it instead. + +## Next steps + +- `tanstack-table/svelte/table-state` — reactivity model, selectors, subscribeTable, ownership. +- `tanstack-table/core/filtering` / `pagination` / `sorting` / `row-selection` — feature-by-feature. +- `tanstack-table/svelte/compose-with-tanstack-query` — server-side data. +- `tanstack-table/svelte/compose-with-tanstack-virtual` — large datasets. +- `tanstack-table/svelte/production-readiness` — selector tuning, bundle size. diff --git a/packages/svelte-table/skills/svelte/migrate-v8-to-v9/SKILL.md b/packages/svelte-table/skills/svelte/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..f3704d0873 --- /dev/null +++ b/packages/svelte-table/skills/svelte/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,256 @@ +--- +name: svelte/migrate-v8-to-v9 +description: > + Mechanical migration from `@tanstack/svelte-table@8` to `@tanstack/svelte-table@9`. v9 in Svelte + is a full rewrite — Svelte 5 runes only (no Svelte 3/4), no `/legacy` adapter (unlike React), + `createSvelteTable` → `createTable`, `getCoreRowModel` / `getSortedRowModel` factories → required + `_features` + `_rowModels` registration, `flexRender` helper → `` component, + writable-store `state` → rune-based getters / external atoms, `onStateChange` → per-slice + `on[State]Change` or `atoms`. Plan a feature-by-feature audit, not a search-and-replace. +type: lifecycle +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - TanStack/table:docs/framework/svelte/svelte-table.md + - TanStack/table:docs/framework/svelte/guide/table-state.md + - TanStack/table:packages/svelte-table/src/ + - TanStack/table:examples/svelte/basic-create-table/ + - TanStack/table:examples/svelte/basic-external-atoms/ + - TanStack/table:examples/svelte/basic-external-state/ +--- + +# Migrate v8 → v9 (Svelte) + +## CRITICAL: v9 = Svelte 5 + +**v9 of the Svelte adapter only supports Svelte 5+.** No backport. No shim. No `/legacy` import. +The v9 adapter is built on Svelte 5 runes (`$state`, `$derived.by`, `$effect.pre`). If your app +is still on Svelte 3/4, you have two real options: + +1. Stay on `@tanstack/svelte-table@8`. v8 keeps working with the Svelte 3/4 writable-store API. +2. Migrate the app to Svelte 5 first, then migrate the table. + +There is no third option. Trying to install v9 on a Svelte 4 codebase will error at compile. + +> This makes the Svelte migration heavier than React's. React has a `/legacy` re-export that +> mirrors the v8 surface; Svelte does not. Plan for a real rewrite of every table screen. + +## What changed at the type / API level + +| v8 | v9 | +| -------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `createSvelteTable(options)` | `createTable(options, selector?)` | +| `getCoreRowModel()` | included by default; no factory | +| `getPaginationRowModel()` | `_rowModels.paginatedRowModel: createPaginatedRowModel()` | +| `getSortedRowModel()` | `_rowModels.sortedRowModel: createSortedRowModel(sortFns)` | +| `getFilteredRowModel()` | `_rowModels.filteredRowModel: createFilteredRowModel(filterFns)` | +| `getExpandedRowModel()` | `_rowModels.expandedRowModel: createExpandedRowModel()` | +| `getGroupedRowModel()` | `_rowModels.groupedRowModel: createGroupedRowModel(aggregationFns)` | +| `getFacetedRowModel()` / `MinMax` / `UniqueValues` | facet APIs auto-derived when `*Facet*Feature` registered | +| `flexRender(template, ctx)` helper | `` / `` / `` | +| `ColumnDef` | `ColumnDef` (extra generic) | +| `createColumnHelper()` | `createColumnHelper()` | +| writable store on the table instance | `table.atoms.` + `table.store` + `table.state` | +| `onStateChange` (monolithic) | per-slice `on[State]Change`, or external `atoms` | +| `useSvelteTable` (rare) | gone | + +## What did NOT change + +- The data array is still the source of truth; columns are still defined the same way + shape-wise (`accessorKey` / `accessorFn` / `header` / `cell` / `footer`). +- Filter, sort, aggregation function registries are still `filterFns`, `sortFns`, + `aggregationFns` — but now passed into row-model factories instead of being auto-resolved. +- Feature APIs (`table.nextPage()`, `column.getCanSort()`, `row.toggleSelected()`) keep the + same names. + +## Migration checklist (per file) + +For each Svelte component that uses the table: + +1. **Upgrade Svelte.** Ensure the app is on Svelte 5 and the component compiles in runes mode. +2. **Replace store with rune.** `let data = writable([])` → `let data = $state([])`. Reads + inside markup are no longer `$data` — just `data`. +3. **Import surface.** `import { createSvelteTable, getCoreRowModel, flexRender }` → + `import { createTable, FlexRender, tableFeatures, ... }`. +4. **Add `_features`.** Create `const _features = tableFeatures({ ... only the features this +table uses ... })`. +5. **Move row-model factories.** Every `get*RowModel: get*RowModel()` becomes a `_rowModels` + entry with the matching `create*RowModel(*Fns)` factory. +6. **Generic columns.** Add `` to `ColumnDef<>` and + `createColumnHelper<>()`. TypeScript will tell you when you missed one. +7. **`data` as a getter.** `data: data` → `get data() { return data }`. Same for any other + reactive option (`state.*`, `columns`, `rowCount`). +8. **State.** Pick the new ownership model — see below. +9. **Rendering.** `{flexRender(header.column.columnDef.header, header.getContext())}` → + ``. Same for cells and footers. +10. **Components / snippets in cells.** v8: pass a Svelte component constructor. v9: wrap with + `renderComponent(MyCell, props)` or `renderSnippet(snippet, args)`. + +## State ownership — choose one per slice + +### Was: `writable` store + `onStateChange` + +```svelte + +``` + +### Now: pick one of three + +**Internal (default).** Pass only `initialState` and let the table own it. + +```ts +const table = createTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + get data() { + return data + }, + initialState: { + pagination: { pageIndex: 0, pageSize: 25 }, + }, +}) +``` + +**External `state` + per-slice `on[State]Change`.** Closest to a literal v8 port. + +```svelte + +``` + +**External atoms (preferred for shared state).** Atomic, subscribable from anywhere. + +```ts +import { createAtom, useSelector } from '@tanstack/svelte-store' + +const sortingAtom = createAtom([]) +const paginationAtom = createAtom({ pageIndex: 0, pageSize: 10 }) + +const sorting = useSelector(sortingAtom) +const pagination = useSelector(paginationAtom) + +const table = createTable({ + _features, + _rowModels: { ... }, + columns, + get data() { return data }, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + }, +}) +``` + +**Do not combine** `state.pagination` with `atoms.pagination` — atoms always win, `state` is +discarded silently, you'll think your callbacks aren't firing. + +## Rendering migration cheat sheet + +```svelte + + + {#if !header.isPlaceholder} + + {/if} + + + + + {#if !header.isPlaceholder} + + {/if} + +``` + +For cells that render a custom Svelte component: + +```svelte + +{ cell: () => MyCellComponent } + + +import { renderComponent } from '@tanstack/svelte-table' +{ cell: ({ row }) => renderComponent(MyCellComponent, { row }) } +``` + +For inline snippets: + +```svelte + +import { renderSnippet } from '@tanstack/svelte-table' + +{#snippet myCell(row)} + {row.original.firstName} +{/snippet} + +{ cell: ({ row }) => renderSnippet(myCell, row) } +``` + +## After the rewrite — verify + +- `pnpm test:types` (or `svelte-check`) catches missing `` generics, + missing `_features` / `_rowModels`, and feature APIs called on tables that didn't register + them. +- `pnpm build` should pass with the new `_features` set. Bundle should shrink — only the + features you register are included. +- Click through every table screen. Reset buttons, multi-sort, pagination reset on filter, + expanded-row count under pagination — these are all the spots where v8 muscle memory + reaches for an option (`autoResetPageIndex` etc.) that's still named the same in v9 but + only takes effect if the matching feature is registered. + +## Common failure modes during migration + +- **Trying to skip the Svelte 5 upgrade.** Will not work. +- **Reaching for `useLegacyTable`.** Doesn't exist in `@tanstack/svelte-table`. That's a + React-only escape hatch. +- **Importing from `@tanstack/table-core` directly.** Re-exported by the adapter — go through + the adapter. +- **Plain `data` instead of `get data()` getter.** Largest single source of "but my data + changed" bugs during migration. +- **Plain `_features: { rowPaginationFeature }` instead of `tableFeatures({...})`.** Loses + inference; you'll get `any` everywhere. +- **Forgetting feature registration after copying the row-model factory.** Adding + `paginatedRowModel: createPaginatedRowModel()` without `rowPaginationFeature` in + `_features` does nothing. +- **Reimplementing v8 helpers** (`flexRender`-style functions, manual store subscriptions, + hand-rolled selectors) instead of using `FlexRender` / `subscribeTable` / atom selectors. + That's the #1 AI tell on this migration. + +## Related skills + +- `tanstack-table/svelte/getting-started` — clean-slate setup. +- `tanstack-table/svelte/table-state` — the v9 state model in depth. +- `tanstack-table/core/state-management` — atom precedence rules. +- `tanstack-table/svelte/production-readiness` — post-migration tuning. diff --git a/packages/svelte-table/skills/svelte/production-readiness/SKILL.md b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md new file mode 100644 index 0000000000..a661216c1a --- /dev/null +++ b/packages/svelte-table/skills/svelte/production-readiness/SKILL.md @@ -0,0 +1,256 @@ +--- +name: svelte/production-readiness +description: > + Ship-ready optimizations for `@tanstack/svelte-table@9` on Svelte 5. Tree-shake by registering + ONLY the `_features` you use; keep `_features`, `columns`, and `data` stable; replace broad + `(state) => state` selectors with narrow projections on `createTable`; reach for + `subscribeTable(atom, selector?)` when only one block of markup should react; lean on rune-aware + atom reads (`table.atoms..get()`) for non-reactive paths; key every `{#each}` block on + stable ids; debounce high-frequency writes with `@tanstack/svelte-pacer`. Svelte 5+ only. +type: lifecycle +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - svelte/table-state +sources: + - TanStack/table:docs/guide/features.md + - TanStack/table:docs/framework/svelte/guide/table-state.md + - TanStack/table:packages/svelte-table/src/createTable.svelte.ts + - TanStack/table:packages/svelte-table/src/subscribe.ts + - TanStack/table:examples/svelte/basic-external-atoms/ + - TanStack/table:examples/svelte/virtualized-rows/ +--- + +# Production Readiness — Svelte + +Once your tables work, this is the checklist for making them fast and small. Most of these are +v9-specific — v8 tables won't have any of these levers. + +## 1. Register only the features you use + +`_features` is the bundle gate. Any feature you don't register is tree-shaken out — including +its state slice, its API surface, and its reactive plumbing. + +```ts +// good — minimal table, ~smallest bundle +const _features = tableFeatures({}) + +// good — feature-by-feature opt-in +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +// bad — kitchen sink, every state slice created even when unused +const _features = tableFeatures({ + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + globalFilteringFeature, + rowExpandingFeature, + rowPaginationFeature, + rowPinningFeature, + rowSelectionFeature, + rowSortingFeature, +}) +``` + +If you find yourself running a table without a feature's UI ever showing, drop the feature. + +## 2. Stable identities for `_features`, `columns`, `data` + +`createTable` syncs options in `$effect.pre`. If any of these identities flip every component +run, the table re-syncs more than it needs to. + +- **`_features` and `_rowModels`**: declare at module scope, not inside the component + function, and never inside `$derived` / `$effect`. +- **`columns`**: same — module scope or `$state.frozen` / a non-reactive `const` in the + component. Reactive recompute of columns is rare and almost always a bug. +- **`data`**: pass with a getter (`get data()`) so the reference is stable when the data + doesn't change. If you're reshaping data inside the component, do it once in a `$derived`, + not on every read. + +```svelte + +``` + +## 3. Narrow the `table.state` selector + +The default selector is `(state) => state`. That makes `table.state` re-run any consumer when +**any** slice changes. Pass a focused selector and you only re-render markup that actually +depends on that slice. + +```ts +// good — only re-projects when pagination changes +const table = createTable(options, (state) => ({ + pagination: state.pagination, +})) + +// even better — only what your UI actually reads +const table = createTable(options, (state) => ({ + pageIndex: state.pagination.pageIndex, + pageSize: state.pagination.pageSize, +})) +``` + +If different parts of the UI need different slices, **don't** widen the selector — use +`subscribeTable` instead (next section). + +## 4. Reach for `subscribeTable` for fine-grained reactivity + +`subscribeTable(source, selector?)` is the per-block subscription. It returns an object whose +`.current` re-runs only when the selected value changes (shallow compared). + +```svelte + + + +``` + +Use it inside per-row components — the row block re-renders only on its own selection toggle, +not on every row's toggle. + +## 5. Non-reactive reads where you don't need reactivity + +Inside event handlers, derived calculations, or one-shot logic, read atoms directly. Cheaper +than subscribing. + +```ts +function exportSelected() { + const selection = table.atoms.rowSelection.get() + const selectedIds = Object.keys(selection).filter((id) => selection[id]) + // ... +} +``` + +`table.store.state` is the same idea for a full snapshot. + +## 6. Key every `{#each}` block on a stable id + +Svelte without keys recreates nodes on reorder. Result: lost input focus, lost scroll, lost +component state. Every TanStack Table loop has a stable id. + +```svelte +{#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + ... + {/each} + +{/each} + +{#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + ... + {/each} + +{/each} +``` + +## 7. Don't fight `$effect.pre` + +`createTable` is already syncing options in `$effect.pre`. Don't write a second `$effect` +that calls `table.setOptions` — it'll race with the built-in sync and may render with stale +state. + +If you need to react to an option change with a side effect, put the side effect in your own +`$effect`, not the option write. + +## 8. Debounce high-frequency writes + +Two places will hammer table state at keystroke / pointermove rate: + +- **Filter inputs.** Wrap `setFilterValue` calls with a debounced callback. +- **Column resizing.** v9 commits `columnSizing` continuously by default; use the resize-end + commit mode or debounce. + +```ts +import { createDebouncer } from '@tanstack/svelte-pacer/debouncer' + +const debouncedSetFilter = createDebouncer( + (value: string) => column.setFilterValue(value), + { wait: 200 }, +) +``` + +See the `compose-with-tanstack-pacer` skill for full examples. + +## 9. Virtualization for large datasets + +`getRowModel().rows.length > ~1000` and rows are simple? Performance is fine without +virtualization. Above that, or with heavy per-row markup, use `@tanstack/svelte-virtual`. See +the `compose-with-tanstack-virtual` skill. + +## 10. Don't ship debug flags + +`debugTable: true`, `debugRows`, `debugHeaders` all log. Strip them or gate on `import.meta.env.DEV`. + +## 11. Don't reimplement built-ins + +The #1 production-readiness regression we see in audits: somebody hand-rolled the thing the +table already does. If you're writing any of these, register the feature instead. + +- Hand-rolled sort comparator across rows → `rowSortingFeature` + `createSortedRowModel` +- Hand-rolled page math (`rows.slice(start, end)`) → `rowPaginationFeature` + + `createPaginatedRowModel` +- Hand-rolled selection toggle (`selected[row.id] = !selected[row.id]`) → `rowSelectionFeature` +- Hand-rolled column hide map → `columnVisibilityFeature` +- Hand-rolled column resizer → `columnResizingFeature` +- Hand-rolled debounced filter that doesn't update through `setFilterValue` → + `columnFilteringFeature` + pacer + +Each rewrite breaks tree-shaking, breaks the reset APIs, and breaks devtools introspection. + +## Quick smoke test before shipping + +- Bundle: does the table chunk match the features you registered? (`pnpm build` and inspect.) +- DevTools profiler: clicking sort triggers exactly one re-render of the headers and rows, + not every consumer of `table.state`. +- Resize / filter: no jank, no per-keystroke server hits (pacer / debounce). +- Reload: state restored from your atom / URL / storage, no flicker. +- Stress: 100k-row dataset with virtualization stays interactive. + +## Related skills + +- `tanstack-table/svelte/table-state` — selectors, atoms, subscribeTable. +- `tanstack-table/svelte/compose-with-tanstack-pacer` — debounce patterns. +- `tanstack-table/svelte/compose-with-tanstack-virtual` — virtualization. +- `tanstack-table/svelte/compose-with-tanstack-store` — atom ownership patterns. diff --git a/packages/svelte-table/skills/svelte/table-state/SKILL.md b/packages/svelte-table/skills/svelte/table-state/SKILL.md new file mode 100644 index 0000000000..e9fbb22030 --- /dev/null +++ b/packages/svelte-table/skills/svelte/table-state/SKILL.md @@ -0,0 +1,441 @@ +--- +name: svelte/table-state +description: > + Svelte 5 rune-based reactivity for TanStack Table v9. Covers `createTable(options, selector?)`, + the `table.state` selector projection, fine-grained `subscribeTable(atom, selector?)` with `.current`, + reading and writing `table.atoms` / `table.baseAtoms`, the `svelteReactivity()` bridge that backs + readonly atoms with `$derived.by` and writable atoms with `$state`, and the `$effect.pre` option + sync. State ownership: `initialState`, `state` + `on[State]Change`, or external `atoms` from + `@tanstack/svelte-store` (`createAtom`, `useSelector`). Svelte 5+ only — no Svelte 3/4 support. +type: framework +library: tanstack-table +framework: svelte +library_version: '9.0.0-alpha.47' +requires: + - state-management + - setup +sources: + - TanStack/table:docs/framework/svelte/svelte-table.md + - TanStack/table:docs/framework/svelte/guide/table-state.md + - TanStack/table:packages/svelte-table/src/createTable.svelte.ts + - TanStack/table:packages/svelte-table/src/createTableHook.svelte.ts + - TanStack/table:packages/svelte-table/src/reactivity.svelte.ts + - TanStack/table:packages/svelte-table/src/subscribe.ts + - TanStack/table:examples/svelte/basic-create-table/ + - TanStack/table:examples/svelte/basic-external-atoms/ + - TanStack/table:examples/svelte/basic-external-state/ +--- + +# Svelte Table State, `subscribeTable` & `createTableHook` + +> **TanStack Table is a state-management coordinator for table state.** Understanding how state +> flows through atoms, runes, and selectors is foundational to everything else you do with the +> Svelte adapter. + +## Critical: Svelte 5+ only + +`@tanstack/svelte-table@9` requires **Svelte 5 or newer**. The adapter is built on runes +(`$state`, `$derived.by`, `$effect.pre`). For Svelte 3/4 projects, stay on +`@tanstack/svelte-table@8` — there is no v9 path that supports the legacy stores API. + +## How v9 state is wired in Svelte + +A table instance has three (and a half) state surfaces: + +- `table.baseAtoms.` — writable atoms created from the resolved initial state. +- `table.atoms.` — readonly derived atoms, exposed per registered feature. +- `table.store` — readonly flat TanStack Store, a derived view of all registered atoms. +- `table.state` — the value returned by the optional selector passed as the second argument to + `createTable`. **Svelte-only surface.** + +The Svelte adapter installs `svelteReactivity()` as the `coreReativityFeature`: + +| Core concept | Svelte binding | +| ------------- | --------------- | +| readonly atom | `$derived.by()` | +| writable atom | `$state` | +| subscription | rune `$effect` | +| option sync | `$effect.pre` | +| batch | `flushSync` | + +`createTable` reads reactive option getters inside `$effect.pre` so the table sees fresh data, +columns, and controlled state **before** the DOM renders — `getRowModel()` is never a frame behind. + +## Feature-based state — registered features only + +State slices only exist for the features registered in `_features`. Reading +`table.atoms.rowSelection` without `rowSelectionFeature` is a TypeScript error and a runtime +`undefined`. **This is the most common v9 mistake.** + +```ts +import { + createTable, + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + createPaginatedRowModel, + createSortedRowModel, + sortFns, +} from '@tanstack/svelte-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +const table = createTable({ + _features, + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + }, + columns, + get data() { + return data + }, +}) + +table.atoms.pagination.get() // ok +table.atoms.sorting.get() // ok +// table.atoms.rowSelection // TypeScript error +``` + +## Reading state — pick the right tool for the job + +### Current value, no reactivity + +Read the atom directly. Cheapest path; only reactive when called inside a rune-tracked context. + +```ts +const sorting = table.atoms.sorting.get() +const pagination = table.atoms.pagination.get() +const flat = table.store.state +``` + +### Reactive read inside markup — `table.state` selector + +Pass a TanStack Store selector as the second argument to `createTable`. The selected value is +exposed as `table.state`. The default selector returns the full registered state. + +```svelte + + + + Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()} + +``` + +### Fine-grained — `subscribeTable` + +`subscribeTable(source, selector?)` wraps a `useSelector` with `shallow` compare and returns an +object whose `.current` is the selected value. Use it when only one block of markup should +re-render on a state change. + +```ts +import { subscribeTable } from '@tanstack/svelte-table' + +const pageIndex = subscribeTable(table.atoms.pagination, (p) => p.pageIndex) +``` + +```svelte +Page {pageIndex.current + 1} +``` + +## Setting state — APIs first, atoms last + +Use the feature APIs. They handle updaters, external-atom routing, and validation: + +```ts +table.nextPage() +table.previousPage() +table.setPageIndex(0) +table.setPageSize(25) +table.setSorting([{ id: 'age', desc: true }]) +column.toggleVisibility() +row.toggleSelected() +``` + +Direct base-atom writes are a last resort: + +```ts +table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) +``` + +When a slice is owned by an external atom (passed through `atoms`), write to the external atom — +`table.atoms.` will read from it, not from `baseAtoms`. + +## State ownership — three patterns + +### 1. Initial state only + +The default: set starting values, let the table own the rest. `initialState` also drives +`resetSorting()`, `resetPagination()`, etc. + +```ts +const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { + return data + }, + initialState: { + sorting: [{ id: 'age', desc: true }], + pagination: { pageIndex: 0, pageSize: 25 }, + }, +}) +``` + +### 2. External atoms (recommended for shared state) + +When the app should own a slice, create a stable atom with `createAtom`, pass it through `atoms`, +and subscribe with `useSelector` or `subscribeTable`. The table writes through the external atom +on `setPageIndex`, `setSorting`, etc. + +```ts +import { createAtom, useSelector } from '@tanstack/svelte-store' +import { + createTable, + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + type PaginationState, + type SortingState, +} from '@tanstack/svelte-table' + +const _features = tableFeatures({ + rowPaginationFeature, + rowSortingFeature, +}) + +const sortingAtom = createAtom([]) +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) + +const sorting = useSelector(sortingAtom) +const pagination = useSelector(paginationAtom) + +const table = createTable({ + _features, + _rowModels: {}, + columns, + get data() { + return data + }, + atoms: { + sorting: sortingAtom, + pagination: paginationAtom, + }, +}) +``` + +When you use `atoms` for a slice, **do not** pair it with the matching `on[State]Change` callback. + +### 3. External `state` + `on[State]Change` (migration / simple cases) + +Classic pattern, still supported. Use Svelte 5 `$state` and getter properties so the table sees +updates. + +```svelte + +``` + +> The v8-style monolithic `onStateChange` is gone in v9. Use per-slice `on[State]Change` or, better, +> external atoms. + +### Precedence — do not mix sources + +External `atoms` win over external `state`. External `state` syncs into the internal base atom. +For any given slice, pick **one** source of truth. Don't pass `initialState.pagination` and +`atoms.pagination` and `state.pagination` together. + +## Rendering — `FlexRender` + +`FlexRender` handles `header`, `cell`, and `footer` definitions whether they're plain strings, +Svelte components wrapped with `renderComponent`, or snippets wrapped with `renderSnippet`. + +```svelte + + + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + {/each} + + {/each} + +``` + +Always key `{#each}` blocks on stable ids (`headerGroup.id`, `header.id`, `row.id`, `cell.id`). + +## `createTableHook` — app-wide composition + +Create one configured hook per app: shared `_features`, `_rowModels`, defaults, and pre-bound +component registries. + +```ts +import { + createPaginatedRowModel, + createSortedRowModel, + createTableHook, + rowPaginationFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/svelte-table' +import TextCell from './cells/TextCell.svelte' +import SortIndicator from './headers/SortIndicator.svelte' + +export const { + createAppTable, + createAppColumnHelper, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + _features: tableFeatures({ rowPaginationFeature, rowSortingFeature }), + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(sortFns), + }, + cellComponents: { TextCell }, + headerComponents: { SortIndicator }, +}) +``` + +In components: + +```svelte + + + + {#snippet children()} + + + {#each table.getHeaderGroups() as group (group.id)} + + {#each group.headers as header (header.id)} + + {#snippet children(h)} + + {/snippet} + + {/each} + + {/each} + +
+ {/snippet} +
+``` + +Inside custom `cellComponents` / `headerComponents` / `tableComponents`, use `useCellContext()` / +`useHeaderContext()` / `useTableContext()` instead of prop-drilling. + +## Common failure modes + +- **Svelte 4 code with v9 adapter.** Will not run. `$state` / `$derived.by` are Svelte 5 syntax. +- **`createSvelteTable` import.** v8 name. v9 uses `createTable`. There is no `useSvelteTable`, + `getCoreRowModel`, `getSortedRowModel`, etc. — those are v8 names too. +- **Forgetting `tableFeatures()`.** `_features` must come from `tableFeatures({...})` for type + inference; passing a raw object loses the typed state slice keys. +- **Forgetting feature registration.** Calling `table.setSorting(...)` without + `rowSortingFeature` in `_features` is a runtime no-op (the API method won't exist). +- **Mixing ownership.** `atoms.pagination` + `state.pagination` + `initialState.pagination` is + ambiguous; the table will not "merge" them the way you expect. +- **Reactive getters dropped.** If you pass `data` as a plain value instead of a getter, the + table won't re-render when `data` changes. Always use `get data() { return data }`. +- **Reimplementing built-ins.** If you're hand-rolling sorting comparators, pagination math, or + selection toggles, you're skipping `rowSortingFeature` / `rowPaginationFeature` / + `rowSelectionFeature` and their reset / state APIs. Register the feature instead. + +## Related skills + +- `tanstack-table/core/state-management` — atom model, slice precedence, base vs derived atoms. +- `tanstack-table/svelte/getting-started` — end-to-end first table. +- `tanstack-table/svelte/compose-with-tanstack-store` — direct atom interop. +- `tanstack-table/svelte/production-readiness` — selector / subscription tuning. diff --git a/packages/table-core/README.md b/packages/table-core/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/table-core/README.md +++ b/packages/table-core/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/table-core/package.json b/packages/table-core/package.json index c2b053d144..1d9544e653 100644 --- a/packages/table-core/package.json +++ b/packages/table-core/package.json @@ -23,7 +23,8 @@ "angular", "table", "table-core", - "datagrid" + "datagrid", + "tanstack-intent" ], "type": "module", "types": "./dist/index.d.cts", @@ -58,7 +59,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", diff --git a/packages/table-core/skills/column-definitions/SKILL.md b/packages/table-core/skills/column-definitions/SKILL.md new file mode 100644 index 0000000000..8340fa50cf --- /dev/null +++ b/packages/table-core/skills/column-definitions/SKILL.md @@ -0,0 +1,333 @@ +--- +name: column-definitions +description: > + Define TanStack Table v9 columns with `createColumnHelper()`. + Covers `columnHelper.accessor` (key + function forms), `columnHelper.display`, + `columnHelper.group`, `columnHelper.columns`, the `ColumnDef`/`AccessorKeyColumnDef`/ + `AccessorFnColumnDef`/`DisplayColumnDef`/`GroupColumnDef` types, `accessorKey` with + `DeepKeys`, `accessorFn`, the `header`/`cell`/`footer`/`aggregatedCell` renderers, + required `id` rules, and `getRowId` for stable row identity. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +sources: + - TanStack/table:docs/guide/column-defs.md + - TanStack/table:docs/guide/columns.md + - TanStack/table:packages/table-core/src/helpers/columnHelper.ts + - TanStack/table:packages/table-core/src/core/columns/constructColumn.ts + - TanStack/table:examples/react/basic-use-table/src/main.tsx +--- + +## Setup + +`createColumnHelper` takes TWO generics in v9: the features type (so accessor keys, sort/filter strings, etc. are typed against your registered features) and the row data type. + +```ts +import { + createColumnHelper, + tableFeatures, + rowSortingFeature, +} from '@tanstack/table-core' + +type Person = { + id: string + firstName: string + lastName: string + age: number + visits: number +} + +const _features = tableFeatures({ rowSortingFeature }) + +// TFeatures FIRST, TData SECOND +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + // accessorKey — deep keys via DeepKeys (dot paths) are supported + columnHelper.accessor('firstName', { header: 'First Name' }), + columnHelper.accessor('lastName', { header: 'Last Name' }), + columnHelper.accessor('age', { header: 'Age' }), + + // accessorFn — needs an explicit `id` + columnHelper.accessor((row) => `${row.firstName} ${row.lastName}`, { + id: 'fullName', + header: 'Full Name', + cell: (info) => info.getValue(), + }), + + // display column — no value extraction, just rendering + columnHelper.display({ + id: 'actions', + header: 'Actions', + cell: ({ row }) => `Edit ${row.original.id}`, + }), + + // group column — wraps child columns under a shared header + columnHelper.group({ + id: 'stats', + header: 'Stats', + columns: [columnHelper.accessor('visits', { header: 'Visits' })], + }), +]) +``` + +## Core Patterns + +### Stable row identity with `getRowId` + +```ts +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, // ← stable from row's own data +}) +``` + +Without `getRowId`, `row.id` defaults to the row's array index. Row-keyed state (selection, expansion, pinning) then attaches to whatever happens to be at that index after a sort/filter/refetch. + +### Accessor key with deep path + +```ts +type User = { name: { first: string; last: string } } + +const columnHelper = createColumnHelper() + +columnHelper.accessor('name.first', { header: 'First' }) +columnHelper.accessor('name.last', { header: 'Last' }) +``` + +For nested objects with non-optional intermediate keys, the dotted `accessorKey` form works and infers the right value type. Switch to `accessorFn` when intermediates are optional (see Common Mistakes below). + +### Header / cell / footer renderers + +```ts +columnHelper.accessor('age', { + header: () => 'Age', + cell: (info) => info.getValue(), + footer: (info) => `${info.table.getRowModel().rows.length} rows`, +}) +``` + +Renderers accept string, JSX (in framework adapters), or function forms. Render via `flexRender(def, ctx)` or `` so all three forms work uniformly. + +### `columnHelper.columns([...])` for module-scope stability + +```ts +// Outside any component / hook — stable reference forever +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), +]) +``` + +`columnHelper.columns` returns the array as-is but preserves the precise tuple types. Hoist to module scope or wrap in `useMemo` — the table compares `columns` by reference. + +## Common Mistakes + +### [CRITICAL] Passing only `TData` to `createColumnHelper` + +Wrong: + +```ts +// v8 signature — TData ends up in the TFeatures slot +const columnHelper = createColumnHelper() +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() +``` + +v9 changed the generic order: ``. The compiler error is noisy because `Person` lands in the `TFeatures` slot and breaks every column type that follows. + +Source: packages/table-core/src/helpers/columnHelper.ts; docs/framework/react/guide/migrating.md + +### [HIGH] Accessor function returns an object or array + +Wrong: + +```ts +// returns an object — built-in alphanumeric sort and includesString filter break +columnHelper.accessor((row) => row.name, { + id: 'name', + cell: (info) => `${info.getValue().first} ${info.getValue().last}`, +}) +``` + +Correct: + +```ts +// accessor returns a primitive; cell can still format it +columnHelper.accessor((row) => `${row.name.first} ${row.name.last}`, { + id: 'fullName', + cell: (info) => info.getValue(), +}) +``` + +The accessed value drives sorting, filtering, faceting, and grouping. Built-in `sortFn`/`filterFn`/`aggregationFn` expect a primitive `string` / `number` / `Date`. Return a primitive — or supply a matching custom function. + +Source: docs/guide/column-defs.md + +### [CRITICAL] Omitting `id` on an `accessorFn` column + +Wrong: + +```tsx +// accessorFn + JSX header => no id can be derived +columnHelper.accessor((row) => row.lastName, { + header: () => Last Name, + cell: (info) => info.getValue(), +}) +``` + +Correct: + +```tsx +columnHelper.accessor((row) => row.lastName, { + id: 'lastName', // required when there's no string accessorKey or string header + header: () => Last Name, + cell: (info) => info.getValue(), +}) +``` + +The constructor throws "coreColumnsFeature require an id when using an accessorFn" in development. The same applies to non-string `header` values without a fallback `id`. + +Source: packages/table-core/src/core/columns/constructColumn.ts + +### [CRITICAL] Defining `columns` inside the component without `useMemo` + +Wrong: + +```tsx +function MyTable() { + // new array reference every render → infinite render loop + const columns = [ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + ] + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Correct: + +```tsx +function MyTable() { + const columns = React.useMemo( + () => + columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + ]), + [], + ) + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +TanStack Table compares `columns` and `data` by reference. The #1 FAQ entry across versions. + +Source: docs/faq.md; examples/react/basic-subscribe/src/main.tsx + +### [HIGH] Using array-index row IDs with mutating data + +Wrong: + +```ts +// no getRowId — rowSelection survives data updates but maps to wrong rows +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + enableRowSelection: true, +}) +``` + +Correct: + +```ts +const table = useTable({ + _features, + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, + enableRowSelection: true, +}) +``` + +When `data` reorders, filters, or items are removed/refetched, row-keyed state (selection, expansion, pinning) attaches to the wrong row. + +Source: docs/guide/rows.md; packages/table-core/src/core/rows/coreRowsFeature.utils.ts + +### [MEDIUM] `accessorKey` with optional path strips `undefined` from `getValue` type + +Wrong: + +```ts +// amount inferred as `number` even though salary is optional +columnHelper.accessor('user.salary.amount', { + cell: (info) => { + const amount = info.getValue() // type: number (WRONG) + return amount.toFixed(2) // crashes when salary is undefined + }, +}) +``` + +Correct: + +```ts +columnHelper.accessor((row) => row.user.salary?.amount, { + id: 'salary', + cell: (info) => { + const amount = info.getValue() // type: number | undefined + return amount?.toFixed(2) ?? '-' + }, +}) +``` + +The `DeepValue` type doesn't propagate `undefined` through optional intermediates. Use `accessorFn` when any segment is optional — the type follows the expression. + +Source: https://github.com/TanStack/table/issues/6238 + +### [MEDIUM] `columnHelper.accessor` nested inside `columnHelper.group` loses `getValue` inference + +Wrong: + +```ts +// info.getValue() inferred as unknown +columnHelper.group({ + id: 'name', + columns: [ + columnHelper.accessor('firstName', { + cell: (info) => info.getValue(), // unknown + }), + ], +}) +``` + +Correct: + +```ts +// Hoist accessor definitions out of the group +const firstNameCol = columnHelper.accessor('firstName', { + cell: (info) => info.getValue(), // string +}) + +columnHelper.group({ id: 'name', columns: [firstNameCol] }) +``` + +The group helper's overloads don't thread `TData` through correctly when accessors are defined inline. + +Source: https://github.com/TanStack/table/issues/5860 + +## See also + +- `tanstack-table/setup` — how `_features` and `_rowModels` thread through `useTable` +- `tanstack-table/customizing-feature-behavior` — per-column `sortFn`/`filterFn`/`aggregationFn` +- `tanstack-table/row-selection` — why `getRowId` is essentially mandatory diff --git a/packages/table-core/skills/column-layout/SKILL.md b/packages/table-core/skills/column-layout/SKILL.md new file mode 100644 index 0000000000..a8a46a4cb6 --- /dev/null +++ b/packages/table-core/skills/column-layout/SKILL.md @@ -0,0 +1,328 @@ +--- +name: column-layout +description: > + The five UI-state-only column features in TanStack Table v9 that shape how + columns render — visibility, ordering, pinning, sizing, resizing. None + require a row model. Covers `columnVisibilityFeature` (getVisibleLeafColumns, + row.getVisibleCells), `columnOrderingFeature` (columnOrder string[], column.getIndex), + `columnPinningFeature` (left/right ColumnPinningState, column.pin, column.getStart / + getAfter, split-table getLeft*/getCenter*/getRight* APIs, sticky-CSS pattern; + `enableColumnPinning` table-level option distinct from per-column `enablePinning`), + `columnSizingFeature` (defaultColumnSizing, column.getSize, table.getTotalSize), + `columnResizingFeature` (columnResizeMode 'onEnd'/'onChange', columnResizeDirection + 'ltr'/'rtl', header.getResizeHandler for mouse + touch, CSS-variable + performant resize pattern). Pipeline: Column Pinning → columnOrder → Grouping. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/guide/column-visibility.md + - TanStack/table:docs/guide/column-ordering.md + - TanStack/table:docs/guide/column-pinning.md + - TanStack/table:docs/guide/column-sizing.md + - TanStack/table:docs/guide/column-resizing.md + - TanStack/table:examples/react/column-visibility/src/main.tsx + - TanStack/table:examples/react/column-resizing/src/main.tsx + - TanStack/table:examples/react/column-resizing-performant/src/main.tsx + - TanStack/table:examples/react/column-pinning-split/src/main.tsx + - TanStack/table:examples/react/column-pinning-sticky/src/main.tsx + - TanStack/table:examples/react/column-dnd/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`. Read it first for the atom model — these are UI-state-only features (no row model). + +## Setup + +All five features are opt-in via `tableFeatures({...})`. The reorder pipeline is fixed: **(1) Column Pinning splits into left/center/right → (2) `columnOrder` reorders the center → (3) Grouping (`groupedColumnMode: 'reorder' | 'remove'`) may move grouped columns to the front.** + +```ts +import { + tableFeatures, + columnVisibilityFeature, + columnOrderingFeature, + columnPinningFeature, + columnSizingFeature, + columnResizingFeature, + constructTable, +} from '@tanstack/table-core' + +const _features = tableFeatures({ + columnVisibilityFeature, + columnOrderingFeature, + columnPinningFeature, + columnSizingFeature, + columnResizingFeature, // explicit — formerly part of v8 ColumnSizing +}) + +const table = constructTable({ + _features, + _rowModels: {}, // no row model needed for these features + columns, + data, + initialState: { + columnVisibility: {}, + columnOrder: [], + columnPinning: { left: [], right: [] }, + columnSizing: {}, + }, +}) +``` + +## Subsystems + +| Feature | State slice | Key APIs | +| ------------------------- | ---------------------- | ---------------------------------------------------- | +| `columnVisibilityFeature` | `columnVisibility` | `column.toggleVisibility()`, `row.getVisibleCells()` | +| `columnOrderingFeature` | `columnOrder` | `table.setColumnOrder()`, `column.getIndex()` | +| `columnPinningFeature` | `columnPinning` (l/r) | `column.pin()`, `column.getStart()`, `getAfter()` | +| `columnSizingFeature` | `columnSizing` | `column.getSize()`, `table.getTotalSize()` | +| `columnResizingFeature` | (transient drag state) | `header.getResizeHandler()`, `columnResizeMode` | + +Full API surface, render strategies, and additional MEDIUM-priority failure modes (reorder-pinned-via-columnOrder, react-dnd/react-beautiful-dnd avoidance, touch-resize handler) in [subsystems.md](references/subsystems.md). + +## Core Patterns + +### Performant `'onChange'` resize (React) + +```tsx +// From examples/react/column-resizing-performant/src/main.tsx +const columnSizeVars = React.useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: number } = {} + for (const header of headers) { + colSizes[`--header-${header.id}-size`] = header.getSize() + colSizes[`--col-${header.column.id}-size`] = header.column.getSize() + } + return colSizes +}, [table.state.columnResizing, table.state.columnSizing]) + +
+ {table.store.state.columnResizing.isResizingColumn + ? + : } +
+ +// Body cells use the CSS variable (no per-cell getSize() call) +
+ {cell.renderValue()} +
+ +export const MemoizedTableBody = React.memo( + TableBody, + (prev, next) => prev.table.options.data === next.table.options.data, +) +``` + +## Common Mistakes + +### [HIGH] Rendering body cells with `row.getAllCells()` while visibility is registered + +Wrong: + +```tsx +// Toggling visibility has no effect on rendered cells +{ + table.getAllLeafColumns().map((column) => ...) +} +{ + row.getAllCells().map((cell) => ( + + + + )) +} +``` + +Correct: + +```tsx +// Header groups already respect visibility; use them for headers. +// For body cells, swap getAllCells → getVisibleCells. + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : } + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +``` + +The `getAll*` accessors do NOT consult `columnVisibility` state. Only `Visible` variants and header-group APIs filter by visibility. + +Source: docs/guide/column-visibility.md; examples/react/column-visibility/src/main.tsx + +### [HIGH] `columnResizeMode: 'onChange'` + `column.getSize()` per cell + un-memoized body + +Wrong: + +```tsx +const table = useTable({ + _features: tableFeatures({ columnSizingFeature, columnResizingFeature }), + columnResizeMode: 'onChange', +}) +{cell.renderValue()} +``` + +Correct: + +```tsx +// See "Performant 'onChange' resize" above — CSS variables + memoized body +const columnSizeVars = React.useMemo(() => { + /* … */ +}, [table.state.columnResizing, table.state.columnSizing]) + +{ + table.store.state.columnResizing.isResizingColumn ? ( + + ) : ( + + ) +} + +;
+``` + +`'onChange'` commits a new `columnSizing` map on every pointer move. Per-cell `getSize()` blows the 16ms frame budget. The CSS-variable pattern caches widths once per resize batch. + +Source: docs/guide/column-resizing.md; examples/react/column-resizing-performant/src/main.tsx + +### [HIGH] Using v8 `enablePinning` at the table level + +Wrong: + +```ts +// v8 syntax — no longer disables pinning at table level in v9 +const table = useTable({ + _features: tableFeatures({ columnPinningFeature }), + enablePinning: false, // ignored +}) +``` + +Correct: + +```ts +// v9 split: two distinct table-level options +const table = useTable({ + _features: tableFeatures({ columnPinningFeature, rowPinningFeature }), + enableColumnPinning: false, + enableRowPinning: false, +}) + +// Per-column opt-out is still spelled `enablePinning`: +columnHelper.accessor('id', { + enablePinning: false, // this column can't be pinned +}) +``` + +v9 split `enablePinning` into `enableColumnPinning` and `enableRowPinning`. The bare name now refers ONLY to per-column opt-out. + +Source: packages/table-core/src/features/column-pinning/columnPinningFeature.types.ts + +### [HIGH] Defining `columns` inline (infinite loop once a layout feature commits state) + +Wrong: + +```tsx +function App() { + const columns = [ + columnHelper.accessor('firstName', { + /* … */ + }), + columnHelper.accessor('lastName', { + /* … */ + }), + ] + const table = useTable({ + _features: tableFeatures({ columnPinningFeature, columnResizingFeature }), + columns, + data, + }) +} +``` + +Correct: + +```tsx +const defaultColumns = columnHelper.columns([ + columnHelper.accessor('firstName', { + /* … */ + }), + columnHelper.accessor('lastName', { + /* … */ + }), +]) +function App() { + const [columns] = React.useState(() => [...defaultColumns]) + const table = useTable({ _features, columns, data }) +} + +// or: useMemo +const columns = React.useMemo( + () => + columnHelper.columns([ + /* … */ + ]), + [], +) +``` + +Layout features commit state on every interaction. An inline `columns` array gets a new identity each render → table rebuild → another render. FAQ Pitfall 1. + +Source: docs/faq.md; examples/react/column-pinning-split/src/main.tsx; examples/react/column-dnd/src/main.tsx + +### [CRITICAL] Reimplementing visibility / pinning / resize logic manually + +Wrong: + +```ts +// Hand-rolled hide/show with a separate set +const [hidden, setHidden] = useState(new Set()) +const visibleColumns = useMemo( + () => columns.filter((c) => !hidden.has(c.id)), + [columns, hidden], +) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ columnVisibilityFeature }), + _rowModels: {}, + columns, + data, +}) +column.toggleVisibility() +column.getIsVisible() +table.getVisibleLeafColumns() +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/state-management` — `state.columnVisibility` / `columnOrder` / `columnPinning` / `columnSizing` slices +- `tanstack-table/row-pinning` — analogous pinning for rows (different render pipeline) +- `tanstack-table/grouping` — `groupedColumnMode` interacts with `columnOrder` + +## References + +- [subsystems.md](references/subsystems.md) — full API surface per UI-state subsystem (visibility, ordering, pinning, sizing, resizing) plus MEDIUM-priority failure modes: reorder-pinned-via-`columnOrder`, react-dnd / react-beautiful-dnd avoidance, touch-resize handler diff --git a/packages/table-core/skills/column-layout/references/subsystems.md b/packages/table-core/skills/column-layout/references/subsystems.md new file mode 100644 index 0000000000..7e14d93a1d --- /dev/null +++ b/packages/table-core/skills/column-layout/references/subsystems.md @@ -0,0 +1,220 @@ +# Column-layout subsystems — full API surface + +Detailed reference for the five UI-state-only column features extracted from `SKILL.md`. The SKILL keeps a 2-line summary table linking here; this file documents each subsystem in detail. + +## Visibility — `columnVisibilityFeature` + +State: `columnVisibility: Record` — missing or `true` means visible. + +```tsx +// Visibility toggle panel +{ + table.getAllLeafColumns().map((column) => ( + + )) +} + +// Body — use Visible variants, NOT getAllLeafColumns / getAllCells +; + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + + + ))} + + ))} + +``` + +## Ordering — `columnOrderingFeature` + +State: `columnOrder: string[]` of leaf column ids. Empty means definition order. **Scoped to UNPINNED columns** when pinning is active — pinned columns are sequenced inside `columnPinning.left/right`. + +```ts +table.setColumnOrder(['firstName', 'lastName', 'age']) +column.getIndex('center') // ← position +column.getIsFirstColumn() +column.getIsLastColumn() +``` + +For drag-and-drop with `@dnd-kit/core`, see the "Common Mistakes" entry on dnd libraries in the SKILL — `DndContext` must wrap from OUTSIDE the ``. + +## Pinning — `columnPinningFeature` + +State: `columnPinning: { left: string[]; right: string[] }`. Two render strategies: + +```tsx +// Strategy A — split tables + + {table.getLeftHeaderGroups().map(/* … */)} + +// + getCenterHeaderGroups / getRightHeaderGroups +// + row.getLeftVisibleCells / getCenterVisibleCells / getRightVisibleCells + +// Strategy B — single table + sticky CSS + + +// Toggle a pin programmatically +column.pin('left') // or 'right' | false +``` + +## Sizing — `columnSizingFeature` + +State: `columnSizing: Record` (pixels). Defaults via `defaultColumnSizing` ({ size: 150, minSize: 20, maxSize: Number.MAX_SAFE_INTEGER }) or `tableOptions.defaultColumn` globally. + +```ts +columnHelper.accessor('firstName', { + size: 200, + minSize: 80, + maxSize: 400, +}) + +// Reads +column.getSize() // committed size (clamped) +header.getSize() // same, for groups sums children +table.getTotalSize() +table.getCenterTotalSize() +column.resetSize() // drop the override +``` + +## Resizing — `columnResizingFeature` + +```tsx +// Wire BOTH onMouseDown AND onTouchStart on the resize handle +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`} +/> + +// Modes: +// columnResizeMode: 'onEnd' (default) — commit on drag release; safer for big React tables +// columnResizeMode: 'onChange' — commit live; needs the perf pattern in SKILL +// columnResizeDirection: 'ltr' (default) | 'rtl' +``` + +## Additional MEDIUM-priority failure modes + +### Trying to reorder pinned columns via `columnOrder` + +Wrong: + +```ts +// Won't move 'actions' relative to 'firstName' while it's pinned right +const [columnPinning] = useState({ + left: ['select'], + right: ['actions'], +}) +table.setColumnOrder(['actions', 'select', 'firstName', 'lastName']) +``` + +Correct: + +```ts +// Reorder the pinning state itself +table.setColumnPinning((old) => ({ + left: ['select'], + right: ['summary', 'actions'], // 'summary' renders before 'actions' +})) + +// columnOrder works normally for the unpinned center region +table.setColumnOrder(['firstName', 'lastName']) +``` + +After the pipeline's pinning split, the left/right partitions read directly from `state.columnPinning.left/right`. `columnOrder` only affects the center. + +Source: docs/guide/column-ordering.md; packages/table-core/src/features/column-pinning/columnPinningFeature.utils.ts + +### Using `react-dnd` / `react-beautiful-dnd` for column reorder in React 18+ + +Wrong: + +```tsx +// react-dnd in React 18 Strict Mode — flicker and stale drags +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' + +// or nesting DndContext inside
+ ... +
+;
+ + ... + +
+``` + +Correct: + +```tsx +// @dnd-kit + wrap from OUTSIDE the table (DndContext renders divs) + + + + {table.getHeaderGroups().map((hg) => ( + + + {hg.headers.map((h) => ( + + ))} + + + ))} + +
+
+``` + +`react-dnd` has Strict Mode incompatibilities; `react-beautiful-dnd` is in maintenance. dnd-kit is the v9-recommended stack. + +Source: examples/react/column-dnd/src/main.tsx + +### Wiring `header.getResizeHandler()` to only `onMouseDown` + +Wrong: + +```tsx +// Desktop only — mobile users can't resize +
+``` + +Correct: + +```tsx +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${header.column.getIsResizing() ? 'isResizing' : ''}`} +/> +``` + +`header_getResizeHandler` branches internally on `isTouchStartEvent`. The same handler must be installed on both DOM events. + +Source: docs/guide/column-resizing.md; examples/react/column-resizing/src/main.tsx diff --git a/packages/table-core/skills/customizing-feature-behavior/SKILL.md b/packages/table-core/skills/customizing-feature-behavior/SKILL.md new file mode 100644 index 0000000000..55bd556a0d --- /dev/null +++ b/packages/table-core/skills/customizing-feature-behavior/SKILL.md @@ -0,0 +1,416 @@ +--- +name: customizing-feature-behavior +description: > + Override per-column `sortFn`, `filterFn`, `aggregationFn` and table-level + `globalFilterFn` in TanStack Table v9. Covers built-in `filterFns` / `sortFns` / + `aggregationFns` registries (passed to `createFilteredRowModel(filterFns)` / + `createSortedRowModel(sortFns)` / `createGroupedRowModel(aggregationFns)`), + authoring custom functions with the `FilterFn` / `SortFn` / `AggregationFn` + signatures, chaining filter→sort via the `addMeta` callback + + `row.columnFiltersMeta`, `resolveFilterValue`, `autoRemove`, `invertSorting`, + `sortUndefined` ('first'|'last'|-1|1), and `sortDescFirst`. Distinguishes + `aggregationFn` (produces value) from `aggregatedCell` (renders value). +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/guide/sorting.md + - TanStack/table:docs/guide/column-filtering.md + - TanStack/table:docs/guide/fuzzy-filtering.md + - TanStack/table:packages/table-core/src/fns/filterFns.ts + - TanStack/table:packages/table-core/src/fns/sortFns.ts + - TanStack/table:packages/table-core/src/fns/aggregationFns.ts + - TanStack/table:examples/react/filters-fuzzy/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`. Read it first for how feature plugins drive state slices. + +## Setup + +v9 customization happens in three places: + +1. **Built-in function registries** — `filterFns`, `sortFns`, `aggregationFns` — passed as arguments to row-model factories so unused fns tree-shake away. +2. **Per-column overrides** — `columnDef.filterFn`, `columnDef.sortFn`, `columnDef.aggregationFn` (string name OR inline function). +3. **Table-level overrides** — `tableOptions.globalFilterFn`. + +```ts +import { + tableFeatures, + rowSortingFeature, + columnFilteringFeature, + globalFilteringFeature, + columnGroupingFeature, + rowExpandingFeature, + createFilteredRowModel, + createSortedRowModel, + createGroupedRowModel, + createExpandedRowModel, + filterFns, + sortFns, + aggregationFns, + createColumnHelper, +} from '@tanstack/table-core' +import type { FilterFn, SortFn, AggregationFn } from '@tanstack/table-core' +import { + rankItem, + compareItems, + type RankingInfo, +} from '@tanstack/match-sorter-utils' + +type Person = { + id: string + firstName: string + lastName: string + revenue: number + status: 'single' | 'complicated' | 'relationship' +} + +const _features = tableFeatures({ + rowSortingFeature, + columnFilteringFeature, + globalFilteringFeature, + columnGroupingFeature, + rowExpandingFeature, +}) + +// Module augmentation registers custom fn names so columnDef.filterFn typechecks. +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank?: RankingInfo + } +} + +const fuzzyFilter: FilterFn = ( + row, + columnId, + value, + addMeta, +) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed +} + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + filterFn: 'fuzzy', // ← refers to registered name + sortFn: 'alphanumeric', + }), + columnHelper.accessor('revenue', { + aggregationFn: 'sum', + aggregatedCell: (info) => `$${info.getValue().toLocaleString()}`, + }), +]) + +const table = constructTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel({ + ...filterFns, // keep built-ins + fuzzy: fuzzyFilter, // add custom + }), + sortedRowModel: createSortedRowModel(sortFns), + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, + globalFilterFn: 'fuzzy', +}) +``` + +## Core Patterns + +### Pick a built-in `sortFn` by name + direction control + +```ts +columnHelper.accessor('lastName', { + sortFn: 'alphanumeric', + sortDescFirst: false, + sortUndefined: 'last', // ABSOLUTE: always at end regardless of asc/desc +}) +``` + +Layered direction controls: + +- `sortDescFirst: true/false` — first click sorts descending +- `sortUndefined: 'first' | 'last' | -1 | 1 | false` — string forms are absolute; numeric flips with `desc` +- `invertSorting: true` — for "lower-is-better" scales (rank 1 above rank 2 even when descending) + +### Filter → sort handoff via `addMeta` + +```ts +const fuzzyFilter: FilterFn = ( + row, + columnId, + value, + addMeta, +) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed +} + +// Custom sortFn reads the meta the filter stashed +const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir +} + +columnHelper.accessor('fullName', { filterFn: 'fuzzy', sortFn: fuzzySort }) +``` + +`row.columnFiltersMeta` is keyed by the column id that produced the meta (or `'__global__'` for the global filter). The sortFn MUST look up the same column id its filterFn used. + +### Custom `aggregationFn` for grouping + +```ts +import type { AggregationFn } from '@tanstack/table-core' + +// Signature: (columnId, leafRows, childRows) → aggregated value +// leafRows = all descendant non-grouped rows +// childRows = immediate children (may be sub-aggregates at deeper levels) +const weightedMean: AggregationFn = ( + columnId, + leafRows, +) => { + let totalWeight = 0 + let weightedSum = 0 + leafRows.forEach((row) => { + const v = row.getValue(columnId) + const w = row.original.revenue + weightedSum += v * w + totalWeight += w + }) + return totalWeight === 0 ? 0 : weightedSum / totalWeight +} + +const table = constructTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel({ ...aggregationFns, weightedMean }), + expandedRowModel: createExpandedRowModel(), + }, + columns: columnHelper.columns([ + columnHelper.accessor('revenue', { + aggregationFn: 'weightedMean', + aggregatedCell: (info) => `$${info.getValue().toFixed(2)}`, + }), + ]), + data, +}) +``` + +## Common Mistakes + +### [CRITICAL] Referencing a custom `filterFn` by string without registering it + +Wrong: + +```ts +// "fuzzy" string never registered +const table = useTable({ + _features, + columns: [columnHelper.accessor('fullName', { filterFn: 'fuzzy' })], + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), // ❌ no fuzzy + }, + data, +}) +``` + +Correct: + +```ts +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } +} + +const fuzzyFilter: FilterFn = ( + row, + columnId, + value, + addMeta, +) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed +} + +const table = useTable({ + _features, + columns: [columnHelper.accessor('fullName', { filterFn: 'fuzzy' })], + _rowModels: { + filteredRowModel: createFilteredRowModel({ + ...filterFns, + fuzzy: fuzzyFilter, + }), + }, + data, +}) +``` + +String values are looked up in `table._rowModelFns.filterFns`. Unregistered names log `Could not find a valid 'column.filterFn' …` in dev and silently no-op in prod. + +Source: examples/react/filters-fuzzy/src/main.tsx; packages/table-core/src/features/column-filtering/columnFilteringFeature.utils.ts + +### [HIGH] Using v8 `sortingFn` / `sortingFns` names + +Wrong: + +```ts +columnHelper.accessor('age', { + sortingFn: 'alphanumeric', // v8 name — ignored +}) +``` + +Correct: + +```ts +columnHelper.accessor('age', { + sortFn: 'alphanumeric', +}) +``` + +v9 renamed every sorting API: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, type `SortingFn` → `SortFn`, `column.getSortingFn()` → `column.getSortFn()`. The default `sortFn` is `'auto'`, falling back to `sortFn_basic` if the lookup misses — so wrong names sort wrong instead of erroring. + +Source: docs/framework/react/guide/migrating.md; packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts + +### [HIGH] Custom `sortFn` reads filter meta from a different column id + +Wrong: + +```ts +// filter on 'fullName', sort reads meta from 'firstName' +const fuzzySort: SortFn = (a, b, columnId) => { + const meta = a.columnFiltersMeta['firstName'] // ❌ wrong key + return meta + ? compareItems(meta.itemRank, b.columnFiltersMeta['firstName'].itemRank) + : 0 +} +columnHelper.accessor('fullName', { filterFn: 'fuzzy', sortFn: fuzzySort }) +``` + +Correct: + +```ts +const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir +} +``` + +`row.columnFiltersMeta` is keyed by the column id that produced it. Always use the `columnId` argument the sortFn receives. + +Source: examples/react/filters-fuzzy/src/main.tsx + +### [MEDIUM] Returning a complex value from the accessor while using a built-in `sortFn` + +Wrong: + +```ts +// accessor returns object; alphanumeric sees "[object Object]" +columnHelper.accessor((row) => row.name, { + id: 'name', + sortFn: 'alphanumeric', +}) +``` + +Correct: + +```ts +// Option A — return a primitive +columnHelper.accessor((row) => `${row.name.first} ${row.name.last}`, { + id: 'fullName', + sortFn: 'alphanumeric', +}) + +// Option B — custom sortFn that knows the shape +columnHelper.accessor((row) => row.name, { + id: 'name', + sortFn: (a, b, id) => { + const av = a.getValue<{ first: string }>(id).first + const bv = b.getValue<{ first: string }>(id).first + return av === bv ? 0 : av > bv ? 1 : -1 + }, +}) +``` + +Built-in sortFns (`alphanumeric`, `text`, `basic`) coerce via comparison operators. Object accessors collapse to `"[object Object]"` and every row ties. + +Source: packages/table-core/src/fns/sortFns.ts + +### [MEDIUM] Confusing `aggregationFn` with `aggregatedCell` + +Wrong: + +```ts +// rendering JSX inside the aggregation function +columnHelper.accessor('revenue', { + aggregationFn: (id, leaves) => ${leaves.reduce((a, r) => a + r.getValue(id), 0)}, +}) +``` + +Correct: + +```ts +columnHelper.accessor('revenue', { + aggregationFn: 'sum', // returns a value + aggregatedCell: (info) => ${info.getValue().toLocaleString()}, // renders it +}) +``` + +`aggregationFn` produces the grouped-row value (signature `(columnId, leafRows, childRows)`). `aggregatedCell` renders it. Don't combine. + +Source: packages/table-core/src/features/column-grouping/columnGroupingFeature.types.ts + +### [CRITICAL] Reimplementing what built-in APIs provide + +Wrong: + +```ts +// Reimplements sorting state manually instead of using the API +const [sorting, setSorting] = useState([]) +const sortedData = useMemo(() => [...data].sort(/* … */), [data, sorting]) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +// table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/filtering` — `filterFn` placement, fuzzy filter pattern, faceted UI +- `tanstack-table/sorting` — built-in `sortFns`, multi-sort, `sortUndefined` +- `tanstack-table/grouping` — `aggregationFn` signature details and built-ins diff --git a/packages/table-core/skills/filtering/SKILL.md b/packages/table-core/skills/filtering/SKILL.md new file mode 100644 index 0000000000..5765138b6c --- /dev/null +++ b/packages/table-core/skills/filtering/SKILL.md @@ -0,0 +1,357 @@ +--- +name: filtering +description: > + Filter rows in TanStack Table v9 with the `filteredRowModel` pipeline stage. + Covers `columnFilteringFeature` + `globalFilteringFeature` + `columnFacetingFeature`, + `createFilteredRowModel(filterFns)`, `createFacetedRowModel()` / + `createFacetedUniqueValues()` / `createFacetedMinMaxValues()`, fuzzy filtering + with `@tanstack/match-sorter-utils`, the built-in `filterFns` registry, custom + `filterFn` + module augmentation, `state.columnFilters` (Array<{ id, value }>), + `state.globalFilter`, `column.setFilterValue` / `setColumnFilters` / + `setGlobalFilter`, `column.getFacetedUniqueValues` / + `column.getFacetedMinMaxValues`, `manualFiltering`, `filterFromLeafRows`, + `maxLeafRowFilterDepth`, `getColumnCanGlobalFilter`. Five subsystems: + column-filtering, global-filtering, column-faceting, global-faceting, + fuzzy-filtering. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management + - customizing-feature-behavior +sources: + - TanStack/table:docs/guide/column-filtering.md + - TanStack/table:docs/guide/global-filtering.md + - TanStack/table:docs/guide/column-faceting.md + - TanStack/table:docs/guide/global-faceting.md + - TanStack/table:docs/guide/fuzzy-filtering.md + - TanStack/table:examples/react/filters/src/main.tsx + - TanStack/table:examples/react/filters-faceted/src/main.tsx + - TanStack/table:examples/react/filters-fuzzy/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/customizing-feature-behavior`. Read those first for the atom model and `filterFn`/`globalFilterFn` overrides. + +## Setup + +Filtering has five subsystems in v9 — register only the features you need: + +| Subsystem | Feature | Row-model | +| ---------------- | ------------------------ | ----------------------------------- | +| column-filtering | `columnFilteringFeature` | `createFilteredRowModel(filterFns)` | +| global-filtering | `globalFilteringFeature` | (same `filteredRowModel`) | +| column-faceting | `columnFacetingFeature` | `createFacetedRowModel()` + helpers | +| global-faceting | `globalFacetingFeature` | global versions of the helpers | +| fuzzy-filtering | (custom filterFn) | `@tanstack/match-sorter-utils` | + +```ts +import { + tableFeatures, + columnFilteringFeature, + globalFilteringFeature, + rowPaginationFeature, + createFilteredRowModel, + createPaginatedRowModel, + filterFns, +} from '@tanstack/table-core' +import type { ColumnFiltersState } from '@tanstack/table-core' + +const _features = tableFeatures({ + columnFilteringFeature, + globalFilteringFeature, + rowPaginationFeature, +}) + +const table = constructTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + initialState: { + columnFilters: [] satisfies ColumnFiltersState, + globalFilter: '', + }, + globalFilterFn: 'includesString', +}) + +table.setColumnFilters([{ id: 'firstName', value: 'Ada' }]) +table.setGlobalFilter('Lovelace') +``` + +## Core Patterns + +### Text / range / select column filters + +```tsx +// From examples/react/filters/src/main.tsx +function Filter({ column }) { + const firstValue = column + .getFacetedRowModel() + .flatRows[0]?.getValue(column.id) + const columnFilterValue = column.getFilterValue() + + return typeof firstValue === 'number' ? ( +
+ + column.setFilterValue((old: [number, number]) => [ + e.target.value, + old?.[1], + ]) + } + placeholder="Min" + /> + + column.setFilterValue((old: [number, number]) => [ + old?.[0], + e.target.value, + ]) + } + placeholder="Max" + /> +
+ ) : ( + column.setFilterValue(e.target.value)} + placeholder="Search…" + /> + ) +} +``` + +### Faceted filter UIs and fuzzy global search + +Faceting requires `createFacetedRowModel()` as the base plus `createFacetedUniqueValues()` / `createFacetedMinMaxValues()`; fuzzy filtering wires a custom `FilterFn` backed by `@tanstack/match-sorter-utils` (`rankItem` / `compareItems`) with module augmentation for `FilterFns` + `FilterMeta`. Full examples in [faceting-and-fuzzy.md](references/faceting-and-fuzzy.md). + +### Server-side filtering + +```tsx +const [columnFilters, setColumnFilters] = useState([]) +const { data } = useQuery({ + queryKey: ['rows', columnFilters], + queryFn: () => + fetch('/api/rows?' + serialize(columnFilters)).then((r) => r.json()), +}) +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: {}, // no filteredRowModel needed for manual filtering + data, + columns, + manualFiltering: true, + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, +}) +``` + +### Filter tree data and keep matching descendants visible + +```ts +const table = constructTable({ + _features: tableFeatures({ columnFilteringFeature, rowExpandingFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, + getSubRows: (r) => r.subRows, + filterFromLeafRows: true, // bottom-up: keep parent if any descendant matches +}) +``` + +## Common Mistakes + +### [HIGH] Forgetting `createFacetedRowModel()` while registering `createFacetedUniqueValues()` / `createFacetedMinMaxValues()` + +Wrong: + +```tsx +const table = useTable({ + _features: tableFeatures({ columnFacetingFeature, columnFilteringFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + // BUG: missing facetedRowModel + facetedUniqueValues: createFacetedUniqueValues(), + facetedMinMaxValues: createFacetedMinMaxValues(), + }, + columns, + data, +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ + columnFacetingFeature, + columnFilteringFeature, + rowPaginationFeature, + }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + facetedRowModel: createFacetedRowModel(), // REQUIRED base + facetedMinMaxValues: createFacetedMinMaxValues(), + facetedUniqueValues: createFacetedUniqueValues(), + }, + columns, + data, +}) +``` + +Without the base `facetedRowModel`, the unique/minMax helpers fall back to `getPreFilteredRowModel()` — facet values stop excluding the column's own active filter, and a select dropdown collapses to only the currently selected value once the user picks one. + +Source: packages/table-core/src/features/column-faceting/columnFacetingFeature.utils.ts; examples/react/filters-faceted/src/main.tsx + +### [HIGH] Setting `manualFiltering: true` without refetching data + +Wrong: + +```tsx +// manualFiltering bypasses the filteredRowModel — filter UI changes do nothing +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + data, + columns, + manualFiltering: true, + // ...but no useEffect / useQuery key tracking columnFilters +}) +``` + +Correct: + +```tsx +const [columnFilters, setColumnFilters] = useState([]) +const { data } = useQuery({ + queryKey: ['rows', columnFilters], + queryFn: () => + fetch('/api/rows?' + serialize(columnFilters)).then((r) => r.json()), +}) + +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: {}, // no filteredRowModel needed + data, + columns, + manualFiltering: true, + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, +}) +``` + +With `manualFiltering: true`, `table_getFilteredRowModel` short-circuits and returns the core rows. Rows are NOT filtered client-side — you must refetch. + +Source: docs/guide/column-filtering.md; packages/table-core/src/core/row-models/coreRowModelsFeature.utils.ts + +### [HIGH] Custom fuzzy filter without merging into `filterFns` + +Wrong: + +```tsx +// drops the built-in registry +filteredRowModel: createFilteredRowModel({ + fuzzy: fuzzyFilter, +}), +// Column with filterFn: 'includesString' now warns and never filters +``` + +Correct: + +```tsx +import { filterFns } from '@tanstack/react-table' + +filteredRowModel: createFilteredRowModel({ + ...filterFns, // KEEP built-ins + fuzzy: fuzzyFilter, // ADD custom +}), +``` + +`createFilteredRowModel` replaces the registry with whatever you pass. Any column using a built-in name like `'includesString'` becomes a no-op. + +Source: examples/react/filters-fuzzy/src/main.tsx + +### [MEDIUM] Global filter silently skips columns with non-string/non-number values + +Wrong: + +```tsx +// createdAt is a Date object — global filter silently skips it +const columns = [ + columnHelper.accessor('createdAt', { header: 'Created' }), + columnHelper.accessor('name', { header: 'Name' }), +] +// table.setGlobalFilter('2024') will never find Date rows +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ globalFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, + data, + globalFilterFn: 'includesString', + getColumnCanGlobalFilter: (column) => true, // include every column +}) + +// Or per-column: +columnHelper.accessor('createdAt', { + header: 'Created', + enableGlobalFilter: true, +}) +``` + +`globalFilteringFeature` defaults `getColumnCanGlobalFilter` to a function that returns `typeof value === 'string' || typeof value === 'number'` sampled from the first row. Objects, dates, booleans, undefined all silently fail. + +Source: packages/table-core/src/features/global-filtering/globalFilteringFeature.ts + +### [CRITICAL] Reimplementing what built-in APIs provide + +Wrong: + +```ts +// Hand-rolled filter loop, bypassing the table +const filteredData = useMemo( + () => data.filter(/* …custom matching… */), + [data, query], +) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, + data, +}) +table.setColumnFilters([{ id: 'name', value: 'Ada' }]) +// or: column.setFilterValue('Ada') +``` + +`table.setColumnFilters`, `column.setFilterValue`, `table.setGlobalFilter` honor reset behavior and internal invariants. + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/customizing-feature-behavior` — `filterFn` / `globalFilterFn` authoring, `addMeta` chain +- `tanstack-table/sorting` — fuzzy sort pairing for `match-sorter-utils` +- `tanstack-table/row-expanding` — `filterFromLeafRows` interaction with tree data + +## References + +- [faceting-and-fuzzy.md](references/faceting-and-fuzzy.md) — faceted filter UIs (autocomplete + range slider) with `createFacetedRowModel`/`createFacetedUniqueValues`/`createFacetedMinMaxValues`, fuzzy global search via `@tanstack/match-sorter-utils`, plus MEDIUM-priority failure modes: `state` + `initialState` collision, `filterFromLeafRows` semantics, `'auto'` filter misdetection on null first row diff --git a/packages/table-core/skills/filtering/references/faceting-and-fuzzy.md b/packages/table-core/skills/filtering/references/faceting-and-fuzzy.md new file mode 100644 index 0000000000..e65e83676e --- /dev/null +++ b/packages/table-core/skills/filtering/references/faceting-and-fuzzy.md @@ -0,0 +1,191 @@ +# Faceting and fuzzy filtering — extended patterns + +Extended filtering patterns extracted from `SKILL.md`. The SKILL keeps simple column filter, global filter, server-side, and tree-data patterns inline; this file covers faceted UIs and fuzzy filtering with `@tanstack/match-sorter-utils`. + +## Faceted filter UIs (autocomplete + range slider) + +```ts +import { + columnFacetingFeature, + createFacetedRowModel, + createFacetedUniqueValues, + createFacetedMinMaxValues, +} from '@tanstack/table-core' + +const _features = tableFeatures({ + columnFacetingFeature, + columnFilteringFeature, + rowPaginationFeature, +}) + +const table = constructTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + facetedRowModel: createFacetedRowModel(), // REQUIRED base + facetedMinMaxValues: createFacetedMinMaxValues(), + facetedUniqueValues: createFacetedUniqueValues(), + }, + columns, + data, +}) + +// In a Filter component: +const uniqueValues = column.getFacetedUniqueValues() // Map +const [min, max] = column.getFacetedMinMaxValues() ?? [0, 0] +``` + +## Fuzzy global search with match-sorter-utils + +```ts +import { + rankItem, + compareItems, + type RankingInfo, +} from '@tanstack/match-sorter-utils' +import type { FilterFn, SortFn } from '@tanstack/table-core' + +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank?: RankingInfo + } +} + +const fuzzyFilter: FilterFn = ( + row, + columnId, + value, + addMeta, +) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta?.({ itemRank }) + return itemRank.passed +} + +const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir +} + +const table = constructTable({ + _features, + _rowModels: { + filteredRowModel: createFilteredRowModel({ + ...filterFns, + fuzzy: fuzzyFilter, + }), + sortedRowModel: createSortedRowModel(sortFns), + }, + columns, + data, + globalFilterFn: 'fuzzy', +}) +``` + +## Additional MEDIUM-priority failure modes + +### Using `state.columnFilters` AND `initialState.columnFilters` simultaneously + +Wrong: + +```tsx +const [columnFilters, setColumnFilters] = useState([]) +const table = useTable({ + initialState: { columnFilters: [{ id: 'name', value: 'John' }] }, // IGNORED + state: { columnFilters }, // wins, starts empty + onColumnFiltersChange: setColumnFilters, +}) +``` + +Correct: + +```tsx +// Seed the controlled state at useState time, NOT in initialState +const [columnFilters, setColumnFilters] = useState([ + { id: 'name', value: 'John' }, +]) +const table = useTable({ + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, +}) +``` + +`state` always overrides `initialState`. Seed your controlled state instead. + +Source: docs/guide/column-filtering.md + +### Expecting `filterFromLeafRows` to keep ALL children of a matching parent visible + +Wrong: + +```tsx +// filterFromLeafRows hides children that don't match individually +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature, rowExpandingFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, + getSubRows: (r) => r.subRows, + filterFromLeafRows: true, + // expectation: parent matches "John" → all children visible + // reality: only children that also match "John" stay visible +}) +``` + +Correct: + +```tsx +// Filter root-only to preserve all sub-rows under a matching parent +const table = useTable({ + _features: tableFeatures({ columnFilteringFeature, rowExpandingFeature }), + _rowModels: { + filteredRowModel: createFilteredRowModel(filterFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, + getSubRows: (r) => r.subRows, + maxLeafRowFilterDepth: 0, +}) +``` + +`filterFromLeafRows: true` is bottom-up. The mutually exclusive top-down default is what preserves descendants under a matching parent. + +Source: packages/table-core/src/features/column-filtering/filterRowsUtils.ts + +### Auto filter type misdetects when first row has `null`/`undefined` + +Wrong: + +```ts +const data = [ + { id: 1, name: null }, // first row + { id: 2, name: 'Alice' }, +] +// Column filter returns 0 results — auto picked wrong filter from null +``` + +Correct: + +```ts +columnHelper.accessor('name', { + filterFn: 'includesString', // explicit, don't rely on auto +}) +``` + +The default `'auto'` `filterFn` infers from the first row's value. If it's null/undefined, type detection fails. + +Source: https://github.com/TanStack/table/issues/4711 diff --git a/packages/table-core/skills/grouping/SKILL.md b/packages/table-core/skills/grouping/SKILL.md new file mode 100644 index 0000000000..36c843d562 --- /dev/null +++ b/packages/table-core/skills/grouping/SKILL.md @@ -0,0 +1,448 @@ +--- +name: grouping +description: > + Group rows by column values in TanStack Table v9 with the `groupedRowModel` + stage. Covers `columnGroupingFeature` + `createGroupedRowModel(aggregationFns)`, + `state.grouping` (GroupingState = Array), `onGroupingChange`, + `columnDef.aggregationFn` ('auto'|name|fn) — distinct signature + `(columnId, leafRows, childRows)` — `columnDef.aggregatedCell`, + `columnDef.getGroupingValue`, `groupedColumnMode` (false|'reorder'|'remove'), + `manualGrouping`, `column.toggleGrouping` / `getCanGroup` / `getIsGrouped`, + `row.getIsGrouped` / `groupingColumnId` / `leafRows`, `cell.getIsGrouped` / + `getIsAggregated` / `getIsPlaceholder`, the built-in `aggregationFns` registry, + and the required `rowExpandingFeature` pairing for drill-down UX. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management + - customizing-feature-behavior +sources: + - TanStack/table:docs/guide/grouping.md + - TanStack/table:packages/table-core/src/fns/aggregationFns.ts + - TanStack/table:packages/table-core/src/features/column-grouping/createGroupedRowModel.ts + - TanStack/table:examples/react/grouping/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/customizing-feature-behavior`. Read those first for the atom model and `aggregationFn` signature. + +## Setup + +Grouping nearly always pairs with expanding — otherwise grouped rows show aggregates with no way to drill in. + +```ts +import { + tableFeatures, + columnGroupingFeature, + rowExpandingFeature, + rowPaginationFeature, + createGroupedRowModel, + createExpandedRowModel, + createPaginatedRowModel, + aggregationFns, + createColumnHelper, + constructTable, +} from '@tanstack/table-core' +import type { GroupingState } from '@tanstack/table-core' + +const _features = tableFeatures({ + columnGroupingFeature, + rowExpandingFeature, + rowPaginationFeature, +}) + +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { + aggregatedCell: () => null, + enableGrouping: false, + }), + columnHelper.accessor('age', { aggregationFn: 'median' }), + columnHelper.accessor('visits', { aggregationFn: 'sum' }), + columnHelper.accessor('status', { aggregationFn: 'count' }), + columnHelper.accessor('progress', { aggregationFn: 'mean' }), +]) + +const table = constructTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + initialState: { grouping: [] satisfies GroupingState }, +}) + +table.setGrouping(['status']) +``` + +## Core Patterns + +### Per-column group toggle + aggregated cell rendering + +```tsx +// From examples/react/grouping/src/main.tsx +{ + headerGroup.headers.map((header) => ( + + {header.column.getCanGroup() ? ( + + ) : null} + + + )) +} + +// Cell renderer with three branches: grouped, aggregated, normal +{ + row.getVisibleCells().map((cell) => ( + + {cell.getIsGrouped() ? ( + <> + {' '} + ({row.subRows.length}) + + ) : cell.getIsAggregated() ? ( + + ) : cell.getIsPlaceholder() ? null : ( + + )} + + )) +} +``` + +### Custom `aggregationFn` + +```ts +import type { AggregationFn } from '@tanstack/table-core' + +// Signature: (columnId, leafRows, childRows) → aggregated value +// leafRows = ALL descendant non-grouped rows (recursive) +// childRows = immediate children (may themselves be sub-aggregates) +const weightedAverage: AggregationFn = ( + columnId, + leafRows, +) => { + let totalWeight = 0 + let weightedSum = 0 + leafRows.forEach((row) => { + const v = row.getValue(columnId) + const w = row.original.weight + weightedSum += v * w + totalWeight += w + }) + return totalWeight === 0 ? 0 : weightedSum / totalWeight +} + +const table = constructTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel({ + ...aggregationFns, + weightedAverage, + }), + expandedRowModel: createExpandedRowModel(), + }, + columns: columnHelper.columns([ + columnHelper.accessor('progress', { + aggregationFn: 'weightedAverage', + aggregatedCell: (info) => `${info.getValue().toFixed(1)}%`, + }), + ]), + data, +}) +``` + +### Override grouping key with `getGroupingValue` + +```ts +columnHelper.accessor('firstName', { + // group by full name, not just firstName + getGroupingValue: (row) => `${row.firstName} ${row.lastName}`, +}) +``` + +### Control grouped-column placement + +```ts +const table = constructTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, + groupedColumnMode: 'reorder', // default — grouped columns lead + // groupedColumnMode: 'remove', // hide grouped columns from visible flow + // groupedColumnMode: false, // keep columnOrder intact +}) +``` + +## Common Mistakes + +### [HIGH] Adding `columnGroupingFeature` without `rowExpandingFeature` + +Wrong: + +```ts +// grouped rows show aggregates but can't be expanded +const _features = tableFeatures({ columnGroupingFeature }) +const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, + data, +}) +// row.getToggleExpandedHandler() → TS error or undefined +``` + +Correct: + +```ts +import { + aggregationFns, + columnGroupingFeature, + createExpandedRowModel, + createGroupedRowModel, + rowExpandingFeature, +} from '@tanstack/react-table' + +const _features = tableFeatures({ + columnGroupingFeature, + rowExpandingFeature, +}) + +const table = useTable({ + _features, + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + }, + columns, data, +}) + +// In cell renderer: +{cell.getIsGrouped() && ( + +)} +``` + +Without `rowExpandingFeature`, `row.getToggleExpandedHandler` doesn't exist — grouped rows stay collapsed forever. + +Source: examples/react/grouping/src/main.tsx + +### [HIGH] Customizing aggregationFns via a v8-style `tableOptions.aggregationFns` option + +Wrong: + +```ts +const table = useTable({ + _features: tableFeatures({ columnGroupingFeature }), + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, data, + // @ts-ignore - this property doesn't exist on v9 TableOptions + aggregationFns: { + myCustom: (id, leaf, child) => /* ... */, + }, +}) +``` + +Correct: + +```ts +import { aggregationFns, createGroupedRowModel } from '@tanstack/react-table' + +const table = useTable({ + _features: tableFeatures({ columnGroupingFeature, rowExpandingFeature }), + _rowModels: { + groupedRowModel: createGroupedRowModel({ + ...aggregationFns, + myCustomAggregation: (columnId, leafRows, childRows) => { + return /* aggregated value */ + }, + }), + expandedRowModel: createExpandedRowModel(), + }, + columns, + data, +}) + +// Then on a column: +columnHelper.accessor('sales', { aggregationFn: 'myCustomAggregation' }) +``` + +In v9, the aggregation registry is the FIRST argument to `createGroupedRowModel`. There is no top-level `tableOptions.aggregationFns`. + +Source: packages/table-core/src/features/column-grouping/createGroupedRowModel.ts + +### [MEDIUM] Confusing the `aggregationFn` signature with filter/sort signatures + +Wrong: + +```ts +// wrong arg names — first arg is columnId, not row +aggregationFn: (rowA, rowB, columnId) => /* ... */ + +// or: averaging via childRows includes already-aggregated sub-group sums +aggregationFn: (id, leaf, child) => child.reduce((a, r) => a + r.getValue(id), 0) / child.length +``` + +Correct: + +```ts +// (columnId, leafRows, childRows) +// leafRows = all descendant non-grouped rows +// childRows = immediate children (may be sub-aggregates) + +// For pure leaf averages, use leafRows: +const aggregationFn_mean: AggregationFn = (columnId, leafRows) => { + let count = 0, + sum = 0 + leafRows.forEach((row) => { + const value = row.getValue(columnId) + if (typeof value === 'number') { + count++ + sum += value + } + }) + return count ? sum / count : undefined +} + +// For nestable sums (reuse sub-aggregates), use childRows: +const aggregationFn_sum: AggregationFn = ( + columnId, + _leafRows, + childRows, +) => { + return childRows.reduce((acc, next) => { + const v = next.getValue(columnId) + return acc + (typeof v === 'number' ? v : 0) + }, 0) +} +``` + +Built-in `mean`, `median`, `unique`, `uniqueCount`, `count` use `leafRows`. `sum`, `min`, `max`, `extent` use `childRows`. + +Source: packages/table-core/src/fns/aggregationFns.ts + +### [MEDIUM] Expecting grouped columns to keep their original position + +Wrong: + +```ts +const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, + data, + initialState: { + columnOrder: ['firstName', 'lastName', 'age', 'status'], // explicit + grouping: ['status'], + }, + // status jumps to position 0 — columnOrder is "overridden" +}) +``` + +Correct: + +```ts +const table = useTable({ + _features, + _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns) }, + columns, + data, + initialState: { + columnOrder: ['firstName', 'lastName', 'age', 'status'], + grouping: ['status'], + }, + groupedColumnMode: false, // keep columnOrder intact +}) + +// Or hide grouped columns entirely: +// groupedColumnMode: 'remove' +``` + +`columnGroupingFeature.getDefaultTableOptions` sets `groupedColumnMode: 'reorder'`, which moves grouped columns to the start. + +Source: packages/table-core/src/features/column-ordering/columnOrderingFeature.utils.ts + +### [LOW] Calling `getSelectedRowModel()` on a grouped table expecting grouped rows + +Wrong: + +```ts +// returns selection from the CORE model, not the grouped projection +const selectedRows = table.getSelectedRowModel().rows +// Doesn't reflect grouping — leaf rows only +``` + +Correct: + +```ts +table.getSelectedRowModel() // selection from raw data +table.getFilteredSelectedRowModel() // selection within current filters +table.getGroupedSelectedRowModel() // selection within current groups +``` + +Three distinct APIs. Pick the model matching the question being asked. + +Source: packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + +### [CRITICAL] Reimplementing aggregation manually + +Wrong: + +```ts +// Hand-rolled groupBy + reduce, bypassing the table +const grouped = useMemo(() => { + const map = new Map() + data.forEach((row) => { + const key = row.status + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(row) + }) + return Array.from(map.entries()).map(([k, rows]) => ({ + status: k, + avgAge: rows.reduce((s, r) => s + r.age, 0) / rows.length, + })) +}, [data]) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ columnGroupingFeature, rowExpandingFeature }), + _rowModels: { + groupedRowModel: createGroupedRowModel(aggregationFns), + expandedRowModel: createExpandedRowModel(), + }, + columns: columnHelper.columns([ + columnHelper.accessor('status', { enableGrouping: true }), + columnHelper.accessor('age', { aggregationFn: 'mean' }), + ]), + data, +}) +table.setGrouping(['status']) +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/row-expanding` — required pairing for grouped drill-down +- `tanstack-table/customizing-feature-behavior` — `aggregationFn` authoring +- `tanstack-table/row-selection` — `getGroupedSelectedRowModel` distinction diff --git a/packages/table-core/skills/migrate-v8-to-v9/SKILL.md b/packages/table-core/skills/migrate-v8-to-v9/SKILL.md new file mode 100644 index 0000000000..7ad9b536f7 --- /dev/null +++ b/packages/table-core/skills/migrate-v8-to-v9/SKILL.md @@ -0,0 +1,495 @@ +--- +name: migrate-v8-to-v9 +description: > + Mechanical breaking-change migration from TanStack Table v8 to v9 at the + `@tanstack/table-core` level. Covers hook/entry rename + (`useReactTable`/`createSolidTable`/… → `useTable`/`injectTable`/`createTable`/ + `constructTable`), the new required `_features` + `_rowModels` options, + `createColumnHelper()` → `createColumnHelper()`, + row-model factory rename (`getCoreRowModel()` → automatic; `getSortedRowModel()` + → `createSortedRowModel(sortFns)`; same for filtered/paginated/grouped/expanded/ + faceted), `table.getState()` → `table.store.state` / `table.atoms..get()`, + sorting renames (`sortingFn` → `sortFn`, etc.), `enablePinning` split, column + sizing/resizing split, underscore-prefixed APIs becoming public, `RowData` + type tightening, TFeatures-first generics, the `useLegacyTable` React escape + hatch (deprecated, removed in v10), and the `stockFeatures` v8-style "everything + on" registry. +type: lifecycle +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - setup + - state-management + - column-definitions +sources: + - TanStack/table:docs/framework/table-core/guide/migrating.md + - TanStack/table:docs/framework/react/guide/use-legacy-table.md + - TanStack/table:packages/react-table/src/legacy.ts +--- + +## Setup + +v9 is a substantial reshape, not a tweak. The breaking changes group into: + +1. **Hook/entry rename** per adapter. +2. **`_features` + `_rowModels` are required** — features are tree-shaken. +3. **Column helper generic order** — `` not ``. +4. **Row-model factories** moved out of root options, into `_rowModels`, and now take their `*Fns` argument. +5. **State surface renamed** — `table.getState()` → `table.store.state` / `table.atoms..get()` / `table.state` (selector). +6. **Sorting names**: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, `getSortingFn()` → `getSortFn()`, type `SortingFn` → `SortFn`. +7. **`enablePinning` split** into `enableColumnPinning` + `enableRowPinning` (table-level); per-column `enablePinning` stays. +8. **Column resizing split out** of column sizing. `columnSizingInfo` state → `columnResizing`. `onColumnSizingInfoChange` → `onColumnResizingChange`. +9. **Underscore-prefixed APIs are public now** — drop the `_` prefix (`row._getAllCellsByColumnId()` → `row.getAllCellsByColumnId()`, `table._getFacetedRowModel()` → public, etc.). +10. **Generics now lead with `TFeatures`** — `Column`, `Row`, `ColumnMeta`. +11. **`RowData` tightened** from `unknown | object | any[]` to `Record | Array`. +12. **`data` and `columns` are readonly** in v9 — flow changes through state, don't mutate. + +For React projects that cannot migrate every table at once, `useLegacyTable` from `@tanstack/react-table/legacy` accepts the v8 shape on top of the v9 engine. Deprecated, ships every feature, no `table.Subscribe`. Removed in v10. + +## Core Patterns + +### Full v9 equivalent for the most common v8 shape + +```ts +// === v9 (correct) === +import { + useTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnSizingFeature, + columnResizingFeature, + createColumnHelper, + createSortedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + sortFns, + filterFns, +} from '@tanstack/react-table' +import type { ColumnDef } from '@tanstack/react-table' + +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + columnSizingFeature, + columnResizingFeature, // explicit — formerly part of ColumnSizing +}) + +const columnHelper = createColumnHelper() + +const columns: ColumnDef[] = columnHelper.columns([ + columnHelper.accessor('name', { + header: 'Name', + sortFn: 'alphanumeric', // renamed from sortingFn + }), +]) + +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + enableColumnPinning: true, // split from enablePinning + enableRowPinning: true, +}) + +// State reads +const allState = table.store.state // full snapshot +const sorting = table.atoms.sorting.get() // per-slice atom +const cells = row.getAllCellsByColumnId() // no underscore + +// Rendering +// +// +``` + +### v8 muscle-memory anti-shape + +```ts +// === v8 muscle memory — every line is broken in v9. === +import { + useReactTable, // (1) renamed → useTable + getCoreRowModel, // (2) no longer a root option + getFilteredRowModel, // move to _rowModels as factories + getSortedRowModel, // createSortedRowModel(sortFns) etc. + getPaginationRowModel, + createColumnHelper, // (3) needs now + sortingFns, // (4) renamed → sortFns + filterFns, + flexRender, // still exists, prefer table.FlexRender +} from '@tanstack/react-table' +import type { ColumnDef, Row } from '@tanstack/react-table' + +const columnHelper = createColumnHelper() // wrong arity + +const columns: ColumnDef[] = [ + // (5) ColumnDef now + { accessorKey: 'name', header: 'Name', sortingFn: 'alphanumeric' }, // (6) renamed → sortFn +] + +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), // (2) move into _rowModels + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + filterFns, // (7) no longer a root option + sortingFns, // (4)+(7) + enablePinning: true, // (8) split → enableColumnPinning / enableRowPinning + onColumnSizingInfoChange: setInfo, // (9) renamed → onColumnResizingChange +}) + +const all = table.getState() // (10) → table.store.state +const cells = row._getAllCellsByColumnId() // underscore removed +``` + +### Transitional `useLegacyTable` (React only) + +```ts +import { + useLegacyTable, + getCoreRowModel, + legacyCreateColumnHelper, +} from '@tanstack/react-table/legacy' + +const legacyHelper = legacyCreateColumnHelper() +const legacyTable = useLegacyTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), +}) +``` + +This accepts the v8 shape on top of the v9 engine. Deprecated, ships every feature, no `table.Subscribe`, no atoms. Removed in v10. Use to unblock incremental migration only — not as a long-term API. Angular has no `useLegacyTable` equivalent; Angular projects must migrate directly. + +## Common Mistakes + +### [CRITICAL] Hallucinating react-table v7 / pre-v9 API names + +Wrong: + +```ts +// v7 +import { useTable, useSortBy } from 'react-table' +const table = useTable({ columns, data }, useSortBy) + +// v8 +import { useReactTable, getCoreRowModel } from '@tanstack/react-table' +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), +}) +``` + +Correct: + +```ts +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +Every major release of TanStack Table has been a substantial upgrade. Agents trained on v7 or v8 will confidently emit shapes that no longer exist. This is the #2 AI failure (after reimplementing built-ins). + +Source: maintainer interview (Phase 4, 2026-05-17) + +### [CRITICAL] Importing pre-bundled `getCoreRowModel` / `getSortedRowModel` etc. + +Wrong: + +```ts +// v8 pattern — won't drive v9 row models +const table = useTable({ + _features, + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), +}) +``` + +Correct: + +```ts +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), // factory takes sortFns argument + }, + columns, + data, +}) +``` + +In v9, row models live under `_rowModels` and the factories REQUIRE their \*Fns argument for tree-shaking: `createFilteredRowModel(filterFns)`, `createSortedRowModel(sortFns)`, `createGroupedRowModel(aggregationFns)`. Core is automatic. + +Source: PR #6234 (atoms refactor); packages/table-core/src/index.ts + +### [CRITICAL] `createColumnHelper()` (v8 arity) + +Wrong: + +```ts +const columnHelper = createColumnHelper() +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() +``` + +v9 requires ``. `typeof _features` is the standard idiom — declare features once at module scope and reuse the type. + +Source: packages/table-core/src/helpers/columnHelper.ts; docs/framework/react/guide/migrating.md + +### [HIGH] Reading state via `table.getState()` + +Wrong: + +```ts +const all = table.getState() +``` + +Correct: + +```ts +const all = table.store.state // flat snapshot, no subscription +const sorting = table.atoms.sorting.get() // per-slice +const selected = table.state // typed selector output (framework adapters) +``` + +`table.getState()` was removed. There are three reads now, picked by what you need. + +Source: docs/framework/table-core/guide/migrating.md + +### [HIGH] Sorting renames missed + +Wrong: + +```ts +{ accessorKey: 'age', sortingFn: 'alphanumeric' } // v8 name +useTable({ sortingFns: { ... } }) // v8 option +column.getSortingFn() // v8 method +``` + +Correct: + +```ts +columnHelper.accessor('age', { sortFn: 'alphanumeric' }) +createSortedRowModel(sortFns) // pass registry to the factory +column.getSortFn() +``` + +v9 renamed every sorting API: `sortingFn` → `sortFn`, `sortingFns` → `sortFns`, type `SortingFn` → `SortFn`, `getSortingFn()` → `getSortFn()`. TypeScript surfaces these but agents try v8 names first. + +Source: packages/table-core/src/features/row-sorting/rowSortingFeature.types.ts + +### [HIGH] Using `enablePinning` at the table level + +Wrong: + +```ts +const table = useTable({ + _features: tableFeatures({ columnPinningFeature, rowPinningFeature }), + enablePinning: true, // ignored at table level in v9 +}) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ columnPinningFeature, rowPinningFeature }), + enableColumnPinning: true, + enableRowPinning: true, +}) + +// Per-column opt-out is still `enablePinning`: +columnHelper.accessor('id', { enablePinning: false }) +``` + +v9 split `enablePinning` (table-level) into `enableColumnPinning` + `enableRowPinning`. The bare name now refers ONLY to per-column opt-out. + +Source: packages/table-core/src/features/column-pinning/columnPinningFeature.types.ts + +### [HIGH] Treating column resizing as part of column sizing + +Wrong: + +```ts +// v8 — ColumnSizing implied resizing too +const table = useTable({ + _features: tableFeatures({ columnSizingFeature }), + onColumnSizingInfoChange: setInfo, // v8 name +}) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ + columnSizingFeature, + columnResizingFeature, // explicit in v9 + }), + onColumnResizingChange: setResizing, // renamed + // state key columnSizingInfo → columnResizing +}) +``` + +v9 split them: `columnSizingFeature` for fixed widths, `columnResizingFeature` for drag-to-resize. State key `columnSizingInfo` → `columnResizing`; option `onColumnSizingInfoChange` → `onColumnResizingChange`. + +Source: docs/framework/table-core/guide/migrating.md + +### [MEDIUM] Calling underscore-prefixed APIs + +Wrong: + +```ts +row._getAllCellsByColumnId() +table._getFacetedRowModel() +table._getFacetedMinMaxValues() +table._getFacetedUniqueValues() +table._getPinnedRows() +``` + +Correct: + +```ts +row.getAllCellsByColumnId() +table.getFacetedRowModel() +table.getFacetedMinMaxValues() +table.getFacetedUniqueValues() +table.getPinnedRows() +``` + +All became public — drop the underscore. + +Source: docs/framework/table-core/guide/migrating.md + +### [MEDIUM] Module augmentation with v8 generic arity + +Wrong: + +```ts +declare module '@tanstack/react-table' { + interface ColumnMeta { + customProp: string + } +} +``` + +Correct: + +```ts +declare module '@tanstack/react-table' { + interface ColumnMeta { + customProp: string + } +} +``` + +v9 added `TFeatures` as the first generic. Module augmentation silently widens types if arity is wrong. + +Source: docs/framework/table-core/guide/migrating.md + +### [MEDIUM] Mutating `data` or `columns` in place + +Wrong: + +```ts +// v8 pattern, breaks at TS layer in v9 +const data: Person[] = [] +function addRow(row: Person) { + data.push(row) + rerender() +} +``` + +Correct: + +```ts +const [data, setData] = useState([]) +function addRow(row: Person) { + setData((prev) => [...prev, row]) +} +``` + +PR #6183 makes `data` and `columns` readonly to force changes through state. + +Source: PR #6183 + +### [MEDIUM] Reaching for `useLegacyTable` in new code + +Wrong: + +```ts +// Long-term use of the legacy shim +import { useLegacyTable, getCoreRowModel } from '@tanstack/react-table/legacy' +const table = useLegacyTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), +}) +``` + +Correct: + +```ts +// Migrate to native v9 shape +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +`useLegacyTable` is React-only, deprecated, bundles every feature, doesn't support `table.Subscribe`, and is removed in v10. It exists to unblock incremental migration — not as a long-term API. + +Source: packages/react-table/src/legacy.ts; docs/framework/react/guide/use-legacy-table.md + +## See also + +- `tanstack-table/setup` — what the v9-native shape looks like +- `tanstack-table/state-management` — `table.store.state` / `table.atoms` / `table.state` ownership +- `tanstack-table/column-definitions` — `createColumnHelper()` generic order diff --git a/packages/table-core/skills/pagination/SKILL.md b/packages/table-core/skills/pagination/SKILL.md new file mode 100644 index 0000000000..361c7b76b2 --- /dev/null +++ b/packages/table-core/skills/pagination/SKILL.md @@ -0,0 +1,385 @@ +--- +name: pagination +description: > + Paginate rows in TanStack Table v9 with the `paginatedRowModel` stage. Covers + `rowPaginationFeature` + `createPaginatedRowModel()`, `state.pagination` + ({ pageIndex, pageSize }), `onPaginationChange`, `manualPagination`, + `rowCount` and `pageCount` for server-side, `autoResetPageIndex`, + `paginateExpandedRows`, navigation APIs (`nextPage`/`previousPage`/`firstPage`/ + `lastPage`/`setPageIndex`/`setPageSize`), `getCanNextPage` / `getCanPreviousPage`, + `getPageCount` / `getRowCount` / `getPageOptions`, and `getPrePaginatedRowModel` + for "total filtered" counts. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/guide/pagination.md + - TanStack/table:packages/table-core/src/features/row-pagination/rowPaginationFeature.utils.ts + - TanStack/table:packages/table-core/src/features/row-pagination/createPaginatedRowModel.ts + - TanStack/table:examples/react/pagination/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`. Read it first for the atom model and `manual*` mode. + +## Setup + +```ts +import { + tableFeatures, + rowPaginationFeature, + createPaginatedRowModel, + constructTable, +} from '@tanstack/table-core' +import type { PaginationState } from '@tanstack/table-core' + +const _features = tableFeatures({ rowPaginationFeature }) + +const table = constructTable({ + _features, + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + initialState: { + pagination: { pageIndex: 0, pageSize: 10 } satisfies PaginationState, + }, +}) + +table.nextPage() +table.setPageIndex(5) +table.setPageSize(25) +table.getCanNextPage() +table.getPageCount() +table.getRowCount() +``` + +## Core Patterns + +### Pagination toolbar + +```tsx +// From examples/react/pagination/src/main.tsx +
+ + + + + + + Page {table.store.state.pagination.pageIndex + 1} of{' '} + {table.getPageCount().toLocaleString()} + + + + | Go to page: + { + const page = e.target.value ? Number(e.target.value) - 1 : 0 + table.setPageIndex(page) + }} + /> + + + +
+``` + +### Server-side pagination + +```tsx +const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, +}) +const { data: dataQuery } = useQuery({ + queryKey: ['rows', pagination], + queryFn: () => fetchPage(pagination.pageIndex, pagination.pageSize), +}) + +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, // no paginatedRowModel — server paginates + data: dataQuery?.rows ?? EMPTY, + columns, + manualPagination: true, + rowCount: dataQuery?.rowCount, // server-provided total + // OR: pageCount: dataQuery.pageCount + // OR: pageCount: -1 if unknown (next button stays enabled) + state: { pagination }, + onPaginationChange: setPagination, +}) +``` + +### Disable auto page reset when filters change + +```ts +const table = constructTable({ + _features: tableFeatures({ rowPaginationFeature, columnFilteringFeature }), + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + filteredRowModel: createFilteredRowModel(filterFns), + }, + columns, + data, + autoResetPageIndex: false, // keep current page while user types in a filter +}) +``` + +`autoResetPageIndex` defaults to `!manualPagination` — true client-side, false manual. + +### "Total filtered rows" display while paginated + +```ts +const totalFiltered = table.getPrePaginatedRowModel().rows.length +const onCurrentPage = table.getRowModel().rows.length +``` + +`getPrePaginatedRowModel` is the row model AFTER filtering/sorting/grouping/expansion but BEFORE the page slice. + +## Common Mistakes + +### [HIGH] Setting `manualPagination: true` without supplying `rowCount` or `pageCount` + +Wrong: + +```tsx +// getPageCount() returns 1, next/prev buttons are disabled +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, + data, // only 10 rows for the current page + columns, + manualPagination: true, + state: { pagination }, + onPaginationChange: setPagination, + // missing: rowCount or pageCount +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, + data: dataQuery.rows, + columns, + manualPagination: true, + rowCount: dataQuery.rowCount, // server tells the table the total + // OR: pageCount: dataQuery.pageCount + // OR: pageCount: -1 if unknown + state: { pagination }, + onPaginationChange: setPagination, +}) +``` + +`getRowCount()` defaults to `getPrePaginatedRowModel().rows.length` — which in manual mode is only the current page. Without `rowCount`/`pageCount`, the table thinks there's one page total. + +Source: packages/table-core/src/features/row-pagination/rowPaginationFeature.utils.ts + +### [MEDIUM] Putting `pagination` in BOTH `state` and `initialState` + +Wrong: + +```tsx +const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) +const table = useTable({ + initialState: { pagination: { pageSize: 25 } }, // IGNORED + state: { pagination }, // wins (pageSize 10) + onPaginationChange: setPagination, +}) +``` + +Correct: + +```tsx +const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }) +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data, + state: { pagination }, + onPaginationChange: setPagination, +}) +``` + +`state` always overrides `initialState`. Seed the controlled state in `useState` or use `initialState` alone — never both. + +Source: docs/guide/pagination.md + +### [MEDIUM] `autoResetPageIndex: false` without manual clamping leaves empty pages + +Wrong: + +```ts +const table = useTable({ + _features, + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + filteredRowModel: createFilteredRowModel(filterFns), + }, + columns, + data, + autoResetPageIndex: false, // user on page 5, then filters down to 2 pages + // → page 5 is empty. No automatic clamp. +}) +``` + +Correct: + +```ts +// Either leave autoResetPageIndex default (true) for client-side, +// or clamp manually after a data-altering effect: +useEffect(() => { + const lastPage = Math.max(0, table.getPageCount() - 1) + if (table.atoms.pagination.get().pageIndex > lastPage) { + table.setPageIndex(lastPage) + } +}, [data, columnFilters]) +``` + +`setPageIndex` only clamps against `options.pageCount` (max safe int when unset). It doesn't clamp against the current row model. + +Source: packages/table-core/src/features/row-pagination/rowPaginationFeature.utils.ts + +### [LOW] Expecting `getRowCount()` to equal `data.length` under filtering/grouping/expansion + +Wrong: + +```ts +console.log(table.getRowCount()) // count after all transforms +console.log(data.length) // raw input count +// These diverge under filtering, grouping, or tree vs flat input. +``` + +Correct: + +```ts +table.getCoreRowModel().rows.length // raw row count (flat) +table.getPreFilteredRowModel().rows.length // before filtering +table.getFilteredRowModel().rows.length // after filtering +table.getRowCount() // pre-paginated count (or `rowCount` option in manual mode) +table.getRowModel().rows.length // current page only +``` + +`getRowCount` returns `options.rowCount ?? getPrePaginatedRowModel().rows.length`. Pick the model that matches the question. + +Source: docs/guide/row-models.md + +### [HIGH] `getToggleAllRowsSelectedHandler` only selects current page under server pagination + +Wrong: + +```tsx +// Header checkbox only affects current page in manualPagination mode + +``` + +Correct: + +```tsx +// Use page-aware APIs with server pagination + +// For "select all server-side rows", track a separate "all rows mode" +// boolean alongside the row map. +``` + +The table only knows about the current page's rows under `manualPagination`. "Select all" can never mean "every row on the server" without an explicit out-of-band selection mode. + +Source: https://github.com/TanStack/table/issues/4781 + +### [MEDIUM] `autoResetPageIndex` resets to `initialState.pageIndex` (not 0) + +Wrong: + +```ts +// Filtering data resets to the deep-linked pageIndex, not 0 +const table = useTable({ + data, + columns, + initialState: { pagination: { pageIndex: 5, pageSize: 10 } }, + autoResetPageIndex: true, +}) +``` + +Correct: + +```ts +useEffect(() => { + table.setPageIndex(0) +}, [columnFilters, globalFilter]) +// Or: don't rely on autoResetPageIndex when deep-linking pageIndex +``` + +`_autoResetPageIndex` calls `resetPageIndex()` without `true`, which restores to `initialState.pagination.pageIndex` — typically a URL deep-link page that's now invalid. + +Source: https://github.com/TanStack/table/issues/6207 + +### [CRITICAL] Reimplementing pagination math manually + +Wrong: + +```ts +// Hand-rolled page slicing instead of using the API +const paginatedRows = useMemo( + () => allRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), + [allRows, pageIndex, pageSize], +) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data, +}) +// table.nextPage(), table.setPageIndex(...), table.getRowModel().rows +``` + +The built-in APIs honor `autoResetPageIndex`, `paginateExpandedRows`, and reset interactions across other features. + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/state-management` — `manualPagination` + `autoResetAll` +- `tanstack-table/row-expanding` — `paginateExpandedRows` interaction +- `tanstack-table/row-selection` — "select all" pitfalls under server pagination diff --git a/packages/table-core/skills/row-expanding/SKILL.md b/packages/table-core/skills/row-expanding/SKILL.md new file mode 100644 index 0000000000..78e94cd870 --- /dev/null +++ b/packages/table-core/skills/row-expanding/SKILL.md @@ -0,0 +1,348 @@ +--- +name: row-expanding +description: > + Expand and collapse rows in TanStack Table v9 with the `expandedRowModel` + stage. Two patterns: (1) tree sub-rows via `getSubRows`, (2) detail panels + via `getRowCanExpand`. Covers `rowExpandingFeature` + `createExpandedRowModel()`, + `state.expanded` (ExpandedState = true | Record), + `onExpandedChange`, `manualExpanding`, `paginateExpandedRows` (default true), + `autoResetExpanded`, `row.toggleExpanded` / `getIsExpanded` / `getCanExpand` / + `getIsAllParentsExpanded` / `getToggleExpandedHandler`, `table.toggleAllRowsExpanded`, + `row.depth` for indentation, and the `filterFromLeafRows` / + `maxLeafRowFilterDepth` interaction with filtering. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/guide/expanding.md + - TanStack/table:packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts + - TanStack/table:examples/react/expanding/src/main.tsx + - TanStack/table:examples/react/sub-components/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`. Read it first for the atom model. + +## Setup + +```ts +import { + tableFeatures, + rowExpandingFeature, + createExpandedRowModel, + constructTable, +} from '@tanstack/table-core' +import type { ExpandedState } from '@tanstack/table-core' + +const _features = tableFeatures({ rowExpandingFeature }) + +// Tree mode — data has nested subRows +const table = constructTable({ + _features, + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, + data, + getSubRows: (row) => row.subRows, + initialState: { expanded: {} satisfies ExpandedState }, +}) + +// Or detail-panel mode — every row can expand to a sub-component +const detailTable = constructTable({ + _features, + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, + data, + getRowCanExpand: () => true, +}) +``` + +## Core Patterns + +### Tree table with indentation + +```tsx +// From examples/react/expanding/src/main.tsx +{ + row.getVisibleCells().map((cell, i) => ( + + {i === 0 && row.getCanExpand() ? ( + + ) : null} + + + )) +} +``` + +`row.depth` is 0-based. `row.getCanExpand()` returns true when `row.subRows.length > 0`. + +### Detail panels for flat data + +```tsx +// From examples/react/sub-components/src/main.tsx +{ + table.getRowModel().rows.map((row) => ( + + + {row.getVisibleCells().map((cell) => ( + + + + ))} + + {row.getIsExpanded() && ( + + + + + + )} + + )) +} +``` + +### Toggle ALL rows expanded at once + +```tsx + +``` + +### Keep expanded children on their parent's page + +```ts +const table = constructTable({ + _features: tableFeatures({ rowExpandingFeature, rowPaginationFeature }), + _rowModels: { + expandedRowModel: createExpandedRowModel(), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + getSubRows: (r) => r.subRows, + paginateExpandedRows: false, // children stay glued to their parent +}) +``` + +`paginateExpandedRows: true` (default) flows expanded children through pagination — each child counts toward `pageSize`. `false` keeps them stuck under their parent. + +### Tree filtering with leaf-match propagation + +```ts +const table = constructTable({ + _features: tableFeatures({ rowExpandingFeature, columnFilteringFeature }), + _rowModels: { + expandedRowModel: createExpandedRowModel(), + filteredRowModel: createFilteredRowModel(filterFns), + }, + columns, + data, + getSubRows: (r) => r.subRows, + filterFromLeafRows: true, // keep parent visible when ANY descendant matches +}) +``` + +## Common Mistakes + +### [HIGH] Setting `getRowCanExpand: () => true` together with tree data + +Wrong: + +```ts +// every row gets an expander icon, including leaves with no subRows +const table = useTable({ + getRowCanExpand: () => true, + getSubRows: (r) => r.subRows, +}) +// In cell: row.getCanExpand() always true → leaf rows show 👉 with nothing to expand +``` + +Correct: + +```ts +// For pure tree data, omit getRowCanExpand and let it auto-detect: +const table = useTable({ + _features, + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, + data, + getSubRows: (row) => row.subRows, + // row.getCanExpand() is true only when subRows.length > 0 +}) + +// For pure detail panels, override and skip getSubRows: +const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, + data, + getRowCanExpand: () => true, +}) +``` + +`row_getCanExpand` resolves to `options.getRowCanExpand?.(row) ?? (enableExpanding ?? true) && !!row.subRows.length`. When `getRowCanExpand` is set, it wins — including for leaves. + +Source: packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts + +### [MEDIUM] Setting `paginateExpandedRows: false` and expecting `pageSize` to be a hard cap + +Wrong: + +```ts +const table = useTable({ + paginateExpandedRows: false, + initialState: { pagination: { pageSize: 10 } }, + // user expands all parents → 10 parents * 5 children = 60 visible rows +}) +``` + +Correct: + +```ts +// Default behavior — children flow through pagination, pageSize is enforced: +const table = useTable({ + _features, + _rowModels: { + expandedRowModel: createExpandedRowModel(), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + getSubRows: (r) => r.subRows, + // paginateExpandedRows defaults to true +}) + +// OR keep children with parent — accept that pageSize is a soft cap: +// paginateExpandedRows: false +``` + +`paginateExpandedRows: false` inflates each parent's page slice via `expandRows` — more rows render than `pageSize`. Pick deliberately. + +Source: packages/table-core/src/features/row-pagination/createPaginatedRowModel.ts + +### [MEDIUM] Storing `expanded` as `true` then writing into it like a Record + +Wrong: + +```ts +// spreading `true` (a boolean) into an object gives {} - all rows collapse except [id] +setExpanded((old) => ({ ...old, [row.id]: true })) +// When old === true, this becomes { [row.id]: true } — everything else collapses! +``` + +Correct: + +```ts +// Prefer the built-in handler — it materializes properly: + + +// Or handle materialization yourself: +table.setExpanded((old) => { + if (old === true) { + const map: Record = {} + Object.keys(table.getRowModel().rowsById).forEach((id) => { map[id] = true }) + return { ...map, [row.id]: !map[row.id] } + } + return { ...old, [row.id]: !(old as Record)[row.id] } +}) +``` + +`ExpandedState = true | Record`. The `true` literal means "all rows expanded" — `row.toggleExpanded` materializes it correctly before applying per-row changes. + +Source: packages/table-core/src/features/row-expanding/rowExpandingFeature.utils.ts + +### [MEDIUM] `manualExpanding: true` with `expandedRowModel` registered + +Wrong: + +```ts +// manualExpanding bypasses the expanded row model; sub-rows are never flattened +const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, // ignored + columns, + data, + getSubRows: (r) => r.subRows, + manualExpanding: true, +}) +``` + +Correct: + +```ts +// Manual expanding is for server-side patterns where the server returns +// a pre-flattened view based on which rows are expanded. +const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: {}, // no expandedRowModel for manual mode + columns, + data: dataQuery.data, // server returns flattened rows when expanded + manualExpanding: true, + state: { expanded }, + onExpandedChange: setExpanded, +}) + +// For client-side tree, omit manualExpanding: +const clientTable = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, + data, + getSubRows: (r) => r.subRows, +}) +``` + +With `manualExpanding: true`, `getExpandedRowModel` skips the registered factory and returns `getPreExpandedRowModel()` (sorted rows). The expanded state still tracks "which rows are open" but the row model is NOT inflated. + +Source: packages/table-core/src/core/row-models/coreRowModelsFeature.utils.ts + +### [CRITICAL] Reimplementing tree flattening manually + +Wrong: + +```ts +// Hand-rolled tree-to-flat conversion +const flatRows = useMemo(() => { + const out: Person[] = [] + function walk(rows: Person[], depth = 0) { + rows.forEach((r) => { + out.push({ ...r, depth }) + if (expanded[r.id]) walk(r.subRows ?? [], depth + 1) + }) + } + walk(data) + return out +}, [data, expanded]) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowExpandingFeature }), + _rowModels: { expandedRowModel: createExpandedRowModel() }, + columns, data, + getSubRows: (r) => r.subRows, +}) + +// then: table.getRowModel().rows already has row.depth and the expanded view +table.getRowModel().rows.map((row) => /* render with row.depth */) +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/grouping` — pairs with expanding for drill-down on grouped rows +- `tanstack-table/pagination` — `paginateExpandedRows` interaction +- `tanstack-table/filtering` — `filterFromLeafRows` / `maxLeafRowFilterDepth` for tree filtering diff --git a/packages/table-core/skills/row-pinning/SKILL.md b/packages/table-core/skills/row-pinning/SKILL.md new file mode 100644 index 0000000000..61fd442ea9 --- /dev/null +++ b/packages/table-core/skills/row-pinning/SKILL.md @@ -0,0 +1,269 @@ +--- +name: row-pinning +description: > + Pin specific rows to a top or bottom region in TanStack Table v9. State shape + is `rowPinning: { top: string[]; bottom: string[] }` keyed by `row.id`. Covers + `rowPinningFeature`, `row.pin(position, includeLeafRows?, includeParentRows?)`, + `row.getIsPinned` / `getPinnedIndex` / `getCanPin`, `table.getTopRows` / + `getBottomRows` / `getCenterRows` / `getIsSomeRowsPinned`, the + `enableRowPinning` option (bool or row predicate), and `keepPinnedRows` + (default true — persist across pagination/filtering vs. hide when filtered out). + Simpler pipeline than column pinning — only one reorder step: Row Pinning → + Sorting. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/guide/row-pinning.md + - TanStack/table:packages/table-core/src/features/row-pinning/rowPinningFeature.utils.ts + - TanStack/table:examples/react/row-pinning/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management`. Read it first for the atom model. + +## Setup + +```ts +import { + tableFeatures, + rowPinningFeature, + rowPaginationFeature, + createPaginatedRowModel, + constructTable, +} from '@tanstack/table-core' +import type { RowPinningState } from '@tanstack/table-core' + +const _features = tableFeatures({ rowPinningFeature, rowPaginationFeature }) + +const table = constructTable({ + _features, + _rowModels: { + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + getRowId: (row) => row.userId, // ← essentially mandatory + initialState: { + rowPinning: { top: [], bottom: [] } satisfies RowPinningState, + }, +}) + +// Pin a row +row.pin('top') // or 'bottom' | false +``` + +## Core Patterns + +### Pin/unpin buttons in a cell + +```tsx +// From examples/react/row-pinning/src/main.tsx + + +{row.getIsPinned() && } +``` + +For grouped/expanded data, pass include flags: + +```ts +row.pin('top', /* includeLeafRows */ true, /* includeParentRows */ false) +``` + +### Render pinned rows separately + +```tsx + + {table.getTopRows().map((row) => ( + + ))} + {table.getCenterRows().map((row) => ( + + {row.getAllCells().map((cell) => ( + + + + ))} + + ))} + {table.getBottomRows().map((row) => ( + + ))} + +``` + +### Disable persistence across pagination + +```ts +const table = constructTable({ + _features: tableFeatures({ rowPinningFeature, columnFilteringFeature }), + _rowModels: { filteredRowModel: createFilteredRowModel(filterFns) }, + columns, + data, + getRowId: (row) => row.id, + keepPinnedRows: false, // pinned rows disappear when filtered/paginated out +}) +``` + +`keepPinnedRows: true` (default) keeps pinned rows visible even when their underlying row would otherwise be filtered or paginated away. + +### Conditional pin permission + +```ts +const table = constructTable({ + _features, + columns, + data, + enableRowPinning: (row) => !row.original.archived, // predicate form +}) +``` + +## Common Mistakes + +### [HIGH] Omitting `getRowId` so pins attach to array indices + +Wrong: + +```ts +// row.id defaults to row.index; pin survives wrong rows after refetch +const table = useTable({ + _features: tableFeatures({ rowPinningFeature, rowPaginationFeature }), + data, // refetched periodically +}) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowPinningFeature, rowPaginationFeature }), + data, + getRowId: (row) => row.userId, // or row.uuid, row.id from API, etc. +}) + +// For grouped/expanded data, pass the include flags too: +row.pin('top', includeLeafRows, includeParentRows) +``` + +`rowPinning.top` and `rowPinning.bottom` are arrays of string row ids. Default `row.id` is the data array index — refetched data reuses index 3 for a different record, but the pinning state still pins index 3. + +Source: docs/guide/row-selection.md (same root principle); examples/react/row-pinning/src/main.tsx + +### [MEDIUM] Surprise behavior from `keepPinnedRows: true` default + +Wrong: + +```ts +// Expecting pinned rows to vanish on filter, but they don't (default) +const table = useTable({ + _features: tableFeatures({ rowPinningFeature, columnFilteringFeature }), + // keepPinnedRows defaults to true; pinned rows survive filtering +}) +``` + +Correct: + +```ts +// Be explicit about the UX you want +const table = useTable({ + _features: tableFeatures({ rowPinningFeature, columnFilteringFeature }), + keepPinnedRows: false, // pinned rows disappear when filtered/paginated out +}) + +// Or keep the default and render pinned separately: + + {table.getTopRows().map((row) => )} + {table.getCenterRows().map((row) => )} + {table.getBottomRows().map((row) => )} + +``` + +`keepPinnedRows: true` makes `getTopRows()` / `getBottomRows()` search the full pre-pagination row set; `false` only finds rows currently in the row model. + +Source: packages/table-core/src/features/row-pinning/rowPinningFeature.utils.ts; examples/react/row-pinning/src/main.tsx + +### [MEDIUM] Rendering pinned rows TWICE (once at top/bottom, once in main flow) + +Wrong: + +```tsx + + {table.getTopRows().map((row) => ( + + ))} + {table.getRowModel().rows.map( + ( + row, // ← still includes pinned rows + ) => ( + ... + ), + )} + {table.getBottomRows().map((row) => ( + + ))} + +``` + +Correct: + +```tsx + + {table.getTopRows().map((row) => ( + + ))} + {table.getCenterRows().map((row) => ( + + {row.getAllCells().map((cell) => ( + + + + ))} + + ))} + {table.getBottomRows().map((row) => ( + + ))} + +``` + +`getRowModel()` returns the complete current row model with pinned rows still in it. Use `getCenterRows()` for the main flow. Use `getRowModel()` only if you intentionally want pinned rows duplicated. + +Source: examples/react/row-pinning/src/main.tsx + +### [CRITICAL] Reimplementing pin behavior manually + +Wrong: + +```ts +// Hand-rolled "pinned" map + manual filter on render +const [pinned, setPinned] = useState>({}) +const pinnedRows = rows.filter((r) => pinned[r.id]) +const otherRows = rows.filter((r) => !pinned[r.id]) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowPinningFeature }), + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, +}) + +row.pin('top') // pin one row +table.setRowPinning({ top: ['a', 'b'], bottom: [] }) // bulk set +table.getTopRows() +table.getCenterRows() +table.getBottomRows() +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/state-management` — `rowPinning` state slice ownership +- `tanstack-table/row-selection` — same `getRowId` stability concern +- `tanstack-table/column-layout` — column pinning sits in a separate, more complex pipeline diff --git a/packages/table-core/skills/row-selection/SKILL.md b/packages/table-core/skills/row-selection/SKILL.md new file mode 100644 index 0000000000..2139c8d15e --- /dev/null +++ b/packages/table-core/skills/row-selection/SKILL.md @@ -0,0 +1,391 @@ +--- +name: row-selection +description: > + Track which rows are selected in TanStack Table v9 via + `rowSelection: Record`. Covers `rowSelectionFeature`, + the three selected-row APIs (`getSelectedRowModel`, `getFilteredSelectedRowModel`, + `getGroupedSelectedRowModel`), `row.toggleSelected` / `getIsSelected` / + `getIsSomeSelected` (indeterminate) / `getCanSelect` / `getCanMultiSelect`, + `row.getToggleSelectedHandler`, header APIs (`getIsAllRowsSelected` / + `getIsSomeRowsSelected` / `getToggleAllRowsSelectedHandler` and the page-aware + variants), `enableRowSelection` (bool or predicate), + `enableMultiRowSelection: false` for radio-style, `enableSubRowSelection`, + and why `getRowId` is essentially mandatory — especially under server pagination. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management + - column-definitions +sources: + - TanStack/table:docs/guide/row-selection.md + - TanStack/table:packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + - TanStack/table:packages/table-core/src/features/row-selection/rowSelectionFeature.types.ts + - TanStack/table:examples/react/row-selection/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/column-definitions`. Read those first for state ownership and `getRowId`. + +## Setup + +```ts +import { + tableFeatures, + rowSelectionFeature, + constructTable, +} from '@tanstack/table-core' +import type { RowSelectionState } from '@tanstack/table-core' + +const _features = tableFeatures({ rowSelectionFeature }) + +const table = constructTable({ + _features, + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, // ← essentially mandatory + initialState: { rowSelection: {} satisfies RowSelectionState }, + enableRowSelection: true, +}) +``` + +## Core Patterns + +### Select column with header "select all" + per-row checkbox + +```tsx +// From examples/react/row-selection/src/main.tsx +columnHelper.display({ + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ), +}), +``` + +### Single-select (radio-style) + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, + enableMultiRowSelection: false, // ← radio-like +}) + +// Cell renders a radio, no "select all" header makes sense +columnHelper.display({ + id: 'select', + header: '', // no select-all in single-select mode + cell: ({ row }) => ( + + ), +}) +``` + +### Conditional selection per row + +```ts +const table = useTable({ + _features, + columns, + data, + getRowId: (row) => row.id, + enableRowSelection: (row) => row.original.age > 18, // predicate form +}) +``` + +### Three selected-row APIs + +```ts +table.getSelectedRowModel() // built off core — raw data +table.getFilteredSelectedRowModel() // built off filtered — current filters applied +table.getGroupedSelectedRowModel() // built off grouped — current groups applied +``` + +### Hoist selection to an external atom + +```tsx +import { useCreateAtom } from '@tanstack/react-store' + +const rowSelectionAtom = useCreateAtom({}) + +const table = useTable({ + _features, + columns, + data, + getRowId: (row) => row.id, + atoms: { rowSelection: rowSelectionAtom }, +}) + +// Send selected IDs to an API call from a sibling component +function ExportButton() { + const selection = useStore(rowSelectionAtom) + return ( + + ) +} +``` + +## Common Mistakes + +### [HIGH] Omitting `getRowId` under `manualPagination` + +Wrong: + +```ts +// Server-side pagination + default row.id = row.index +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowPaginationFeature }), + data, // only current page from server + manualPagination: true, + rowCount, +}) +// After paging, rowSelection: { '5': true } is ambiguous +// — selection appears to move with the user. +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowPaginationFeature }), + data, + manualPagination: true, + rowCount, + getRowId: (row) => row.uuid, // stable across pages +}) + +// For "X of Y selected" with server-side pagination, read state directly: +const totalSelected = Object.keys(table.state.rowSelection).length +``` + +`row.id` defaults to `row.index`. Under `manualPagination`, every page reuses indices 0..n-1, so selection IDs collide across pages. + +Source: docs/guide/row-selection.md; examples/react/row-selection/src/main.tsx + +### [MEDIUM] `enableMultiRowSelection: false` + a "select all" checkbox header + +Wrong: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + enableMultiRowSelection: false, // radio-like +}) + +// Header still renders a checkbox + indeterminate + +``` + +Correct: + +```tsx +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + enableMultiRowSelection: false, + getRowId: (row) => row.id, +}) + +// Drop the toggle-all header in single-select mode +columnHelper.display({ + id: 'select', + header: '', + cell: ({ row }) => ( + + ), +}) +``` + +In single-select mode, `mutateRowIsSelected` clears all other ids before adding the new one. "Select all" becomes effectively no-op and indeterminate is meaningless. + +Source: docs/guide/row-selection.md; packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + +### [HIGH] `getSelectedRowModel().flatRows` for counts under `manualPagination` + +Wrong: + +```ts +// Under manualPagination, only counts the visible page's selected rows +const selectedCount = table.getSelectedRowModel().flatRows.length + +const handleBulkAction = () => { + const ids = table.getSelectedRowModel().flatRows.map((row) => row.original.id) + api.archive(ids) // missing all selections from other pages! +} +``` + +Correct: + +```ts +// For counts and id lists under manualPagination, read state directly +const selectedCount = Object.keys(table.state.rowSelection).length + +const handleBulkAction = () => { + const ids = Object.keys(table.state.rowSelection) + api.archive(ids) +} +// (Client-side: getSelectedRowModel is fine — data contains every row.) +``` + +`getSelectedRowModel` walks the core row model — which under `manualPagination` only contains the current page. `state.rowSelection` may contain ids that aren't in `data` (that's by design). + +Source: docs/guide/row-selection.md; packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + +### [MEDIUM] Surprise sub-row propagation from `enableSubRowSelection` default + +Wrong: + +```ts +// Default behavior: clicking the parent selects all children +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowExpandingFeature }), + getSubRows: (row) => row.subRows, + // enableSubRowSelection unset — defaults to true +}) +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature, rowExpandingFeature }), + getSubRows: (row) => row.subRows, + enableSubRowSelection: false, // toggling parent doesn't touch subRows +}) + +// Or selectively: +enableSubRowSelection: (row) => row.depth > 0, + +// Indeterminate parent checkbox + +``` + +`enableSubRowSelection: true` is the default. `mutateRowIsSelected` recurses into `row.subRows` when truthy. Decide deliberately — "select group as a whole" UX wants this off. + +Source: docs/guide/row-selection.md; packages/table-core/src/features/row-selection/rowSelectionFeature.utils.ts + +### [HIGH] Parent checkbox stuck "unchecked" with all sub-rows selected (deep trees) + +Wrong: + +```ts +// Returns false even when all leaf descendants are selected +const isParentChecked = row.getIsAllSubRowsSelected() +``` + +Correct: + +```ts +const allLeafs = row.getLeafRows() +const allSelected = + allLeafs.length > 0 && allLeafs.every((r) => r.getIsSelected()) +const someSelected = allLeafs.some((r) => r.getIsSelected()) +``` + +`getIsAllSubRowsSelected` only counts direct children. With multi-level grouping, a parent reports based on a partial count. + +Source: https://github.com/TanStack/table/issues/4878; https://github.com/TanStack/table/issues/4759 + +### [HIGH] Stale rowSelection IDs after data refresh + +Wrong: + +```ts +useEffect(() => { + refreshData() // selection still references deleted IDs +}, [trigger]) +``` + +Correct: + +```ts +useEffect(() => { + setRowSelection((prev) => { + const validIds = new Set(data.map((row) => row.id)) + const next: RowSelectionState = {} + for (const id in prev) if (validIds.has(id)) next[id] = prev[id] + return next + }) +}, [data]) +``` + +v8 removed v7's `autoResetSelectedRows`. With websockets / refetch, IDs that no longer exist remain in `rowSelection` and `getIsAllRowsSelected()` returns true based on stale state. Prune yourself. + +Source: https://github.com/TanStack/table/issues/5850; https://github.com/TanStack/table/issues/4498 + +### [CRITICAL] Reimplementing selection state manually + +Wrong: + +```ts +// Hand-rolled "selected" set, bypassing the table +const [selected, setSelected] = useState(new Set()) +const toggle = (id: string) => { + setSelected((s) => { + const next = new Set(s) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) +} +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSelectionFeature }), + _rowModels: {}, + columns, + data, + getRowId: (row) => row.id, +}) +row.toggleSelected() +row.toggleSelected(true) +table.toggleAllRowsSelected() +table.setRowSelection({ abc: true }) +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/column-definitions` — `getRowId` is the foundation of every row-keyed feature +- `tanstack-table/state-management` — `rowSelection` slice + atoms for sharing selection +- `tanstack-table/pagination` — server-pagination "select all" pitfalls +- `tanstack-table/grouping` — `getGroupedSelectedRowModel` distinction diff --git a/packages/table-core/skills/setup/SKILL.md b/packages/table-core/skills/setup/SKILL.md new file mode 100644 index 0000000000..01b1765689 --- /dev/null +++ b/packages/table-core/skills/setup/SKILL.md @@ -0,0 +1,400 @@ +--- +name: setup +description: > + Install a TanStack Table v9 framework adapter and wire up a first table with + `tableFeatures({...})` declaring `_features`, an `_rowModels` map of factory + results (`createSortedRowModel(sortFns)`, `createFilteredRowModel(filterFns)`, + `createPaginatedRowModel()`, …), a `createColumnHelper()` + column set, and the framework `useTable` / `injectTable` / `createTable` / + `constructTable` entry point. Covers the registry model, why `_features` must + be module-scoped, when to reach for `stockFeatures`, and `coreFeatures`. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management + - column-definitions +sources: + - TanStack/table:docs/installation.md + - TanStack/table:docs/overview.md + - TanStack/table:docs/guide/tables.md + - TanStack/table:docs/guide/features.md + - TanStack/table:packages/table-core/src/index.ts + - TanStack/table:examples/react/basic-use-table/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/column-definitions`. Read those first for the v9 atom model and the `createColumnHelper()` shape. + +## Setup + +TanStack Table v9 separates a framework-agnostic core (`@tanstack/table-core`) from per-framework adapters. Every table — vanilla or framework — must declare two new v9-required options at construction time: `_features` (the registry of feature plugins) and `_rowModels` (the map of pipeline factories). + +```ts +// Framework-agnostic, using @tanstack/table-core directly. +import { + constructTable, + tableFeatures, + createColumnHelper, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/table-core' + +type Person = { firstName: string; lastName: string; age: number } + +// 1. Declare features at MODULE scope (stable reference). This is required — +// a fresh `_features` object on every call destroys feature registration. +const _features = tableFeatures({ rowSortingFeature }) + +// 2. Build a column helper bound to BOTH TFeatures and TData. +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { header: 'First' }), + columnHelper.accessor('lastName', { header: 'Last' }), + columnHelper.accessor('age', { header: 'Age', sortFn: 'alphanumeric' }), +]) + +const data: Person[] = [ + { firstName: 'Ada', lastName: 'Lovelace', age: 36 }, + { firstName: 'Grace', lastName: 'Hopper', age: 85 }, +] + +const table = constructTable({ + _features, + _rowModels: { + // Pair every feature with its matching row-model factory. + sortedRowModel: createSortedRowModel(sortFns), + }, + columns, + data, +}) + +// Read header groups and row model to render. +table.getHeaderGroups() +table.getRowModel().rows +``` + +Framework adapters wrap this with reactivity: + +| Framework | Entry point | Package | +| ------------------------------ | ---------------------------------------------- | ------------------------- | +| React, Preact, Vue, Solid, Lit | `useTable` / `createTable` / `TableController` | `@tanstack/-table` | +| Angular | `injectTable` | `@tanstack/angular-table` | +| Svelte 5 | `createTable` (runes) | `@tanstack/svelte-table` | +| Vanilla JS | `constructTable` + `storeReactivityBindings()` | `@tanstack/table-core` | + +## Core Patterns + +### Minimum table (no extra features) + +```ts +const _features = tableFeatures({}) // core features only +const table = constructTable({ + _features, + _rowModels: {}, // core row model auto-included + columns, + data, +}) +``` + +`tableFeatures({})` is the canonical "I only want the core read pipeline" setup. + +### Add features and their row models together + +```ts +import { + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, + createSortedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + sortFns, + filterFns, +} from '@tanstack/table-core' + +const _features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + columnFilteringFeature, +}) + +const table = constructTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + filteredRowModel: createFilteredRowModel(filterFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) +``` + +Each feature and its row-model factory are registered together — TypeScript exposes the feature's APIs (`table.setSorting`, `table.nextPage`, `table.setColumnFilters`) only when its feature plugin is present in `_features`. + +### Vanilla JS reactivity binding + +```ts +import { constructTable, tableFeatures } from '@tanstack/table-core' +import { storeReactivityBindings } from '@tanstack/table-core' + +const _features = tableFeatures({}) + +const table = constructTable({ + _features, + _rowModels: {}, + columns, + data, + _rowModelFns: {}, + // Use the vanilla binding when no framework adapter exists + _processingMode: 'core', +}) + +// Subscribe to a slice +const unsub = table.atoms.sorting.subscribe((sorting) => { + console.log('sorting changed', sorting) +}) +``` + +### `stockFeatures` for v8-like "everything on" + +Reach for this only as a transitional aid. It re-introduces v8 bundle size (~15–20kb) and undoes v9's tree-shaking benefit. + +```ts +import { stockFeatures, tableFeatures } from '@tanstack/table-core' + +// Discouraged in new code. Register only what you use. +const _features = tableFeatures(stockFeatures) +``` + +## Common Mistakes + +### [CRITICAL] API/state slice missing because the feature was not registered in `_features` + +Wrong: + +```ts +// rowSortingFeature missing — table.setSorting / state.sorting unavailable +const _features = tableFeatures({}) // empty +const table = useTable({ _features, _rowModels: {}, columns, data }) +table.setSorting([{ id: 'age', desc: true }]) // ❌ does not exist on this table type +``` + +Correct: + +```ts +// Register every feature you intend to use; pair with its row model when applicable +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) +``` + +In v9, `_features` is a tree-shakeable registry — TypeScript hides APIs for unregistered features and the runtime atom is never created. Agents who see `table.setColumnFilters` "missing" often incorrectly conclude the API was removed. + +Source: maintainer interview (Phase 4, 2026-05-17) + +### [CRITICAL] Hallucinating v7 / v8 `useReactTable` + `getCoreRowModel()` shape + +Wrong: + +```ts +// v7 +import { useTable, useSortBy } from 'react-table' +const table = useTable({ columns, data }, useSortBy) + +// v8 +import { useReactTable, getCoreRowModel } from '@tanstack/react-table' +const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), +}) +``` + +Correct: + +```ts +import { + useTable, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, +} from '@tanstack/react-table' + +const _features = tableFeatures({ rowSortingFeature }) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +``` + +v7→v8 and v8→v9 both reshaped the API substantially. Agents trained on older data confidently emit v7/v8 shapes; v9 enforces `_features` + `_rowModels`. + +Source: maintainer interview (Phase 4, 2026-05-17) + +### [HIGH] Calling `tableFeatures({})` inside the component body + +Wrong: + +```tsx +function MyTable() { + // ❌ new object every render — destroys stable feature registration + const _features = tableFeatures({ rowSortingFeature }) + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +Correct: + +```tsx +// ✅ module-scoped, stable reference +const _features = tableFeatures({ rowSortingFeature }) + +function MyTable() { + const table = useTable({ _features, _rowModels: {}, columns, data }) +} +``` + +A fresh `_features` reference on each render churns the table's feature registry — the same way unstable `columns` or `data` cause infinite re-renders. Hoist to module scope or memoize. + +Source: docs/guide/data.md; examples/react/basic-use-table/src/main.tsx + +### [HIGH] Adding a row-model factory without registering its feature + +Wrong: + +```ts +// rowSortingFeature missing — sortedRowModel is orphaned and never runs +const _features = tableFeatures({ rowPaginationFeature }) +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), // no-op + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) +``` + +Correct: + +```ts +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) +const table = useTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, +}) +``` + +Row-model factories only run if their matching feature is in `_features`. Runtime silently degrades. + +Source: docs/guide/row-models.md; examples/react/basic-external-atoms/src/main.tsx + +### [CRITICAL] Reimplementing what built-in APIs already provide + +Wrong: + +```ts +// ❌ Reimplements sorting state manually instead of using the API +const [sorting, setSorting] = useState([]) +const sortedData = useMemo(() => [...data].sort((a, b) => /* …custom… */), [data, sorting]) +// then uses sortedData directly, bypassing the table +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +// then: table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() +``` + +TanStack Table IS a state-management coordinator. The maintainer flags hand-rolled state/sort/filter loops as the #1 tell that an AI wrote the code. Reach for `table.setSorting`, `row.toggleSelected`, `table.nextPage`, `table.setColumnFilters`, `column.toggleVisibility` first. + +Source: maintainer interview (Phase 4, 2026-05-17) + +### [HIGH] Bundling `stockFeatures` when only a few features are used + +Wrong: + +```ts +// Pulls in every feature even though only sorting+pagination are used +import { stockFeatures, tableFeatures } from '@tanstack/react-table' +const _features = tableFeatures(stockFeatures) +``` + +Correct: + +```ts +import { + tableFeatures, + rowSortingFeature, + rowPaginationFeature, +} from '@tanstack/react-table' +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) +``` + +Tree-shaking via `_features` is the headline reason for the v9 redesign. `stockFeatures` exists as a v8-style transitional escape hatch, not a default. + +Source: maintainer interview (Phase 4, 2026-05-17) + +### [CRITICAL] Empty array literal for `data` causes infinite re-renders + +Wrong: + +```tsx +// ❌ Fresh [] each render — infinite loop when items is undefined +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: items ?? [], +}) +``` + +Correct: + +```tsx +// ✅ Hoist the empty fallback OR memoize +const EMPTY: Person[] = [] + +const table = useTable({ + _features, + _rowModels: {}, + columns, + data: items ?? EMPTY, +}) +// or: const data = useMemo(() => items ?? [], [items]) +``` + +A new `[]` reference each render means the table sees a fresh `data` prop and rebuilds row models. Top recurring beginner issue. + +Source: https://github.com/TanStack/table/issues/4566; https://github.com/TanStack/table/issues/6002 + +## See also + +- `tanstack-table/state-management` — atom model, ownership precedence, state slices +- `tanstack-table/column-definitions` — `createColumnHelper()` and `getRowId` +- `tanstack-table/migrate-v8-to-v9` — full rename + restructure table for v8 → v9 upgrades diff --git a/packages/table-core/skills/sorting/SKILL.md b/packages/table-core/skills/sorting/SKILL.md new file mode 100644 index 0000000000..5366f2596b --- /dev/null +++ b/packages/table-core/skills/sorting/SKILL.md @@ -0,0 +1,344 @@ +--- +name: sorting +description: > + Sort rows in TanStack Table v9 with the `sortedRowModel` stage. Covers + `rowSortingFeature` + `createSortedRowModel(sortFns)`, the built-in `sortFns` + registry (renamed from v8 `sortingFns`), `state.sorting` (SortingState = + Array<{ id, desc }>), `onSortingChange`, `columnDef.sortFn` + (string | function | 'auto'), `sortDescFirst`, `sortUndefined` + ('first'|'last'|-1|1|false), `invertSorting`, `enableMultiSort`, + `maxMultiSortColCount`, `isMultiSortEvent`, `table.setSorting` / + `resetSorting`, `column.getToggleSortingHandler` / + `getNextSortingOrder` / `clearSorting` / `getCanSort` / `getCanMultiSort`, + `manualSorting` for server-side, and fuzzy `compareItems` pairing. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management + - customizing-feature-behavior +sources: + - TanStack/table:docs/guide/sorting.md + - TanStack/table:packages/table-core/src/fns/sortFns.ts + - TanStack/table:packages/table-core/src/features/row-sorting/createSortedRowModel.ts + - TanStack/table:packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts + - TanStack/table:examples/react/sorting/src/main.tsx +--- + +This skill builds on `tanstack-table/state-management` and `tanstack-table/customizing-feature-behavior`. Read those first for the atom model and `sortFn` overrides. + +## Setup + +```ts +import { + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + createColumnHelper, + constructTable, +} from '@tanstack/table-core' +import type { SortingState } from '@tanstack/table-core' + +type Person = { + firstName: string + lastName: string + age: number + status: 'single' | 'complicated' | 'relationship' +} + +const _features = tableFeatures({ rowSortingFeature }) +const columnHelper = createColumnHelper() + +const columns = columnHelper.columns([ + columnHelper.accessor('firstName', { sortFn: 'alphanumeric' }), + columnHelper.accessor('lastName', { + sortUndefined: 'last', + sortDescFirst: false, + }), + columnHelper.accessor('age', { sortFn: 'basic' }), +]) + +const table = constructTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + }, + columns, + data, + initialState: { sorting: [] satisfies SortingState }, +}) + +table.setSorting([{ id: 'age', desc: true }]) +``` + +## Core Patterns + +### Clickable header sorting with multi-sort on Shift+click + +```tsx +// From examples/react/sorting/src/main.tsx +{ + headerGroup.headers.map((header) => ( + + + {{ asc: ' 🔼', desc: ' 🔽' }[header.column.getIsSorted() as string] ?? + null} + + )) +} +``` + +`getToggleSortingHandler` already handles multi-sort when the user holds Shift (configurable via `isMultiSortEvent`). + +### Custom `sortFn` for an enum + +```ts +// From examples/react/sorting/src/main.tsx +const sortStatusFn: SortFn = ( + rowA, + rowB, + _columnId, +) => { + const statusOrder = ['single', 'complicated', 'relationship'] + return ( + statusOrder.indexOf(rowA.original.status) - + statusOrder.indexOf(rowB.original.status) + ) +} + +columnHelper.accessor('status', { sortFn: sortStatusFn }) +``` + +Always return an ascending-order comparison. The row model multiplies by `-1` for descending and again for `invertSorting`. + +### Direction control with `sortUndefined` and `invertSorting` + +```ts +columnHelper.accessor('rank', { + invertSorting: true, // rank 1 above rank 2 even when "descending" +}) + +columnHelper.accessor('lastName', { + sortUndefined: 'last', // ABSOLUTE: end regardless of asc/desc + sortDescFirst: false, +}) +``` + +`sortUndefined` literals (`'first'`, `'last'`) are absolute. Numeric (`-1`, `1`) flips with `desc`. + +### Server-side sorting + +```tsx +const [sorting, setSorting] = useState([]) +const { data } = useQuery({ + queryKey: ['rows', sorting], + queryFn: () => + fetch('/api/rows?sort=' + serialize(sorting)).then((r) => r.json()), +}) + +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: {}, // omit sortedRowModel — server sorts + columns, + data, + manualSorting: true, + state: { sorting }, + onSortingChange: setSorting, +}) +``` + +## Common Mistakes + +### [HIGH] Using v8 `sortingFn` / `sortingFns` names + +Wrong: + +```tsx +{ + accessorKey: 'fullName', + sortingFn: 'alphanumeric', // v8 name — falls through to sortFn_basic +} +// useTable({ sortingFns: { ...sortingFns, myFn } }) // v8 option name +``` + +Correct: + +```tsx +import { sortFns, createSortedRowModel } from '@tanstack/react-table' + +columnHelper.accessor('firstName', { + sortFn: 'alphanumeric', +}) + +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { + sortedRowModel: createSortedRowModel({ + ...sortFns, + myCustom: (a, b, id) => a.original[id] - b.original[id], + }), + }, + columns, + data, +}) +``` + +v9 renamed `columnDef.sortingFn → sortFn`, `tableOptions.sortingFns → sortFns`, exported registry `sortingFns → sortFns`. The new column option defaults to `'auto'` and falls back to `sortFn_basic` when lookup misses — wrong names sort wrong, silently. + +Source: packages/table-core/src/features/row-sorting/rowSortingFeature.utils.ts + +### [MEDIUM] Expecting `sortUndefined: 'first' | 'last'` to work in v8 + +Wrong: + +```tsx +// agent assumes numeric and literal forms are interchangeable +{ accessorKey: 'lastName', sortUndefined: -1 } // ascending-first, descending-LAST +``` + +Correct: + +```tsx +// From examples/react/sorting/src/main.tsx +columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + sortUndefined: 'last', // ABSOLUTE: always at end regardless of asc/desc + sortDescFirst: false, +}) +``` + +v8 only had `false | -1 | 1`. v9 added `'first'` / `'last'`. Numeric flips with `desc`; literals are absolute. + +Source: packages/table-core/src/features/row-sorting/createSortedRowModel.ts + +### [MEDIUM] Custom `sortFn` factors `desc` in itself + +Wrong: + +```tsx +// takes sort direction into account, breaks the toggle +const customSort: SortFn = (a, b, id, desc) => { + // desc isn't even a parameter — agents try to detect via state + const cmp = a.original[id] - b.original[id] + return desc ? -cmp : cmp +} +``` + +Correct: + +```tsx +// From examples/react/sorting/src/main.tsx +// Always return ascending; the row model handles desc & invertSorting. +const sortStatusFn: SortFn = (rowA, rowB, _columnId) => { + const statusOrder = ['single', 'complicated', 'relationship'] + return ( + statusOrder.indexOf(rowA.original.status) - + statusOrder.indexOf(rowB.original.status) + ) +} +``` + +From the docs guide: "The comparison function does not need to take whether or not the column is in descending or ascending order into account. The row models will take care of that logic." Doubly-flipping yields broken toggles. + +Source: packages/table-core/src/features/row-sorting/createSortedRowModel.ts + +### [MEDIUM] Fuzzy filter without a fuzzy-aware `sortFn` + +Wrong: + +```ts +columnHelper.accessor('fullName', { + filterFn: 'fuzzy', + // BUG: rows sort alphabetically, not by match rank +}) +``` + +Correct: + +```ts +import { compareItems } from '@tanstack/match-sorter-utils' + +const fuzzySort: SortFn = (rowA, rowB, columnId) => { + let dir = 0 + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId].itemRank!, + rowB.columnFiltersMeta[columnId].itemRank!, + ) + } + return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir +} + +columnHelper.accessor('fullName', { filterFn: 'fuzzy', sortFn: fuzzySort }) +``` + +The fuzzy filter writes `{ itemRank }` into `row.columnFiltersMeta[columnId]` via `addMeta`. Without a sortFn that reads it, results sort alphabetically and defeat the fuzzy ranking. + +Source: examples/react/filters-fuzzy/src/main.tsx + +### [MEDIUM] `getCanSort` returns false for display columns under `manualSorting` + +Wrong: + +```ts +// getCanSort() returns false even though manualSorting is true +const table = useTable({ + manualSorting: true, + columns: [ + { id: 'computed', header: 'Computed', cell: (info) => row.x + row.y }, + ], +}) +``` + +Correct: + +```ts +columnHelper.display({ + id: 'computed', + header: 'Computed', + enableSorting: true, // force-enable for manualSorting + cell: (info) => info.row.original.x + info.row.original.y, +}) +``` + +`getCanSort` checks for `accessorKey`/`accessorFn` even under `manualSorting`. Force it on display columns via `enableSorting: true` (and let the server sort). + +Source: https://github.com/TanStack/table/issues/4136 + +### [CRITICAL] Reimplementing what built-in APIs provide + +Wrong: + +```ts +const [sorting, setSorting] = useState([]) +const sortedData = useMemo( + () => [...data].sort(/* …custom… */), + [data, sorting], +) +// then uses sortedData directly, bypassing the table +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +// table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() +``` + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/customizing-feature-behavior` — `sortFn` authoring + `addMeta` chain +- `tanstack-table/filtering` — fuzzy filter pattern that pairs with `fuzzySort` +- `tanstack-table/state-management` — `manualSorting` + server-side state ownership diff --git a/packages/table-core/skills/state-management/SKILL.md b/packages/table-core/skills/state-management/SKILL.md new file mode 100644 index 0000000000..1dc8ee50aa --- /dev/null +++ b/packages/table-core/skills/state-management/SKILL.md @@ -0,0 +1,388 @@ +--- +name: state-management +description: > + Coordinate TanStack Table v9 state across `initialState`, controlled + `state`+`on*Change`, and external `atoms`. Covers the atom model + (`table.atoms.`, `table.baseAtoms.`, `table.store`, `table.state`), + per-slice precedence (atoms beat state beat initialState beat baseAtoms), + `manualSorting` / `manualFiltering` / `manualPagination` / `manualGrouping` / + `manualExpanding` for server-side data, `autoResetPageIndex` / `autoResetAll`, + reset APIs (`resetSorting`, `resetPagination`, `reset()`), and the + `SortingState` / `PaginationState` / `RowSelectionState` / `ColumnFiltersState` / + `GroupingState` shapes. Foundational for every other skill. +type: core +library: tanstack-table +library_version: '9.0.0-alpha.47' +sources: + - TanStack/table:docs/framework/vanilla/guide/table-state.md + - TanStack/table:docs/framework/react/guide/table-state.md + - TanStack/table:packages/table-core/src/store-reactivity-bindings.ts + - TanStack/table:packages/table-core/src/reactivity.ts + - TanStack/table:packages/table-core/src/core/table/constructTable.ts +--- + +## Setup + +TanStack Table v9 is built on TanStack Store. Each state slice (sorting, pagination, columnFilters, rowSelection, columnVisibility, …) is a separate atom. There are four ownership patterns, and the table reads from them in a fixed precedence. + +```ts +import { + constructTable, + tableFeatures, + rowSortingFeature, + rowPaginationFeature, + createSortedRowModel, + createPaginatedRowModel, + sortFns, +} from '@tanstack/table-core' + +const _features = tableFeatures({ rowSortingFeature, rowPaginationFeature }) + +const table = constructTable({ + _features, + _rowModels: { + sortedRowModel: createSortedRowModel(sortFns), + paginatedRowModel: createPaginatedRowModel(), + }, + columns, + data, + // 1. initialState — set starting values; read once at construction + initialState: { + sorting: [{ id: 'lastName', desc: false }], + pagination: { pageIndex: 0, pageSize: 10 }, + }, +}) + +// Read APIs: +table.store.state // flat snapshot of every slice (no subscription) +table.atoms.sorting.get() // single-slice atom read (no subscription) +table.state // typed output of the `useTable` selector (framework adapters) + +// Write APIs use the feature setters — they're atom-aware: +table.setSorting([{ id: 'firstName', desc: true }]) +table.setPageIndex(2) +``` + +The four ownership patterns per slice: + +| Pattern | When to use | Wins over | +| ----------------------------------- | ---------------------------------------------------- | ------------------------------- | +| internal (default) | Most slices in a simple table | nothing — baseline | +| `initialState.` | Set starting value only | internal default | +| `state.` + `onChange` | v8-style controlled state | `initialState` | +| `atoms.` | v9 preferred — share with other components / queries | `state`+`on*Change` (silently!) | + +## Core Patterns + +### Internal state with `initialState` + +```ts +const table = constructTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + initialState: { sorting: [{ id: 'age', desc: false }] }, +}) +// State lives entirely inside the table. +table.setSorting([{ id: 'firstName', desc: true }]) +``` + +### Controlled state with `state` + `on*Change` (v8-style) + +```tsx +const [sorting, setSorting] = React.useState([]) + +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + state: { sorting }, + onSortingChange: setSorting, +}) +``` + +`state` and `on*Change` must be paired. Without the callback the table cannot update React state, so toggling sort appears to do nothing. + +### External atom (v9 preferred for shared slices) + +```tsx +import { useCreateAtom } from '@tanstack/react-store' + +function MyTable() { + // Hoist or pass via context to share with queries / other components. + const paginationAtom = useCreateAtom({ + pageIndex: 0, + pageSize: 10, + }) + + const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data, + atoms: { pagination: paginationAtom }, + // no state.pagination, no onPaginationChange needed + }) + + return +} +``` + +The atom IS the source of truth; `table.atoms.pagination` derives from it. + +### Server-side / manual mode + +```tsx +const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, +}) +const dataQuery = useQuery({ + queryKey: ['rows', pagination], + queryFn: () => fetchPage(pagination), +}) + +const table = useTable({ + _features: tableFeatures({ rowPaginationFeature }), + _rowModels: {}, // can drop paginatedRowModel — server paginates + columns, + data: dataQuery.data?.rows ?? EMPTY, + rowCount: dataQuery.data?.rowCount, // server tells the table the total + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, // ← tell the table to NOT re-paginate +}) +``` + +The same shape applies to `manualSorting`, `manualFiltering`, `manualGrouping`, `manualExpanding`. Without the flag, the table re-applies its client-side pipeline on top of already-prepared server data. + +## Common Mistakes + +### [CRITICAL] Passing both `state.` and `atoms.` + +Wrong: + +```tsx +// both ownership paths for the same slice +const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) +const [pagination, setPagination] = React.useState(...) + +const table = useTable({ + _features, _rowModels: {...}, columns, data, + state: { pagination }, // ignored + onPaginationChange: setPagination, + atoms: { pagination: paginationAtom }, // wins +}) +``` + +Correct: + +```tsx +// pick one ownership path per slice — here, external atoms +const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) + +const table = useTable({ + _features, _rowModels: {...}, columns, data, + atoms: { pagination: paginationAtom }, +}) +``` + +When both are supplied, the external atom wins silently. `state.pagination` becomes dead config and `setPagination` writes never reach the table. + +Source: docs/framework/react/guide/table-state.md; packages/table-core/src/core/table/constructTable.ts + +### [CRITICAL] Using external `state` without the matching `on*Change` callback + +Wrong: + +```tsx +const [sorting, setSorting] = React.useState([]) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + state: { sorting }, // no onSortingChange +}) +``` + +Correct: + +```tsx +const [sorting, setSorting] = React.useState([]) +const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + state: { sorting }, + onSortingChange: setSorting, +}) +``` + +The table keeps reading from `state.sorting`, so the UI looks stuck — sort toggles never make it back into React state. + +Source: docs/framework/react/guide/table-state.md; examples/react/basic-external-state/src/main.tsx + +### [HIGH] Using `initialState` to control or update state + +Wrong: + +```tsx +// updates to initialState are ignored after first render +function MyTable({ defaultSort }: { defaultSort: SortingState }) { + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + initialState: { sorting: defaultSort }, // later changes never sync + }) +} +``` + +Correct: + +```tsx +function MyTable({ defaultSort }: { defaultSort: SortingState }) { + const [sorting, setSorting] = React.useState(defaultSort) + const table = useTable({ + _features, + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, + state: { sorting }, + onSortingChange: setSorting, + }) +} +``` + +`initialState` is read once at construction to seed `baseAtoms`. Mutating it later does nothing. + +Source: docs/framework/vanilla/guide/table-state.md; docs/framework/react/guide/table-state.md + +### [HIGH] Writing to `table.baseAtoms.` while `atoms.` owns the slice + +Wrong: + +```ts +const paginationAtom = useCreateAtom({ pageIndex: 0, pageSize: 10 }) +const table = useTable({ _features, _rowModels: {...}, columns, data, atoms: { pagination: paginationAtom } }) + +table.baseAtoms.pagination.set((old) => ({ ...old, pageIndex: 0 })) +// baseAtom updated, but table.atoms.pagination still reads from paginationAtom +``` + +Correct: + +```ts +// Write to the external atom directly, OR use the feature's setter API +paginationAtom.set((old) => ({ ...old, pageIndex: 0 })) +// or +table.setPageIndex(0) // setter writes through the slice's updater (atom-aware) +``` + +When an external atom owns a slice, `table.atoms.` derives from it — not from `baseAtoms`. Direct base-atom writes drift and never surface in the UI. + +Source: docs/framework/vanilla/guide/table-state.md; packages/table-core/src/core/table/constructTable.ts + +### [CRITICAL] Forgetting `manualSorting` / `manualFiltering` / `manualPagination` for server-side data + +Wrong: + +```tsx +// data is already paginated server-side, but table still slices it +const dataQuery = useQuery({ + queryKey: ['data', pagination], + queryFn: fetchPage, +}) +const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + data: dataQuery.data?.rows ?? [], + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, + // ❌ missing manualPagination: true +}) +``` + +Correct: + +```tsx +const table = useTable({ + _features, + _rowModels: {}, // drop paginatedRowModel if fully server-side + columns, + data: dataQuery.data?.rows ?? [], + rowCount: dataQuery.data?.rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +Without the manual flag, the table re-applies its client-side row models on top of already-prepared server data — wrong rows, broken page math, blank pages. + +Source: docs/framework/react/guide/table-state.md; packages/table-core/src/features/row-pagination/rowPaginationFeature.types.ts + +### [HIGH] Using `table.reset()` to clear externally owned state + +Wrong: + +```ts +// external atom keeps its current value; only baseAtoms reset +const sortingAtom = useCreateAtom([]) +const table = useTable({ + _features, _rowModels: {...}, columns, data, + atoms: { sorting: sortingAtom }, +}) +table.reset() // sortingAtom is NOT cleared +``` + +Correct: + +```ts +// Use feature-specific reset — atom-aware +table.resetSorting() +// or, to clear the external atom specifically: +sortingAtom.set([]) +``` + +`table.reset()` only resets `baseAtoms` to `initialState`; slices owned by external atoms or external `state` are untouched. The atom split makes `reset()` less safe than v8. + +Source: docs/framework/vanilla/guide/table-state.md; packages/table-core/src/core/table/coreTablesFeature.utils.ts + +### [CRITICAL] Reimplementing what built-in setters provide + +Wrong: + +```ts +// Reimplements sorting state manually instead of using the API +const [sorting, setSorting] = useState([]) +const sortedData = useMemo(() => [...data].sort(/* ... */), [data, sorting]) +// then uses sortedData directly, bypassing the table +``` + +Correct: + +```ts +const table = useTable({ + _features: tableFeatures({ rowSortingFeature }), + _rowModels: { sortedRowModel: createSortedRowModel(sortFns) }, + columns, + data, +}) +// table.setSorting(...), column.toggleSorting(), header.getToggleSortingHandler() +``` + +The setters honor reset behavior, multi-sort, internal invariants. Hand-rolled state loops skip all of that. + +Source: maintainer interview (Phase 4, 2026-05-17) + +## See also + +- `tanstack-table/setup` — how `_features` and `_rowModels` are wired +- `tanstack-table/pagination`, `tanstack-table/sorting`, `tanstack-table/filtering` — feature-specific `manual*` and reset semantics +- `tanstack-table/migrate-v8-to-v9` — `table.getState()` → `table.store.state` / `table.atoms..get()` diff --git a/packages/table-devtools/README.md b/packages/table-devtools/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/table-devtools/README.md +++ b/packages/table-devtools/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/table-devtools/package.json b/packages/table-devtools/package.json index e9ce689e05..009749f6c5 100644 --- a/packages/table-devtools/package.json +++ b/packages/table-devtools/package.json @@ -17,7 +17,8 @@ "keywords": [ "tanstack", "table", - "devtools" + "devtools", + "tanstack-intent" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", @@ -43,7 +44,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "dependencies": { "@tanstack/devtools-ui": "^0.5.2", diff --git a/packages/table-devtools/skills/compose-with-tanstack-devtools/SKILL.md b/packages/table-devtools/skills/compose-with-tanstack-devtools/SKILL.md new file mode 100644 index 0000000000..2d507d3517 --- /dev/null +++ b/packages/table-devtools/skills/compose-with-tanstack-devtools/SKILL.md @@ -0,0 +1,103 @@ +--- +name: compose-with-tanstack-devtools +description: > + Internal implementation of TanStack Table devtools. Consumers should NOT + depend on `@tanstack/table-devtools` directly — install the framework-specific + adapter (`@tanstack/react-table-devtools`, `@tanstack/vue-table-devtools`, + `@tanstack/solid-table-devtools`, or `@tanstack/preact-table-devtools`) + instead. This skill explains the underlying solid-js-based implementation for + maintainers and contributors investigating internals. +type: composition +library: tanstack-table +library_version: '9.0.0-alpha.47' +requires: + - state-management +sources: + - TanStack/table:docs/devtools.md + - TanStack/table:packages/table-devtools/src/index.ts + - TanStack/table:packages/table-devtools/src/production.ts + - TanStack/table:packages/table-devtools/src/core.tsx + - TanStack/table:packages/table-devtools/src/tableTarget.ts +--- + +> **For consumers:** stop reading and install the framework adapter: +> +> - React: `@tanstack/react-table-devtools` +> - Vue: `@tanstack/vue-table-devtools` +> - Solid: `@tanstack/solid-table-devtools` +> - Preact: `@tanstack/preact-table-devtools` +> +> Angular, Lit, Svelte, and the vanilla `@tanstack/table-core` package **do not** currently ship table devtools adapters. There is no supported way to mount the devtools in those frameworks today. +> +> See the per-framework skills under `tanstack-table//compose-with-tanstack-devtools` for setup. This skill documents internals only. + +## Setup (internals) + +`@tanstack/table-devtools` is the shared core that every framework adapter wraps. It is **not** meant to be installed by application code. Its package.json declares dependencies on `solid-js`, `@tanstack/solid-store`, `@tanstack/devtools-ui`, `@tanstack/devtools-utils`, and `goober` — pulling it directly into a React/Vue/Preact app drags Solid's runtime into the bundle for no benefit, since the framework adapter already does the right thing. + +The package exports two surfaces: + +- **`@tanstack/table-devtools`** — `TableDevtoolsCore` resolves to a no-op when `process.env.NODE_ENV !== 'development'`. +- **`@tanstack/table-devtools/production`** — `TableDevtoolsCore` always resolves to the real implementation. + +Both surfaces also re-export the registration target store: + +```ts +import { + getTableDevtoolsTargets, + removeTableDevtoolsTarget, + setTableDevtoolsTarget, + subscribeTableDevtoolsTargets, + upsertTableDevtoolsTarget, +} from '@tanstack/table-devtools' +``` + +These are the functions the framework adapter hooks (`useTanStackTableDevtools`) call to register/unregister `Table` instances by id. + +## Architecture + +### Solid-based core + +The panel UI is built in Solid even when it is mounted inside a React/Vue/Preact tree: + +- `core.tsx` constructs `TableDevtoolsCore` via `constructCoreClass` from `@tanstack/devtools-utils/solid` and lazy-loads `./TableDevtools` (the Solid panel component). +- `useTableStore.ts` uses `@tanstack/solid-store` to back the panel's reactive state. +- Styling is handled with `goober` (atomic CSS-in-JS), keeping the bundle small and side-effect free. + +Framework adapters mount this Solid component inside the host framework via `@tanstack/devtools-utils` interop helpers: + +- `@tanstack/devtools-utils/react` (`createReactPlugin`) — used by `react-table-devtools` and `preact-table-devtools` plugin builders. +- `@tanstack/devtools-utils/solid` — used directly by `solid-table-devtools`. +- The vue adapter uses its own interop in `packages/vue-table-devtools/src/plugin.ts`. + +Each adapter exports `tableDevtoolsPlugin()` (returns a plugin descriptor for the `TanStackDevtools` host) and a `useTanStackTableDevtools(table, name?, options?)` hook idiomatic to the framework. The hook's only job is to call `upsertTableDevtoolsTarget(...)` on mount/update and `removeTableDevtoolsTarget(...)` on cleanup. + +### Registration target store (`tableTarget.ts`) + +The target store is the bridge between framework-specific hooks and the Solid panel: + +- `upsertTableDevtoolsTarget({ id, table, name })` — register or update a table by stable id. +- `removeTableDevtoolsTarget(id)` — unregister on cleanup. +- `setTableDevtoolsTarget(...)` — replace the full set (used internally). +- `subscribeTableDevtoolsTargets(listener)` — the Solid panel subscribes here to keep its selector in sync. +- `getTableDevtoolsTargets()` — snapshot for non-reactive reads. + +Ids are generated per-adapter using framework primitives (`React.useId()`, `preact/hooks` `useId()`, Solid's incrementing counter, Vue's `getCurrentInstance().uid`), guaranteeing stable identity across renders. + +### Dev vs. production swap + +Every entrypoint (root and adapters) re-exports a development binding and a no-op binding. The default index file picks between them based on `process.env.NODE_ENV`; the `/production` entrypoint always returns the real binding. Bundlers (Vite, Rollup, webpack, esbuild) tree-shake the unused branch. + +## Common Mistakes + +### Importing `@tanstack/table-devtools` directly into a non-Solid framework app + +You will pay for Solid's runtime in your bundle and you will not get a working integration — there is no React/Vue/Preact-aware hook on this package. Install `@tanstack/-table-devtools` instead and import `tableDevtoolsPlugin` + `useTanStackTableDevtools` from there. + +### Confusing this with the framework adapter packages + +The framework-specific packages (`react-table-devtools`, etc.) re-export adapter hooks and plugins; this package re-exports only the registration target store and a Solid `TableDevtoolsCore` class. The names overlap (`tableDevtoolsPlugin` exists only on adapters; `TableDevtoolsCore` exists only here), so reading source code requires noting which package you are in. + +### Expecting Angular/Lit/Svelte/vanilla support + +There is no `@tanstack/angular-table-devtools`, `-lit-`, `-svelte-`, or `-table-core-devtools` package today. Document this gap when answering user questions — do not invent an import path. A future release may add adapters; until then the only way to inspect a table in those frameworks is to log `table.getState()` and `table.getRowModel()` manually. diff --git a/packages/vue-table-devtools/README.md b/packages/vue-table-devtools/README.md index 4490fe2758..e82fa87596 100644 --- a/packages/vue-table-devtools/README.md +++ b/packages/vue-table-devtools/README.md @@ -49,6 +49,16 @@ A headless table library for building powerful datagrids with full control over ### Read the Docs → +## Using an AI Coding Agent? + +TanStack Table ships [TanStack Intent](https://github.com/TanStack/intent) skills inside each adapter package. After installing the library, run: + +```sh +npx @tanstack/intent@latest install +``` + +to add skill-loading guidance for your agent (Claude Code, Cursor, Copilot, etc.). The same CLI also exposes `intent list` to browse available skills and `intent load ` to print one for inspection. Skills version with the library — your agent gets guidance that matches the version of `@tanstack/-table` you installed. Only available for v9 and above. + ## Get Involved - We welcome issues and pull requests! diff --git a/packages/vue-table-devtools/package.json b/packages/vue-table-devtools/package.json index 78bc1e3705..9dd88299de 100644 --- a/packages/vue-table-devtools/package.json +++ b/packages/vue-table-devtools/package.json @@ -18,7 +18,8 @@ "vue", "tanstack", "table", - "devtools" + "devtools", + "tanstack-intent" ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", @@ -52,7 +53,8 @@ }, "files": [ "dist/", - "src" + "src", + "skills" ], "dependencies": { "@tanstack/devtools-utils": "^0.5.0", diff --git a/packages/vue-table-devtools/skills/vue/compose-with-tanstack-devtools/SKILL.md b/packages/vue-table-devtools/skills/vue/compose-with-tanstack-devtools/SKILL.md new file mode 100644 index 0000000000..0fa1b4a865 --- /dev/null +++ b/packages/vue-table-devtools/skills/vue/compose-with-tanstack-devtools/SKILL.md @@ -0,0 +1,180 @@ +--- +name: vue/compose-with-tanstack-devtools +description: > + Wire up TanStack Devtools for TanStack Table in Vue. Mount `TanStackDevtools` + with `tableDevtoolsPlugin()` from the app root and call + `useTanStackTableDevtools(table, name?)` inside ` + + +``` + +`tableDevtoolsPlugin()` returns a plugin descriptor for the multi-panel TanStack Devtools UI. `useTanStackTableDevtools` is a Vue composable that registers/unregisters the table via a `watchEffect`, so it reacts to `MaybeRef` inputs for the table, name, and `enabled` option. + +## Patterns + +### Naming Tables + +The optional second argument labels the table in the panel selector. Without it, devtools assign fallback names like `Table 1` and `Table 2`. + +```ts +useTanStackTableDevtools(table, 'Orders Table') +``` + +The name may be reactive — pass a `Ref` to update the label live. + +### Multiple Tables + +Register multiple tables and the Table panel renders a selector. Name each one. + +```vue + +``` + +### Disabling Per Table + +`useTanStackTableDevtools` accepts an `enabled` option (reactive). When `false`, the registration is removed but the composable runs cleanly. + +```ts +import { ref } from 'vue' +const showTableDevtools = ref(false) + +useTanStackTableDevtools(table, 'Users Table', { + enabled: showTableDevtools.value, +}) +``` + +### Production Builds + +The default `@tanstack/vue-table-devtools` entrypoint swaps to no-op implementations when `process.env.NODE_ENV !== 'development'`. To ship the real devtools to production, switch BOTH imports to the `/production` entrypoint: + +```ts +// main.ts +import { tableDevtoolsPlugin } from '@tanstack/vue-table-devtools/production' +``` + +```vue + +``` + +If you mix entrypoints (one from `/production`, one from the default), one side is a no-op in production and the panel will appear empty. + +### Conditional Devtools by Env + +A common pattern is to dynamically import the production entrypoint behind a feature flag: + +```ts +import { defineAsyncComponent } from 'vue' + +const TableDevtoolsRoot = defineAsyncComponent(async () => { + const { tableDevtoolsPlugin } = + await import('@tanstack/vue-table-devtools/production') + const { TanStackDevtools } = await import('@tanstack/vue-devtools') + return { + setup() { + return () => h(TanStackDevtools, { plugins: [tableDevtoolsPlugin()] }) + }, + } +}) +``` + +## Common Mistakes + +### Forgetting to mount `TanStackDevtools` at the app root + +Calling `useTanStackTableDevtools(table)` alone does nothing visible — it only registers the table with the devtools target store. Without a `` (or `h(TanStackDevtools, ...)`) somewhere in the tree, there is no panel to render the registration. Symptom: composable runs without errors, no devtools button appears. + +### Importing devtools from the default path in a prod-only bundle + +If you only deploy production builds, `@tanstack/vue-table-devtools` resolves to no-op implementations. The plugin will mount, but the panel will be empty. Use `@tanstack/vue-table-devtools/production` if you want the real devtools available there. + +### Accidentally shipping devtools to end users via `/production` + +The flip side: importing from `/production` in your default app bundle means every visitor downloads and runs the devtools UI. That is usually not what you want. Restrict `/production` imports to dev/preview entrypoints or code-split them behind a flag. + +### Calling `useTanStackTableDevtools` outside `setup` + +The composable uses `watchEffect` and `getCurrentInstance` — it must run inside a component's `setup` / ` +``` + +Source: `examples/vue/with-tanstack-query/src/App.tsx`, `examples/vue/basic-external-atoms/`. + +## Core Patterns + +### 1. The `manual*` flag + `_rowModels` drop pair + +Pick which slices live server-side and flip the matching `manual*` flag. **Also drop the +matching `_rowModels` factory** — otherwise the table re-processes server-processed rows. + +| Server owns | Set | Drop from `_rowModels` | +| ----------- | ------------------------ | ---------------------- | +| Pagination | `manualPagination: true` | `paginatedRowModel` | +| Sorting | `manualSorting: true` | `sortedRowModel` | +| Filtering | `manualFiltering: true` | `filteredRowModel` | +| Grouping | `manualGrouping: true` | `groupedRowModel` | +| Expanding | `manualExpanding: true` | `expandedRowModel` | + +Column visibility / ordering / pinning / row selection are client-side state and stay as-is. + +### 2. `rowCount` so `getPageCount()` works + +Without `rowCount`, `getPageCount()` falls back to `Math.ceil(data.length / pageSize)` — i.e. +`1` if the server already paginated. The pager locks at "Page 1 of 1". + +```ts +useTable({ + _features, + _rowModels: {}, + columns, + data: tableData, + rowCount: dataQuery.data.value?.rowCount, // or a stable ref/computed + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +If `rowCount` isn't immediately available, hold the last known value in a `ref` and update via +`watchEffect` so the pager doesn't reset to 0 during refetches. + +### 3. Two state-ownership shapes (pick one per slice) + +**External atoms (recommended with Query).** Table writes through to the atom. No +`on[State]Change` needed. + +```ts +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +useTable({ + _features, + _rowModels: {}, + columns, + data, + rowCount, + atoms: { pagination: paginationAtom }, + manualPagination: true, +}) +``` + +**Classic `state` + `on[State]Change` with getters.** Required when migrating from v8 or +integrating with existing Vue ref-based state. Each slice must be a getter so Vue tracks +`.value`. + +```ts +const pagination = ref({ pageIndex: 0, pageSize: 10 }) + +useTable({ + _features, + _rowModels: {}, + columns, + data, + rowCount, + state: { + get pagination() { + return pagination.value + }, + }, + onPaginationChange: (u) => { + pagination.value = typeof u === 'function' ? u(pagination.value) : u + }, + manualPagination: true, +}) +``` + +**Precedence:** `atoms[slice]` > `state[slice]` > internal `baseAtoms[slice]`. Don't pass the +same slice through both — the atoms wins silently. + +### 4. Cache keys must include controlled state + +```ts +const sortingAtom = createAtom([]) +const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 10, +}) +const sorting = useSelector(sortingAtom) +const pagination = useSelector(paginationAtom) + +const dataQuery = useQuery(() => ({ + queryKey: [ + 'people', + { sorting: sorting.value, pagination: pagination.value }, + ], + queryFn: () => + fetchPeople({ sorting: sorting.value, pagination: pagination.value }), + placeholderData: keepPreviousData, +})) +``` + +If pagination/sort/filter aren't in `queryKey`, Query won't refetch when the user clicks a +pager button — the buttons "do nothing" from the user's POV. + +### 5. Mixed client + server features still work + +Column visibility, ordering, pinning, and row selection are client state — they don't depend +on the row model and continue to function with `manualPagination`/`manualSorting`/`manualFiltering`. +You can have a server-paginated table where the user pins or hides columns locally. + +## Common Mistakes + +### Forgetting `manualPagination` / `manualSorting` / `manualFiltering` (CRITICAL) + +The table double-processes server-processed rows. If the server returned page 2 of 50, +the table will paginate that 10-row slice again and show "Page 1 of 1". + +```ts +// ❌ +useTable({ + _features, + _rowModels: {}, + columns, + data: serverPage.rows, + rowCount, + atoms: { pagination: paginationAtom }, + // missing: manualPagination: true +}) +``` + +### Leaving `paginatedRowModel` registered when server paginates (CRITICAL) + +```ts +// ❌ Factory ships for nothing AND the table re-paginates server-sliced data. +_rowModels: { + paginatedRowModel: createPaginatedRowModel() +} + +// ✅ +_rowModels: { +} +``` + +Same applies to `sortedRowModel`, `filteredRowModel`, `groupedRowModel`, `expandedRowModel` +when the server owns the slice. + +### Omitting `rowCount` (CRITICAL) + +`getPageCount()` returns `1` if the server already paginated. The pager UI locks at +"Page 1 of 1" and users can't navigate. + +### Passing `state.pagination` without `onPaginationChange` (CRITICAL) + +```ts +// ❌ table.setPageIndex(2) is a no-op — no writeback handler. +const pagination = ref({ pageIndex: 0, pageSize: 10 }) +useTable({ + _features, + _rowModels: {}, + columns, + data, + rowCount, + state: { + get pagination() { + return pagination.value + }, + }, + manualPagination: true, +}) + +// ✅ Either pair `state` with `on[State]Change`, OR use `atoms`. +``` + +### Mixing `state.pagination` AND `atoms.pagination` for the same slice (HIGH) + +```ts +useTable({ + // ... + state: { + get pagination() { + return localPagination.value + }, + }, // silently ignored + onPaginationChange: setLocalPagination, // silently ignored + atoms: { pagination: paginationAtom }, // wins +}) +``` + +Atoms beat `state`; the `state` plumbing is dead but lingering in the code. Pick one mechanism. + +### Forgetting to include controlled state in `queryKey` (CRITICAL) + +```ts +// ❌ Never refetches when pagination changes. +useQuery(() => ({ + queryKey: ['people'], + queryFn: () => fetchPeople(pagination.value), +})) + +// ✅ +useQuery(() => ({ + queryKey: ['people', pagination.value], + queryFn: () => fetchPeople(pagination.value), +})) +``` + +### Skipping `placeholderData: keepPreviousData` (HIGH) + +Between fetches the table collapses to 0 rows, the row container collapses, and scroll +position jumps. `keepPreviousData` keeps the previous page visible during the refetch. + +### Passing a raw `ref` to `state.pagination` without a getter (CRITICAL — Vue-specific) + +```ts +// ❌ Vue can't track .value changes on the captured ref object. +state: { pagination: pagination } + +// ✅ +state: { get pagination() { return pagination.value } } +``` + +### Hand-rolling sort/page state instead of using the API (CRITICAL — #1 AI tell) + +```ts +// ❌ Manual state machine. +const pageIndex = ref(0) +const next = () => { + pageIndex.value++ + refetch() +} + +// ✅ Built-ins. +table.nextPage() +table.setPageIndex(0) +table.setSorting([{ id: 'age', desc: true }]) +table.setColumnFilters(/* ... */) +``` + +### "API missing" because the feature isn't in `_features` (CRITICAL — v9-specific) + +Server-side pagination still needs `rowPaginationFeature` in `tableFeatures({...})` — that's +what surfaces `table.setPageIndex`, `table.nextPage`, `table.getPageCount`. The factory in +`_rowModels` is what you drop; the feature stays. + +```ts +const _features = tableFeatures({ rowPaginationFeature }) // ✅ even with manualPagination +``` + +## See Also + +- `tanstack-table/vue/compose-with-tanstack-query` — the Query-specific wiring +- `tanstack-table/vue/compose-with-tanstack-store` — external atoms in depth +- `tanstack-table/vue/table-state` — getter rule, atom precedence +- `tanstack-table/table-core/pagination` — `manualPagination` / `rowCount` semantics diff --git a/packages/vue-table/skills/vue/compose-with-tanstack-form/SKILL.md b/packages/vue-table/skills/vue/compose-with-tanstack-form/SKILL.md new file mode 100644 index 0000000000..e94610250b --- /dev/null +++ b/packages/vue-table/skills/vue/compose-with-tanstack-form/SKILL.md @@ -0,0 +1,369 @@ +--- +name: vue/compose-with-tanstack-form +description: > + Editable cells with `@tanstack/vue-form` + `@tanstack/vue-table` v9. The table is the layout + primitive; the form owns state. Wire `data: form.state.values.data` (where `data` is the + array field) so the table reads from the form. In each column's `cell` renderer use a + `` slot to bind an input. Typing gotcha: if + your row type has recursive `subRows`, type the form rows as `Omit` — + TanStack Form's `DeepKeys` walks the recursion and hits TS2589. Subscribe to + `form.state.values.data.length` (not the whole array) to drive row add/remove re-renders. + Pair with TanStack Pacer for debounced filter inputs on the same screen. +type: composition +library: tanstack-table +framework: vue +library_version: '9.0.0-alpha.47' +requires: + - row-selection + - column-definitions +sources: + - examples/react/with-tanstack-form/ + - packages/vue-table/src/useTable.ts +--- + +# Compose @tanstack/vue-table with @tanstack/vue-form + +## Dependencies + +```bash +pnpm add @tanstack/vue-table @tanstack/vue-form +``` + +`@tanstack/vue-form` exposes `useForm` and the `` / `form.Field` component pattern. It +does NOT currently ship a `createFormHook` factory — that's a React-only convenience. In Vue, +you write component-local `` bindings directly. The canonical example for the +_shape_ of this pattern lives in `examples/react/with-tanstack-form/`; the Vue translation +maps the React `` to Vue's `` slot. + +## Setup — editable rows + +```vue + + + +``` + +Source: `examples/react/with-tanstack-form/src/main.tsx` (React canonical); pattern translated +to Vue's `` slot API. + +## Core Patterns + +### 1. Form owns the data, table renders it + +```ts +const form = useForm({ defaultValues: { data: makeData(100) } }) + +const table = useTable({ + _features, + _rowModels: { paginatedRowModel: createPaginatedRowModel() }, + columns, + get data() { + return form.state.values.data + }, +}) +``` + +The table is a layout primitive — pagination, sorting, filtering on the form's data. The form +handles editing, validation, dirty tracking, submit. + +### 2. Cell bindings via `` + +In React-form, the convention is `` inside `cell:`. In Vue-form, +prefer rendering `` directly in the `