TanStack Table autoReset infinite render loop from an unstable data prop#
Problem#
A component that calls useReactTable({ data: someArray.filter(...) }) — passing a freshly built array every render — can drive React into an infinite render loop that pegs the renderer main thread. In this repo it surfaced in GroupedTableView: expanding a group hung the page. The loop is timing-sensitive — it can pass on a fast machine and hang on a slower/headless one (CI).
Symptoms#
- Expanding a grouped table row (any interaction that re-renders the component owning
useReactTable) freezes the page. - Playwright
keyboard.press("Enter")/.click()on the trigger times out — the input event can't be processed because the main thread is stuck; the whole 30s/90s test budget is consumed at that one line. - CI's
e2ejob fails while localjust ci-check(same production build) passes — the loop settles fast enough on a GPU/fast CPU but not on GitHub's headless, software-rendered runner. - A CPU profile of the hung thread shows nonstop
ReactElement,jsxDEV,createElement,beginWork— continuous element creation, i.e. a render loop. - Crucially, no
Maximum update depth exceededconsole error — React's guard only catches setState-during-render; this loop is driven by TanStack scheduling updates in response to a changeddataidentity, so the guard never fires and there's no error to grep for.
What Didn't Work#
- Blaming Radix Collapsible / ResizeObserver. The grouped view first used a per-group
<Collapsible>; removing its height animation and moving the member table'soverflow-x-autoto a single outer container did not fix CI. (Red herring — the block reproduced even after the Collapsible was gone.) - Raising the Playwright test timeout (30s → 90s). It just let the blocked interaction consume more time; the test still failed, now at ~90s.
- Switching the expand interaction from pointer click to
focus()+ keyboardEnter, and to{ force: true }. Both still timed out — the input method was never the issue; the main thread was blocked. - CPU-throttling locally to mimic CI (
Emulation.setCPUThrottlingRate). Inconclusive on its own — throttling slows the whole flow (login, nav), muddying the signal; the clean reproduction only came once the rewrite made the loop fire on every re-render locally.
Solution#
useMemo every array passed as data to useReactTable, keyed on the real inputs:
// BEFORE — new array identity every render → autoReset loop
const ownedData = data.filter((row) => row.ownerId === ownerId);
const borrowed = data.filter((row) => row.ownerId !== ownerId);
const table = useReactTable({ data: ownedData, /* ... */ });
const borrowedTable = useReactTable({ data: borrowed, /* ... */ });
// AFTER — stable identity while inputs are unchanged
const ownedData = useMemo(
() => data.filter((row) => row.ownerId === ownerId),
[data, ownerId],
);
const borrowed = useMemo(
() => data.filter((row) => row.ownerId !== ownerId),
[data, ownerId],
);
See components/ui/data-table/grouped-table-view.tsx. After the fix the same e2e that timed out at 90s passes in ~0.8s.
Why This Works#
useReactTable runs autoReset* logic (autoResetPageIndex, autoResetExpanded, …, on by default) that fires when it detects the data reference changed. Firing schedules an internal state update → the component re-renders → data.filter(...) produces a new array reference → TanStack again thinks data changed → autoReset fires again → loop. It stays dormant until the component re-renders for some other reason (here: local useState expand state); with a stable Radix-owned open-state the component didn't re-render, so the loop never armed locally — which is exactly why it looked like a Collapsible bug and why CI-only failures were so confusing. Memoizing data gives it a stable identity across re-renders, so autoReset sees "no change" and never schedules the update.
Prevention#
- Never pass an inline
.filter()/.map()/buildX()array asuseReactTable({ data }). AlwaysuseMemoit (or hoist it to a stable source). This is the single most important rule when adopting TanStack Table. - When a component holds a
useReactTableand its ownuseState, assume any state change will re-run the table options — so every option that's a fresh object/array (data,columns,stateslices) must be stable or memoized. - A hang with no
Maximum update deptherror is the tell for a scheduler-driven loop (external store / library autoReset), not a setState-in-render loop. Reach for a CPU profile: continuousReactElement/jsxDEVself-time = a render loop. - CI-only hangs that pass locally are often a fast-vs-slow-CPU or GPU-vs-software-rendering difference exposing a latent loop or O(n^2) — reproduce by making the component re-render on its own state, not by hunting the rendering environment.
- Related bulk-add O(n^2) cleanups in the same PR: build accumulator arrays with
.push(...)into a get-or-created bucket rather than[...existing, row]on every element (src/domain/tables/grouping.ts,grouped-table-view.tsx).
Related Issues#
hooks/use-table-view-state.tsand the sharedDataTablewrapper carry a separate but related stability lesson: under React Compiler (reactCompiler: true), passing the referentially-stable-but-internally-mutatingtableobject to a memoized child renders it stale — pass reactive value slices instead.- PR #48 (shared data-table + roll-up grouping) is where this was diagnosed and fixed.