Capítulo 22 de 54

Chapter 22: Table State (React) Guide

Core Idea

"TanStack Table is a state-management coordinator for table state" — v9 backs that state with TanStack Store atoms, so reading current state (table.atoms.x.get()) and reading reactive state that re-renders your component (useTable's selector, or table.Subscribe) are two genuinely different operations, and picking the wrong one is the source of most state-related bugs and perf problems.

Key Concepts

  • You usually don't manage state at all. No initialState, atoms, state, or on[State]Change → the table manages everything internally. Only reach for the mechanisms below when you have a concrete reason (server-driven state, cross-component sharing, fine-grained re-render control).
  • Four state surfaces on table: table.baseAtoms (internal writable atoms), table.atoms (readonly derived atoms, one per registered slice), table.store (readonly flat snapshot derived from all table.atoms), table.state (React-only — whatever the selector passed as useTable's 2nd argument returns).
  • State is feature-gated. A slice (e.g. pagination) only exists on table.atoms/table.store/table.state/initialState/state/atoms if the matching feature (rowPaginationFeature) is registered — referencing an unregistered slice is a type error, not just a runtime undefined.
  • Reading without subscribing (current value, no re-render wiring): table.atoms.pagination.get() or table.store.state.pagination. Fine during render for a one-off read, but future changes to that value will not re-render the component on their own.
  • Reading reactively: the 2nd argument to useTable is a selector over the store. The default selector selects all registered state (component re-renders on any state change). Pass your own selector — e.g. (state) => ({ pagination: state.pagination }) — to narrow what triggers a re-render, or () => null to opt the component out of table-state re-renders entirely (then subscribe lower in the tree with table.Subscribe).
  • table.Subscribe places a re-render boundary exactly where it's needed — pass selector (over table.store) alone, or add a source prop to subscribe directly to one atom (e.g. table.atoms.rowSelection) and select one value off it, which is the standard pattern for "only this row's checkbox re-renders when selection changes."
  • Setting state: always through a feature's dedicated API (table.nextPage(), table.setPageSize(n), table.setSorting(...), column.toggleVisibility(), row.toggleSelected()), never by hand-editing the state object — the APIs preserve each feature's own invariants. Direct writes to table.baseAtoms.<slice>.set(...) are a rare low-level escape hatch, and if a slice is externally owned (via atoms), you must write to that external atom instead — table.atoms.<slice> mirrors the external atom, not the internal base atom.
  • initialState sets starting values only (and is what resetX() APIs reset back to) — mutating the initialState object later does not reset live state. Reset APIs take an optional true to reset to the feature's blank/default state instead of initialState. Prefer feature resetX() calls over the low-level table.reset(), which only resets internal base atoms and won't correctly reset externally-owned atoms.
  • Two ways to externally control a slice — pick exactly one per slice, don't mix:
    1. External atoms (recommended in v9): useCreateAtom(initialValue) from TanStack Store, passed via the table's atoms option; subscribe elsewhere with useSelector. Table APIs write directly to the atom — no matching on[State]Change needed. Ideal for state that also belongs in a query key (server-driven pagination/sorting/filters).
    2. External state + on[State]Change (classic v8-style, still supported): plain React state + a setter callback per slice, e.g. state: { sorting } paired with onSortingChange: setSorting. Less fine-grained — a state update re-renders the owning component, and that can't be pushed lower like an atom subscription can.
  • v8's global onStateChange is gone. Use per-slice on[State]Change callbacks, external atoms, or subscribe to table.store directly if you truly need "every state change."
  • Precedence when a slice is declared in multiple places: external atoms win over external state; external state syncs into the internal base atom. Don't provide the same slice through more than one channel unless you intend that precedence.
  • Prefer the feature-specific exported types (SortingState, PaginationState, RowSelectionState, ...) for local state/atoms over hand-rolling shapes; TableState<typeof features> gives the full inferred state shape for a given feature set.

Code Examples

// narrow re-renders to one slice
const table = useTable({ features, columns, data }, (state) => ({ pagination: state.pagination }))

// subscribe to one atom, one derived value — classic per-row checkbox pattern
<table.Subscribe source={table.atoms.rowSelection} selector={(sel) => sel[row.id]}>
  {(isSelected) => <input type="checkbox" checked={!!isSelected} onChange={row.getToggleSelectedHandler()} />}
</table.Subscribe>

// external atom, shared with a TanStack Query key
const paginationAtom = useCreateAtom<PaginationState>({ pageIndex: 0, pageSize: 10 })
const pagination = useSelector(paginationAtom)
const table = useTable({
  features, columns, data: dataQuery.data?.rows ?? [],
  atoms: { pagination: paginationAtom },   // table APIs write here directly, no onPaginationChange needed
  manualPagination: true,
})
  • What it demonstrates: three re-render strategies from broadest to most surgical — selector on useTable, table.Subscribe with a selector, table.Subscribe scoped to one atom — plus an external atom shared with a data-fetching layer.

Key Takeaways

  1. Default to doing nothing — no initialState/atoms/state — until you have a concrete reason to intervene.
  2. .get()/table.store.state.x = current value, no reactivity. A selector passed to useTable (or table.Subscribe) = reactive, re-render-triggering.
  3. Always mutate state through a feature's own API (setSorting, toggleSelected, nextPage, ...), never by hand-constructing the state object.
  4. For server-driven state (pagination/sorting/filters feeding a query key), prefer external atoms (atoms option) over the classic state/on[State]Change pair — it's the more fine-grained, v9-idiomatic path.
  5. Pick exactly one ownership channel per state slice; mixing initialState/atoms/state for the same slice invites silent precedence bugs.

Connects To

  • React Compiler Guide: why nested components can still hide reactive reads even under React Compiler, and when table.Subscribe is required regardless.
  • Client-Side vs Server-Side Guide: the atoms + query-key pattern in its full server-driven context.
  • Table Instance Guide: table.atoms/table.store/table.baseAtoms introduced at a lower level.