react-production-readiness

star 28.1k

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.<slice>)` subscriptions; and push state-driven re-renders down the tree with `<table.Subscribe>` / `<Subscribe>` 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.

TanStack By TanStack schedule Updated 6/12/2026

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.<slice>) subscriptions; and push state-driven re-renders down the tree with <table.Subscribe> / <Subscribe> 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.48' 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 <Subscribe> 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 and columns references stable across renders. Internal memoization keys off identity, so a new object every render forces full recomputation. Row model factories live on features, so they are also stable at module scope.

// ✓ Module scope = stable identity
const features = tableFeatures({
  rowSortingFeature,
  rowPaginationFeature,
  sortedRowModel: createSortedRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortFns,
})
const columnHelper = createColumnHelper<typeof features, Person>()
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,
    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.

// ✓ 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 <table.Subscribe> walls inside.

// Narrow to specific slices at the table level.
const table = useTable({ features, 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 <table.Subscribe>

A noisy footer that re-renders on every keystroke in a filter doesn't need to re-render the whole <TableBody>. Wrap each consumer in <table.Subscribe> with its own selector.

function MyTable({ data, columns }) {
  const table = useTable(
    { features, columns, data },
    () => null, // top-level opt-out
  )
  return (
    <>
      <TableBody table={table} /> {/* heavy — keep stable */}
      {/* Footer re-renders only on pagination changes */}
      <table.Subscribe selector={(s) => s.pagination}>
        {(pagination) => <PageFooter pagination={pagination} table={table} />}
      </table.Subscribe>
    </>
  )
}

Source: examples/react/basic-subscribe/src/main.tsx.

4. Per-slice useSelector(table.atoms.<slice>) for narrowest scope

Even narrower than <table.Subscribe>: subscribe a leaf component to a single atom. Skips constructing a state snapshot entirely.

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 <span>{Object.keys(selection).length} selected</span>
}

Source: examples/react/basic-external-atoms/src/main.tsx.

5. React Compiler — read state via <Subscribe> 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 <Subscribe> (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:

import { useTable, stockFeatures, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures(stockFeatures) // ships every feature

Correct:

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 / columns references

Wrong:

function MyTable({ data }) {
  const features = tableFeatures({
    // new every render
    rowSortingFeature,
    sortedRowModel: createSortedRowModel(),
    sortFns,
  })
  const table = useTable({ features, columns, data })
}

Correct:

// Module scope — declared once.
const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns,
})

function MyTable({ data }) {
  const table = useTable({ features, 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:

<MyTable data={query.data?.rows ?? []} columns={columns} />

Correct:

const EMPTY: Person[] = []  // module scope

<MyTable data={query.data?.rows ?? EMPTY} columns={columns} />
// 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:

// Default selector — whole tree re-renders on every state change.
const table = useTable(opts)
return <DeeplyNestedTable table={table} />

Correct:

const table = useTable(opts, () => null)
return (
  <>
    <TableBody table={table} />
    <table.Subscribe selector={(s) => s.pagination}>
      {(p) => <PageFooter pagination={p} />}
    </table.Subscribe>
  </>
)

Once you've measured a problem, narrow the top selector and add <table.Subscribe> 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:

<table.Subscribe selector={(s) => s.rowSelection}>
  {(rs) => <span>{Object.keys(rs).length} selected</span>}
</table.Subscribe>

Correct:

import { useSelector } from '@tanstack/react-store'

function SelectedCount({ table }) {
  const selection = useSelector(table.atoms.rowSelection)
  return <span>{Object.keys(selection).length} selected</span>
}

<table.Subscribe> still selects from table.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:

function App() {
  const rowVirtualizer = useVirtualizer({
    /* … */
  }) // virtualizer too high
  const table = useTable(opts)
  return <TableBody table={table} virtualizer={rowVirtualizer} />
}

Correct:

function App() {
  const tableContainerRef = React.useRef<HTMLDivElement>(null)
  const table = useTable(opts)
  return (
    <div ref={tableContainerRef} style={{ overflow: 'auto', height: 800 }}>
      <TableBody table={table} tableContainerRef={tableContainerRef} />
    </div>
  )
}
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 <Subscribe> / narrow selectors on small tables

Wrong:

// 50-row table with Subscribe around every cell.
header: ({ table }) => (
  <Subscribe source={table.store} selector={(s) => s.sorting}>
    {() => <SortHeader />}
  </Subscribe>
)

Correct:

const table = useTable({ features, 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.
Install via CLI
npx skills add https://github.com/TanStack/table --skill react-production-readiness
Repository Details
star Stars 28,097
call_split Forks 3,526
navigation Branch main
article Path SKILL.md
More from Creator