Skip to content

fix: preserve data table state across prop updates - #106

Merged
joshunrau merged 1 commit into
mainfrom
fix/data-table-state-persistence
Jul 22, 2026
Merged

fix: preserve data table state across prop updates#106
joshunrau merged 1 commit into
mainfrom
fix/data-table-state-persistence

Conversation

@joshunrau

Copy link
Copy Markdown
Collaborator

Paginating, sorting or searching a DataTable was undone as soon as the consumer re-rendered. Most visibly: page to 2 and click a row, and the table jumps back to page 1. Clicking a row typically sets state to highlight it, and that re-render was enough — nothing about the table's data changed.

Cause

Two independent causes, both required:

  1. store.tsreset()'s client branch set state: getTanstackTableState(updatedParams), which builds a TableState from scratch (pageIndex: 0, empty sizing, initialState filters and sorting). Every call replaced pagination, sorting, filters and column sizing with props-time defaults. DataTable keys its effect on [props], a fresh rest-spread object each render, so this ran on every render. The server branch never did this.

  2. DataTableControls.tsx — the effect was keyed on [onSearchChange, searchValue]. Callers write onSearchChange inline, so it is a new function every render and the effect replayed the handler with an unchanged search value. A handler that writes filter state (the common shape, setFilterValue(prev => ({ ...prev, searchString }))) then handed Tanstack a new columnFilters identity, tripping autoResetPageIndex.

Cause 2 is why the earlier fix (976be28, released as 6.9.2) looked ineffective and was reverted — it addressed only cause 1, so row clicks still reset the page in any table using onSearchChange.

Change

  • store.tsreset() no longer touches live table state. One setOptions call for both modes updates the genuine inputs (columns, data, meta, plus pageCount for server mode). initialState now applies once, at store creation.
  • utils.tsx — extracted getColumnPinningWithActions. Column pinning is the one slice of state derived from props, so reset reconciles just that via a setTableState updater; sharing the helper with getTanstackTableState keeps the create and update paths from drifting.
  • DataTableControls.tsx — the handler is held in a ref so the effect is keyed on searchValue alone. Mount-time invocation is unchanged.

Memoizing columns is not an alternative: cell closures capture consumer state, so columns is legitimately new each render and skipping the update would leave stale cells rendering. The update was always correct; only its side effect on state was wrong.

Behaviour changes

  • columnVisibility now persists. Previously any consumer re-render restored all columns to visible, so togglesComponent selections were silently reverted. They now stick.
  • initialState is now genuinely initial. Changes to it after mount are ignored. Previously prop-time columnFilters / sorting were reapplied on every render.
  • Dropping rowActions now unpins the actions column in server mode too; the old server branch never reconciled pinning.

Columns changing under us

Verified safe against @tanstack/table-core@8.21.3 — every column-keyed state slice tolerates ids that no longer exist:

  • getSortedRowModel.ts:22 — filters out sortings for non-existent columns
  • getFilteredRowModel.ts:32if (!column) return
  • ColumnPinning.ts:302-320.map(...).filter(Boolean) on left/right/center

columnSizing is fully recomputed by updateColumnSizing() on every reset, so no stale entries survive. Removing a sorted column, removing a filtered column, and dropping rowActions mid-life were each exercised and behave correctly. Stale columnFilters / sorting / columnVisibility entries do persist for removed columns and will reapply if that column id returns — generally the desired behaviour.

Verification

177/177 tests pass, tsc and eslint clean. Three regression tests added in __tests__/DataTable.spec.tsx — page survives a row click; survives it with an inline onSearchChange that writes filter state; search survives it — all three fail before the fix.

Known gaps (follow-ups, not regressions)

  • Transient empty table when data shrinks. On page 3 of 50 rows, swapping in 3 rows renders 0 rows for one commit before autoResetPageIndex's queued microtask (table.ts:334) lands; DataTableBody.tsx:20 shows the empty state at zero rows. Pre-fix it showed 3 rows immediately. A synchronous clamp to page 0 in reset() closes this with no semantic change, since resetPageIndex() lands on 0 anyway (RowPagination.ts:273).
  • The fix is conditional on a referentially stable data. With an unmemoized array (data={rows.map(toDisplay)}), a row click still bounces to page 1 — data identity change trips autoResetPageIndex. This is unchanged from before and not a regression, but it limits the fix's reach. Properly addressing it means autoResetPageIndex: false plus explicit page-reset policy in the store, which also changes what happens when data is swapped wholesale — a deliberate change deserving its own PR.
  • Not yet verified against a consuming app.

Paginating, sorting or searching a DataTable was undone whenever the
consumer re-rendered. Page to 2 and click a row and the table jumps back
to page 1 -- clicking a row typically sets state to highlight it, and
that re-render was enough, though nothing about the table's data changed.

Two independent causes, both required:

1. store.ts -- reset()'s client branch set `state:
   getTanstackTableState(updatedParams)`, rebuilding the whole TableState
   from props-time defaults (pageIndex 0, empty sizing, initialState
   filters and sorting). DataTable keys its effect on [props], a fresh
   rest-spread object each render, so this ran on every render.

2. DataTableControls.tsx -- the effect was keyed on [onSearchChange,
   searchValue]. Callers write onSearchChange inline, so it is a new
   function every render and the effect replayed the handler with an
   unchanged search value. A handler that writes filter state then handed
   Tanstack a new columnFilters identity, tripping autoResetPageIndex.

Cause 2 is why 976be28 (released as 6.9.2) looked ineffective and was
reverted: it addressed only cause 1.

reset() no longer touches live table state; initialState now applies once,
at store creation. Column pinning is the one slice derived from props, so
reset reconciles just that via the extracted getColumnPinningWithActions,
shared with getTanstackTableState so the create and update paths cannot
drift. The search handler is held in a ref so its effect keys on
searchValue alone.

Memoizing columns is not an alternative: cell closures capture consumer
state, so columns is legitimately new each render and skipping the update
would leave stale cells rendering. The update was always correct; only its
side effect on state was wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joshunrau
joshunrau merged commit 1ff0c99 into main Jul 22, 2026
1 check passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 6.9.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant