A code-level audit of packages/table-core/src/**. Each entry describes a concrete, low-risk refactor that preserves the public API surface and the shape of table.getState(). New get* APIs are allowed, but bundle-size discipline is in scope.
-
Categories:
big-oΒ·memoizationΒ·microΒ·bundle-sizeΒ·bug -
Score (1β10): importance after considering hot-path frequency, magnitude of win, and risk.
10= library-wide hot path, big-O improvement7β9= clear win in a frequently-called path (memoization gap, O(nΒ²)βO(n))4β6= noticeable but bounded (micro in a frequent path, or memo in cold path)1β3= micro-opt with negligible runtime, mostly cleanup
-
Scale impact tables: counts of operations / allocations / comparisons saved, not wall time. Numbers are illustrative β they assume realistic ratios (e.g. pinned β 10% of leaf cols, average
.findwalks Β½ the array) and are meant to communicate order of magnitude across table sizes. Bugs and fixed-cost refactors (one-time initialization, correctness fixes) don't get tables. -
Status convention: every finding has a
**Status:**line and an**Implementation note:**line near the top.[ ]not started β untouched[~]partial β applied, but with deviation or scope reduction (note required)[x]done β refactored as suggested[-]skipped β deliberately not pursued (note required)
Fill in the implementation note when status changes from
[ ], with: PR/commit ref if relevant, any deviation from the proposed code, before/after benchmark numbers if measured, and follow-ups.
Status: [x] done
Eliminated back-to-back Array method chains across packages/table-core/src/** by fusing multiple passes into a single loop. Each chain was producing one intermediate array per stage; fused versions allocate exactly the final result array.
Patterns covered:
-
.map(hg => hg.headers).flat()β 5 sites, all flattening header-groups into a flat header list. Replaced each with a nested indexedforloop pushing into a single result array.core/headers/coreHeadersFeature.utils.tsβtable_getFlatHeaderscore/headers/coreHeadersFeature.utils.tsβtable_getLeafHeaders(variant: maps toheader.getLeafHeaders()arrays, same fusion shape)features/column-pinning/columnPinningFeature.utils.tsβtable_getLeftFlatHeaders,table_getRightFlatHeaders,table_getCenterFlatHeaders
-
.map().map().filter()triple chain βcreateFacetedMinMaxValues.ts. Fused into the min/max scan loop (which previously ran after the three array stages). Single pass overflatRowswithNumber()coercion + NaN skip + inline min/max tracking. Replaces 3 intermediate array allocations of size N and the subsequent min/max walk over the resulting array. -
.map(...).filter(predicate).forEach(mutate)three-pass chain βrowPinningFeature.utils.ts(getPinnedRows). Resolves pinned-row ids β row instances β drops misses β tagsposition, all in one loop. Eliminates 2 intermediate arrays. -
.map().filter()chain producing-then-cleaning undefineds βrowSelectionFeature.utils.ts(selectRowsFnrecurseRows). The.mapreturnsundefinedfor unselected rows; the.filter(x => !!x)then removes them. Replaced with single push-into-result loop that skips unselected rows. Saves one intermediate array per recursion level. -
Smaller
.map().filter()chains:createFacetedRowModel.tsβcolumnFilters?.map(d => d.id).filter(d => d !== columnId)+ outer.filter(Boolean)β single loop pushing matching ids.columnPinningFeature.utils.tscolumn_pinβcolumn.getLeafColumns().map(d => d.id).filter(Boolean)β single loop.
Why it matters at scale. In modern V8, .map/.filter per-iteration overhead is competitive with hand-written loops (~5β15% per element). The win is eliminating the intermediate arrays themselves. Each chain stage allocates an array of size N where N is rows/cells/headers. For a 1M-row faceting pass the prior triple-chain in createFacetedMinMaxValues allocated ~3 Γ 8MB of intermediate buffers per faceted column rebuild; the fused version allocates none of those. Across all 5 patterns and all derivation passes (filter, sort, group, facet, pin), this saves tens of MB of allocations and meaningful GC time on cold builds at 1M-row scale.
Subsumes existing findings:
- #21 (
createFacetedMinMaxValueschain) β done as part of pattern 2 above.
Type-check verified clean after the fusion sweep.
Status: [x] done
A codebase-wide conversion of for (const x of arr) to for (let i = 0; i < arr.length; i++) { const x = arr[i]! } for all Array iterations in packages/table-core/src/**. Roughly 50 loops touched across ~20 files. Rationale: at TanStack Table's scale targets (millions of rows, thousands of columns) the cumulative micro-cost of iterator-protocol overhead is meaningful β especially on cold-JIT first renders, row-model derivation passes that walk full datasets, and .find / pinning loops that run per visible row.
Companion change: flipped the @typescript-eslint/prefer-for-of rule from 'warn' to 'off' at the repo root (eslint.config.js) with a comment explaining the rationale. New code should default to indexed for for Array iteration. for...of is still appropriate for Map, Set, and generators where indexed access isn't available.
This sweep subsumes the loop-style portions of several individual findings:
- #11 (
table_getAllFlatColumnsById/getAllLeafColumnsByIdfor...of) - #17 (
row_getAllCells.map+row_getAllCellsByColumnIdfor...of) β also converted.mapto a preallocatednew Array(length)+ indexed assignment forrow_getAllCells. - #23 (faceted min/max β opportunistically swapped
if/ifforif/else iffor the redundant max check)
Typecheck verified clean after the sweep (pnpm tsc --noEmit passes).
Bug fix included: isNumberArray had been previously auto-converted by the lint rule into for (const i of d) { d[i] } β which treats the iteration value as an index and returns false for any non-empty number array. The sweep restores the correct indexed form and the function works again as intended.
Files changed:
utils.ts(2 loops)core/cells/constructCell.ts(1)core/columns/constructColumn.ts(2)core/columns/coreColumnsFeature.utils.ts(2)core/headers/buildHeaderGroups.ts(3)core/headers/constructHeader.ts(1)core/headers/coreHeadersFeature.utils.ts(2)core/rows/constructRow.ts(2)core/rows/coreRowsFeature.utils.ts(1, plus.mapβ preallocated array)core/table/constructTable.ts(3)core/table/coreTablesFeature.utils.ts(1)features/column-faceting/createFacetedMinMaxValues.ts(1)features/column-faceting/createFacetedRowModel.ts(1)features/column-faceting/createFacetedUniqueValues.ts(2)features/column-filtering/createFilteredRowModel.ts(5)features/column-ordering/columnOrderingFeature.utils.ts(5)features/column-pinning/columnPinningFeature.utils.ts(6)features/column-visibility/columnVisibilityFeature.utils.ts(6)features/row-sorting/createSortedRowModel.ts(1)features/row-sorting/rowSortingFeature.utils.ts(1)
- Total findings: 60
- Done
[x]: 15 - Partial
[~]: 2 - Skipped
[-]: 1 - Not started
[ ]: 42
(Update these counters as you go.)
These are touched by every feature β wins compound.
Status: [x] done
Implementation note: Replaced the .some() callback with an indexed for loop + break in src/utils.ts (memoizedFn body). Drops one closure allocation per memo invocation. Implemented exactly as proposed.
Location: src/utils.ts:136β156
Category: micro
memo() is the foundation of every memoized accessor on the table, column, row, cell, and header (called many thousands of times per render in a large table). The .some(callback) allocates a closure each call and prevents engine inlining of the cheap reference-equality check.
Before
const newDeps = memoDeps?.(depArgs)
const depsChanged =
!newDeps ||
newDeps.length !== deps?.length ||
newDeps.some((dep: any, index: number) => deps?.[index] !== dep)After
const newDeps = memoDeps?.(depArgs)
let depsChanged = !newDeps || newDeps.length !== deps?.length
if (!depsChanged && newDeps) {
for (let i = 0; i < newDeps.length; i++) {
if (newDeps[i] !== deps![i]) {
depsChanged = true
break
}
}
}Big-O: No asymptotic change. Constant-factor β one of the most-executed code paths in the library, so worth the few extra lines.
Scale impact (closure allocations saved per render β dimension: number of memoized-accessor calls per render across the whole table):
| Calls / render | Closures before | After | Saved / render |
|---|---|---|---|
| 1,000 | 1,000 | 0 | 1,000 |
| 10,000 | 10,000 | 0 | 10,000 |
| 100,000 | 100,000 | 0 | 100,000 |
| 1,000,000 | 1,000,000 | 0 | 1,000,000 |
Risk: None. Identical semantics.
Status: [-] skipped
Implementation note: Re-examination of utils.ts:407β421 showed the original audit misread the code. The two arrow-function wrappers (memoDeps: and fn:) live inside the if (!this[memoKey]) block, so they're allocated once per instance per method, not per call. Subsequent calls just delegate via return this[memoKey](...args). Removing the const self = this alias in favor of capturing this lexically saves nothing measurable (it's a stack alias, not a heap allocation) and may even cost slightly more due to lexical-this lookup. No win to capture here.
Location: src/utils.ts:402β416
Category: micro, memoization
Each call to a memoized prototype method (column.getIsVisible(), row.getVisibleCells(), header.getSize(), β¦) re-creates two arrow functions (memoDeps/fn wrappers) every call after the lazy init. Pull them out so they're allocated once per prototype, not once per call.
Before
prototype[fnKey] = function (this: any, ...args: Array<any>) {
if (!this[memoKey]) {
const self = this
this[memoKey] = tableMemo({
memoDeps: (depArgs) => memoDeps(self, depArgs),
fn: (...deps) => fn(self, ...deps),
...
})
}
return this[memoKey](...args)
}After
prototype[fnKey] = function (this: any, ...args: Array<any>) {
if (!this[memoKey]) {
this[memoKey] = tableMemo({
memoDeps: (depArgs) => memoDeps(this, depArgs),
fn: (...deps) => fn(this, ...deps),
...
})
}
return this[memoKey](...args)
}(The two closures still capture memoDeps/fn; the win is dropping the self alias and ensuring the closures live inside the one-time init path.)
Big-O: Saves 1 allocation per memoized call after init.
Scale impact (self alias allocations saved β dimension: memoized accessor invocations after init):
| Invocations | Before | After | Saved |
|---|---|---|---|
| 1,000 | 1,000 | 0 | 1,000 |
| 10,000 | 10,000 | 0 | 10,000 |
| 100,000 | 100,000 | 0 | 100,000 |
| 1,000,000 | 1,000,000 | 0 | 1,000,000 |
Risk: Low. this inside a regular function is identical to self.
Status: [ ] not started
Implementation note: (none)
Location: src/utils.ts:200β207
Category: micro, bundle-size
beforeCompareTime, afterCompareTime, startCalcTime, endCalcTime are allocated even in prod. Move them inside the if (process.env.NODE_ENV === 'development') branch. Bundlers eliminate the dev branch entirely in prod.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/utils.ts:88β107
Category: micro
Used in row_getLeafRows and every column tree flatten. Replace .forEach(callback) with an indexed loop to avoid the per-item callback allocation and to allow JIT inlining.
Before
const recurse = (subArr: Array<TNode>) => {
subArr.forEach((item) => {
flat.push(item)
const children = getChildren(item)
if (children.length) recurse(children)
})
}After
const recurse = (subArr: Array<TNode>) => {
for (let i = 0; i < subArr.length; i++) {
const item = subArr[i]
flat.push(item)
const children = getChildren(item)
if (children.length) recurse(children)
}
}Big-O: Same. Constant-factor (and protects against deep-recursion stack growth marginally).
Scale impact (callback allocations saved per flattenBy call β dimension: nodes flattened):
| Nodes flattened | Before (callbacks) | After | Saved |
|---|---|---|---|
| 10 | 10 | 0 | 10 |
| 100 | 100 | 0 | 100 |
| 1,000 | 1,000 | 0 | 1,000 |
| 10,000 | 10,000 | 0 | 10,000 |
Risk: None.
Status: [x] done
Implementation note: Replaced .every() callback with an indexed for loop + early return false. Semantics preserved (empty array still returns true, matching the original .every() behavior). Drops one closure allocation per call.
Location: src/utils.ts:79β81
Category: micro
Replace with an indexed loop and early exit. Low frequency; only used during sort-fn auto-detection.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/helpers/columnHelper.ts:94β117
Category: micro, bundle-size
The helper is stateless. Hoist a module-level singleton and return it.
Before
export function createColumnHelper<...>(): ColumnHelper<TFeatures, TData> {
return {
accessor: (accessor, column) => { ... },
columns: (columns) => { ... },
display: (column) => column,
group: (column) => column,
}
}After
const COLUMN_HELPER = {
accessor: (accessor: any, column: any) => ({ ...column, accessorKey: accessor, ... }),
columns: (columns: any) => columns,
display: (column: any) => column,
group: (column: any) => column,
}
export function createColumnHelper<...>(): ColumnHelper<TFeatures, TData> {
return COLUMN_HELPER as any
}Risk: None. Methods are pure.
Status: [ ] not started
Implementation note: (none)
Location: src/store-reactivity-bindings.ts:19β36
Category: micro
Same pattern as #6. Hoist a singleton.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/flex-render.ts:46β70
Category: micro, bundle-size
TypeScript narrows the discriminated union via the truthy check alone.
Before
if ('cell' in props && props.cell) { ... }
if ('header' in props && props.header) { ... }
if ('footer' in props && props.footer) { ... }After
if (props.cell) { ... }
if (props.header) { ... }
if (props.footer) { ... }Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/core/cells/coreCellsFeature.utils.ts:51β65
Category: micro, memoization
Every render that reads cell.getContext() (which every framework adapter does for every visible cell) builds a fresh 6-property object. Cells are long-lived; the context is functionally immutable. Cache it on the cell instance.
Before
export function cell_getContext(cell) {
return {
table: cell.table,
column: cell.column,
row: cell.row,
cell,
getValue: () => cell.getValue(),
renderValue: () => cell.renderValue(),
}
}After
export function cell_getContext(cell) {
if (!cell._contextCache) {
cell._contextCache = {
table: cell.table,
column: cell.column,
row: cell.row,
cell,
getValue: () => cell.getValue(),
renderValue: () => cell.renderValue(),
}
}
return cell._contextCache
}Big-O: Eliminates one object + two arrow-function allocations per visible cell per access. For a 1000-row Γ 20-col table that's 20k saved allocations per render.
Scale impact (allocations saved per render β 1 object + 2 closures per visible cell read):
| Rows Γ cols (visible cells) | Allocations before / render | After (post-warmup) | Saved / render |
|---|---|---|---|
| 10 Γ 10 = 100 | 300 | 0 | 300 |
| 100 Γ 20 = 2,000 | 6,000 | 0 | 6,000 |
| 1,000 Γ 50 = 50,000 | 150,000 | 0 | 150,000 |
| 10,000 Γ 100 = 1,000,000 | 3,000,000 | 0 | 3,000,000 |
Risk: Add _contextCache? to the internal Cell type. Safe because cell properties are not mutated post-construction.
Status: [ ] not started
Implementation note: (none)
Location: src/core/columns/constructColumn.ts:54β59
Category: micro
split('.').join('_') outperforms replaceAll for single-char replacement in many engines. One-time cost per column.
Risk: None.
Status: [x] done
Implementation note: Converted as part of the codebase-wide for...of β indexed for sweep. See the "Cross-cutting sweep" section near the top of this doc.
Location: src/core/columns/coreColumnsFeature.utils.ts:175β186, 224β235
Category: micro
Swap for...of for indexed loops to drop iterator protocol overhead. Cheap, but called every time the column structure is rebuilt.
Scale impact (iterator protocol overhead saved per column-structure rebuild β dimension: columns):
| Columns | Iterator calls before | After (indexed) | Saved iterator calls |
|---|---|---|---|
| 10 | 10 | 0 | 10 |
| 100 | 100 | 0 | 100 |
| 1,000 | 1,000 | 0 | 1,000 |
| 10,000 | 10,000 | 0 | 10,000 |
Risk: None.
Status: [x] done
Implementation note: Original audit proposed converting left/right arrays to Sets. On reflection that's the wrong fix: pinning in real tables is usually 1β2 cols per side, where .includes on a small array beats a Set (no hashing, no extra object allocation, JIT-friendly). The actual win is in the common case where nothing is pinned at all β today the function does all the per-side partition work, even with empty pin lists. Refactor: hoist the pin-emptiness check to the top of table_getHeaderGroups and bail to buildHeaderGroups(allColumns, leafColumns, table) directly. Skips the getAllLeafColumnsById() call, two empty-array allocations, two for-loops over empty arrays, the .filter pass, and the final 3-way spread.
Location: src/core/headers/coreHeadersFeature.utils.ts:81β134
Category: micro
Before
const { left, right } =
table.atoms.columnPinning?.get() ?? getDefaultColumnPinningState()
const allColumns = table.getAllColumns()
const leafColumns = callMemoOrStaticFn(
table,
'getVisibleLeafColumns',
table_getVisibleLeafColumns,
)
const leafColumnsById = table.getAllLeafColumnsById()
const leftColumns: typeof leafColumns = []
for (const columnId of left) {
/* push if visible */
}
const rightColumns: typeof leafColumns = []
for (const columnId of right) {
/* push if visible */
}
const centerColumns = leafColumns.filter(
(column) => !left.includes(column.id) && !right.includes(column.id),
)
return buildHeaderGroups(
allColumns,
[...leftColumns, ...centerColumns, ...rightColumns],
table,
)After
const { left, right } =
table.atoms.columnPinning?.get() ?? getDefaultColumnPinningState()
const allColumns = table.getAllColumns()
const leafColumns = callMemoOrStaticFn(
table,
'getVisibleLeafColumns',
table_getVisibleLeafColumns,
)
// Fast path: no columns are pinned β skip per-side lookups, partition, and spread.
if (!left.length && !right.length) {
return buildHeaderGroups(allColumns, leafColumns, table)
}
const leafColumnsById = table.getAllLeafColumnsById()
// ... (rest unchanged: left/right loops, center filter, spread, buildHeaderGroups)Big-O: Same asymptotic complexity; constant-factor win in the no-pin case (which is most tables). When pinning is active, one extra boolean check at the top β negligible.
Scale impact (work saved per getHeaderGroups() call when no columns are pinned):
| Leaf cols (L) | Before: filter callbacks + spread allocs + 2 empty arrays + getAllLeafColumnsById() |
After | Saved |
|---|---|---|---|
| 10 | 10 callbacks + 2 arrays(size 10) + 2 empty arrays + 1 method call | 0 (early return) | full work skipped |
| 100 | 100 + 2 arrays(100) + 2 empty + 1 call | 0 | full work skipped |
| 1,000 | 1,000 + 2 arrays(1,000) + 2 empty + 1 call | 0 | full work skipped |
| 10,000 | 10,000 + 2 arrays(10,000) + 2 empty + 1 call | 0 | full work skipped |
Risk: None. Behavior unchanged. The leafColumns reference is reused (not mutated) when pinning is off β buildHeaderGroups reads but does not write to its input array.
Status: [x] done
Implementation note: Replaced columns.filter(...).forEach(...) with an indexed for loop + continue on invisible columns. Drops one filtered-array allocation per recursion level, two callback closures per call (filter + forEach), and removes the spurious , 0) second argument to forEach that was being ignored. Implemented as proposed.
Location: src/core/headers/buildHeaderGroups.ts:41β48
Category: micro
.filter(...).forEach(...) creates throwaway arrays at every depth. Inline the visibility check inside a single indexed loop.
Before
columns
.filter((column) =>
callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible),
)
.forEach((column) => {
if (column.columns.length) findMaxDepth(column.columns, depth + 1)
}, 0)After
for (let i = 0; i < columns.length; i++) {
const column = columns[i]
if (!callMemoOrStaticFn(column, 'getIsVisible', column_getIsVisible)) continue
if (column.columns.length) findMaxDepth(column.columns, depth + 1)
}Big-O: Same. Saves O(n) allocations per recursion level. Also removes the erroneous , 0 second-arg to forEach.
Scale impact (intermediate filtered arrays saved β dimension: total header columns walked, one filtered array per recursion level):
| Header tree size | Filtered arrays before | After | Saved |
|---|---|---|---|
| 10 (1 level) | 1 | 0 | 1 |
| 100 (3 levels) | 3 | 0 | 3 |
| 1,000 (4 levels) | 4 | 0 | 4 |
| 10,000 (5+ levels) | 5+ | 0 | 5+ |
(The win here is constant in tree height, not size β the per-recursion filtered array is the entry that gets eliminated.)
Risk: None.
Status: [x] done
Implementation note: Collapsed .filter().map() chain into a single forβ¦of loop with continue on invisible headers (per project eslint preference for forβ¦of). Inlined the inner .forEach() over recursive children as a forβ¦of loop. Eliminated Math.min(...childRowSpans) spread (which would have hit engine arg-count limits on extremely wide header rows) by tracking minChildRowSpan inline during the same loop that sums colSpan. Edge-case behavior preserved: when a header has subHeaders.length > 0 but none pass visibility (theoretically unreachable given column_getIsVisible semantics, but possible by construction), the original code's Math.min(...[]) returned Infinity β the refactor initializes minChildRowSpan = Infinity so the empty-children branch naturally produces the same value. Per recursion level: removes 1 filtered array allocation, 1 mapped array allocation, 1 child-rowSpan array allocation, and the spread of that array.
Location: src/core/headers/buildHeaderGroups.ts:143β176
Category: micro, big-o (stack-overflow risk)
Math.min(...childRowSpans) spreads into argument list. With very wide header rows this can blow the argument-count stack limit. Also: this function uses .filter().map() which allocates two intermediate arrays per recursion level.
Before
const filteredHeaders = headers.filter((header) =>
callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible),
)
return filteredHeaders.map((header) => {
...
recurseHeadersForSpans(header.subHeaders).forEach(({ colSpan, rowSpan }) => { ... })
const minChildRowSpan = Math.min(...childRowSpans)
...
})After
const results: Array<{ colSpan: number; rowSpan: number }> = []
for (let i = 0; i < headers.length; i++) {
const header = headers[i]
if (!callMemoOrStaticFn(header.column, 'getIsVisible', column_getIsVisible)) continue
...
const childSpans = recurseHeadersForSpans(header.subHeaders)
for (let j = 0; j < childSpans.length; j++) {
colSpan += childSpans[j].colSpan
childRowSpans.push(childSpans[j].rowSpan)
}
let minChildRowSpan = childRowSpans[0]
for (let j = 1; j < childRowSpans.length; j++) {
if (childRowSpans[j] < minChildRowSpan) minChildRowSpan = childRowSpans[j]
}
...
results.push({ colSpan, rowSpan })
}
return resultsBig-O: Removes O(n) intermediate filtered array per recursion + eliminates spread-arg stack risk.
Scale impact (intermediate arrays + spread risk β dimension: leaf headers in widest row):
| Headers in widest row | Before (filter+map arrays + spread args) | After | Saved / risk |
|---|---|---|---|
| 10 | 2 arrays + 10-arg spread | 0 extra arrays | safe range |
| 100 | 2 arrays + 100-arg spread | 0 | safe range |
| 1,000 | 2 arrays + 1,000-arg spread | 0 | approaches engine arg-limit (~10kβ65k) |
| 10,000 | 2 arrays + 10,000-arg spread | 0 | may exceed Math.min arg-limit β crash |
Risk: None. Same output.
Status: [ ] not started
Implementation note: (none)
Location: src/core/headers/coreHeadersFeature.utils.ts:59β69
Category: micro, memoization
Mirror of finding #9 for headers.
Scale impact (object allocations saved per render β dimension: visible headers Γ renders that read header.getContext()):
| Headers Γ renders | Before (objs) | After (post-warmup) | Saved |
|---|---|---|---|
| 10 Γ 100 | 1,000 | 10 | 990 |
| 50 Γ 1,000 | 50,000 | 50 | 49,950 |
| 100 Γ 10,000 | 1,000,000 | 100 | 999,900 |
Risk: Add _contextCache? to internal Header type.
Status: [x] done
Implementation note: Initially planned to replace the three cascading callMemoOrStaticFn(...getLeft/Center/RightHeaderGroups) calls in memoDeps with the six root atoms. On closer inspection the entire pinning branch in the function body was also redundant: table.getHeaderGroups() already builds the top row in left β center β right order via buildHeaderGroups(allColumns, [...leftColumns, ...centerColumns, ...rightColumns], table), so the three side-specific getters give the same set of top-row headers as getHeaderGroups()[0].headers. Final form collapses to the same pattern as table_getFooterGroups / table_getFlatHeaders:
export function table_getLeafHeaders(table) {
return (table.getHeaderGroups()[0]?.headers ?? [])
.map((header) => header.getLeafHeaders())
.flat()
}
// in feature:
table_getLeafHeaders: {
fn: () => table_getLeafHeaders(table),
memoDeps: () => [table.getHeaderGroups()],
},Eliminates three memoized cascades per call (down to one reference check against the cached header groups), removes the per-call columnPinning atom read, and removes the unused imports (callMemoOrStaticFn, table_getLeftHeaderGroups, table_getCenterHeaderGroups, table_getRightHeaderGroups, HeaderGroup type) from this file.
Location: src/core/headers/coreHeadersFeature.ts:75β94
Category: memoization
The memoDeps array invokes getLeftHeaderGroups() / getCenterHeaderGroups() / getRightHeaderGroups() just to compute the dependency tuple. Those getters are themselves memoized but still force an equality walk every time. Depend on the underlying root atoms instead.
Before
memoDeps: () => [
callMemoOrStaticFn(table, 'getLeftHeaderGroups', table_getLeftHeaderGroups),
callMemoOrStaticFn(table, 'getCenterHeaderGroups', table_getCenterHeaderGroups),
callMemoOrStaticFn(table, 'getRightHeaderGroups', table_getRightHeaderGroups),
],After
memoDeps: () => [
table.atoms.columnOrder?.get(),
table.atoms.grouping?.get(),
table.atoms.columnPinning?.get(),
table.atoms.columnVisibility?.get(),
table.options.columns,
table.options.groupedColumnMode,
],Big-O: Avoids 3 memo cascades per getLeafHeaders() access.
Scale impact (memo-cascade triggers saved per call β dimension: getLeafHeaders() invocations per session):
| Calls / session | Cascade triggers before (3/call) | After (cheap atom reads) | Saved cascades |
|---|---|---|---|
| 10 | 30 | 0 | 30 |
| 100 | 300 | 0 | 300 |
| 1,000 | 3,000 | 0 | 3,000 |
| 10,000 | 30,000 | 0 | 30,000 |
Risk: Low. Leaf headers are derived from exactly these inputs.
Status: [x] done
Implementation note: Converted as part of the codebase-wide for...of β indexed for sweep. row_getAllCells .map was additionally replaced with a preallocated new Array(columns.length) + indexed assignment (avoids .push reallocation overhead). See the "Cross-cutting sweep" section near the top of this doc.
Location: src/core/rows/coreRowsFeature.utils.ts:163β191
Category: micro
Swap .map() and for...of for indexed loops. Called for every row in the row model whenever cells are read.
Scale impact (iterator/callback overhead saved β dimension: cells iterated when row cell collections are built):
| Rows Γ cols (cells) | Before (callback/iterator overhead per cell) | After (indexed access) | Saved overhead per pass |
|---|---|---|---|
| 10 Γ 10 = 100 | 100 callback invokes | 0 | 100 |
| 100 Γ 20 = 2,000 | 2,000 | 0 | 2,000 |
| 1,000 Γ 50 = 50,000 | 50,000 | 0 | 50,000 |
| 10,000 Γ 100 = 1,000,000 | 1,000,000 | 0 | 1,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/core/rows/coreRowsFeature.utils.ts:228β251
Category: micro
When the row exists in the primary row model (common case), skip the fallback fetch.
Before
let row = (searchAll ? table.getPrePaginatedRowModel() : table.getRowModel()).rowsById[rowId]
if (!row) {
row = table.getCoreRowModel().rowsById[rowId]
...
}
return rowAfter
const primary = (searchAll ? table.getPrePaginatedRowModel() : table.getRowModel()).rowsById[rowId]
if (primary) return primary
const core = table.getCoreRowModel().rowsById[rowId]
if (core) return core
...Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/core/table/constructTable.ts:46β50
Category: micro
Guard against the undefined return from feature.getDefaultTableOptions?.().
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/.../createCoreRowModel.ts:25
Category: memoization
Today's dep is table.options.data. If a consumer recreates the options object (options = { ...options, data: sameRef }) the memo still works (same data reference). But if a consumer also recreates data per render without intent, the entire row model rebuilds. Consider exposing this as an atom (table.atoms.data) so adapters can route data identity through the reactivity layer instead of options identity.
Risk: Medium β surface change. Not strictly required, but a foundational correctness sharpening.
Status: [x] done
Implementation note: Fused as part of the loop-fusion sweep (see "Cross-cutting sweep: loop fusion" section near the top). Went further than the original proposal: instead of just collapsing the three .map().map().filter() passes into a single numericValues loop, the subsequent min/max scan was fused into that same pass too. Net result: one pass over flatRows, zero intermediate arrays, inline min/max tracking with Number.POSITIVE_INFINITY / Number.NEGATIVE_INFINITY seeds and a foundAny flag to return undefined when no numeric values exist.
Location: src/features/column-faceting/createFacetedMinMaxValues.ts:50β56
Category: micro
Three intermediate arrays per faceted column per change. Collapse to a single indexed loop.
Before
const numericValues = flatRows
.map((flatRow) => flatRow.getValue(columnId))
.map(Number)
.filter((value) => !Number.isNaN(value))After
const numericValues: number[] = []
for (let i = 0; i < flatRows.length; i++) {
const v = Number(flatRows[i].getValue(columnId))
if (!Number.isNaN(v)) numericValues.push(v)
}Big-O: O(3n) β O(n) work, 3 array allocations β 1.
Scale impact (per faceted column rebuild β dimension: flat rows scanned):
| Flat rows | Before (3 intermediate arrays of β€n) | After (1 array of β€n) | Saved arrays |
|---|---|---|---|
| 10 | 3 of 10 | 1 of β€10 | 2 of ~10 |
| 100 | 3 of 100 | 1 of β€100 | 2 of ~100 |
| 1,000 | 3 of 1,000 | 1 of β€1,000 | 2 of ~1,000 |
| 10,000 | 3 of 10,000 | 1 of β€10,000 | 2 of ~10,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-faceting/createFacetedUniqueValues.ts:46β62
Category: micro
set(k, (get(k) ?? 0) + 1) works in either branch.
Scale impact (Map ops saved per facet rebuild β dimension: distinct value encounters):
| Value occurrences | Before (has + get + set) |
After (get + set) |
Saved Map ops |
|---|---|---|---|
| 10 | 30 | 20 | 10 |
| 100 | 300 | 200 | 100 |
| 1,000 | 3,000 | 2,000 | 1,000 |
| 10,000 | 30,000 | 20,000 | 10,000 |
Risk: None.
Status: [x] done
Implementation note: if/if swapped for if/else if (skips the max comparison when min was a hit). Also loop start moved to i = 1 since numericValues[0] is used to seed both facetedMinValue and facetedMaxValue. Done as part of the for...of β indexed for sweep.
Location: src/features/column-faceting/createFacetedMinMaxValues.ts:59β65
Category: micro
if (...) ... else if (...) instead of two unconditional ifs. Tiny.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/columnFilteringFeature.utils.ts:156β185
Category: big-o, memoization
Each call walks the columnFilters array. When a filter UI re-renders columns, every column re-walks. Memoize at the column level with deps [columnFilters, column.id], or expose table.getColumnFiltersById() (new API) returning a Record<string, ColumnFilter>.
Before
return column.table.atoms.columnFilters?.get()?.find((d) => d.id === column.id)
?.valueAfter (new memoized table API)
// in columnFilteringFeature.ts
table_getColumnFiltersById: {
fn: () => Object.fromEntries((table.atoms.columnFilters?.get() ?? []).map(f => [f.id, f])),
memoDeps: () => [table.atoms.columnFilters?.get()],
},
// in column_getFilterValue
return column.table.getColumnFiltersById()[column.id]?.valueBig-O: O(n) per call β O(1) lookup; O(n) one-time per columnFilters change.
Scale impact (.find comparisons saved per render β dimension: columns Γ active filters Γ renders, with average .find walking F/2):
| Cols (C) | Active filters (F) | Renders (R) | Before (β C Γ F/2 Γ R) | After (build map once: F Γ R) | Saved |
|---|---|---|---|---|---|
| 10 | 2 | 10 | 100 | 20 | 80 |
| 50 | 5 | 100 | 12,500 | 500 | 12,000 |
| 100 | 10 | 1,000 | 500,000 | 10,000 | 490,000 |
| 500 | 20 | 10,000 | 50,000,000 | 200,000 | 49,800,000 |
Risk: New API name β bikeshed. Backwards compatible.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/columnFilteringFeature.utils.ts:198β232
Category: micro
Calls .find() then .map() over the same array. Use findIndex and slice in/around it.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/createFilteredRowModel.ts:88β101
Category: micro
Build the array once with the global filter id conditionally appended.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/createFilteredRowModel.ts:95β110
Category: micro, big-o (short-circuit)
The .getAllLeafColumns().filter(column_getCanGlobalFilter) pass runs on every filtered-row-model build, even when no global filter is active. Gate the entire branch.
Before
const globallyFilterableColumns = table
.getAllLeafColumns()
.filter((column) => column_getCanGlobalFilter(column))
if (globalFilter && globalFilterFn && globallyFilterableColumns.length) {
filterableIds.push('__global__')
...
}After
if (globalFilter && globalFilterFn) {
const globallyFilterableColumns = table
.getAllLeafColumns()
.filter((column) => column_getCanGlobalFilter(column))
if (globallyFilterableColumns.length) {
filterableIds.push('__global__')
...
}
}Big-O: Saves O(C) work + O(C) column_getCanGlobalFilter invocations per filtered row-model rebuild when no global filter is active (the common case).
Scale impact (work saved per filtered-row-model rebuild, no global filter active):
| Cols (C) | Rebuilds | Before (C Γ rebuilds) column_getCanGlobalFilter calls |
After | Saved |
|---|---|---|---|---|
| 10 | 10 | 100 | 0 | 100 |
| 50 | 100 | 5,000 | 0 | 5,000 |
| 100 | 1,000 | 100,000 | 0 | 100,000 |
| 500 | 10,000 | 5,000,000 | 0 | 5,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/createFilteredRowModel.ts:59β66
Category: micro
Skip the row.columnFilters = {} write when it's already an empty object.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-filtering/filterRowsUtils.ts:43β101
Category: micro
filterRow(row) is called twice in some branches. Cache the boolean and the hasVisibleSubRows flag, branch once.
Scale impact (duplicate filterRow invocations saved β dimension: rows in subtree-bearing branches per filter pass):
| Rows in subtree-bearing branches | Before (filterRow calls) |
After | Saved |
|---|---|---|---|
| 10 | 20 | 10 | 10 |
| 100 | 200 | 100 | 100 |
| 1,000 | 2,000 | 1,000 | 1,000 |
| 10,000 | 20,000 | 10,000 | 10,000 |
Risk: Logic is subtle; needs unit-test coverage when refactored.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-grouping/createGroupedRowModel.ts:141β152
Category: big-o
The grouped row's getValue(colId) calls .includes() on existingGrouping once (or twice β finding #31) per access. With G grouped columns and C total columns called over R grouped rows that's O(G Γ C Γ R). Cache as a Set built once at row-model build time.
Before
getValue: (colId: string) => {
if (existingGrouping.includes(colId)) { ... }
...
}After
// at top of _createGroupedRowModel:
const existingGroupingSet = new Set(existingGrouping)
// in closure:
getValue: (colId: string) => {
if (existingGroupingSet.has(colId)) { ... }
...
}Big-O: O(G) β O(1) per cell access.
Scale impact (.includes compares saved per render of grouped rows β dimension: grouped rows Γ cell reads Γ grouping length):
| Grouped rows (R) | Cell reads per row (C) | Grouping cols (G) | Before (R Γ C Γ G) | After (R Γ C Γ 1) | Saved |
|---|---|---|---|---|---|
| 10 | 10 | 2 | 200 | 100 | 100 |
| 100 | 20 | 3 | 6,000 | 2,000 | 4,000 |
| 1,000 | 50 | 5 | 250,000 | 50,000 | 200,000 |
| 10,000 | 100 | 10 | 10,000,000 | 1,000,000 | 9,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-grouping/createGroupedRowModel.ts:141, 154
Category: micro
Cache the boolean. Subsumed by #30 once Set lookup lands but worth noting independently.
Scale impact (duplicate .includes walks saved per cell access β dimension: grouped rows Γ cell reads):
| Grouped rows Γ cell reads | Before (2 walks/cell) | After (1 walk/cell) | Saved walks |
|---|---|---|---|
| 10 Γ 10 = 100 | 200 | 100 | 100 |
| 100 Γ 20 = 2,000 | 4,000 | 2,000 | 2,000 |
| 1,000 Γ 50 = 50,000 | 100,000 | 50,000 | 50,000 |
| 10,000 Γ 100 = 1,000,000 | 2,000,000 | 1,000,000 | 1,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-grouping/createGroupedRowModel.ts:204β220
Category: micro
Trivial for loop replacement of .reduce.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-grouping/createGroupedRowModel.ts:159β161
Category: memoization
Inside the grouped row's getValue, every non-grouped column lookup calls table.getColumn(colId) and column_getAggregationFn(column). The result depends only on colId (effectively). Cache aggregation results per (row, colId) via a _aggregationCache on the row.
Scale impact (aggregation invocations saved on repeat cell reads β dimension: grouped rows Γ non-grouped cols Γ repeat reads):
| Grouped rows | Non-grouped cols | Repeat reads/cell | Before (re-aggregate each read) | After (1 per cell, then cache hits) | Saved aggregations |
|---|---|---|---|---|---|
| 10 | 5 | 2 | 100 | 50 | 50 |
| 100 | 20 | 5 | 10,000 | 2,000 | 8,000 |
| 1,000 | 50 | 10 | 500,000 | 50,000 | 450,000 |
| 10,000 | 100 | 10 | 10,000,000 | 1,000,000 | 9,000,000 |
Risk: Already cached implicitly via _groupingValuesCache. Verify cache-key collision doesn't occur if extending it.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-ordering/columnOrderingFeature.utils.ts:205β225
Category: big-o
The .filter((col) => !grouping.includes(col.id)) runs .includes per leaf column. Build a Set once.
Before
const nonGroupingColumns = leafColumns.filter(
(col) => !grouping.includes(col.id),
)After
const groupingSet = new Set(grouping)
const nonGroupingColumns = leafColumns.filter((col) => !groupingSet.has(col.id))Big-O: O(L Γ G) β O(L + G). Triggered on every column-order / grouping change.
Scale impact (.includes compares per call β dimension: leaf columns Γ grouping cols):
| Leaf cols (L) | Grouping cols (G) | Before (L Γ G) | After (L + G) | Saved |
|---|---|---|---|---|
| 10 | 1 | 10 | 11 | -1 |
| 100 | 3 | 300 | 103 | 197 |
| 1,000 | 5 | 5,000 | 1,005 | 3,995 |
| 10,000 | 10 | 100,000 | 10,010 | 89,990 |
Risk: None.
Status: [x] done
Implementation note: Original audit proposed building a per-call Map<columnId, cell> inside each getter. Final implementation went further: reuses the already-memoized row_getVisibleCellsByColumnId lookup record (deps [row.getAllCells(), columnVisibility]) rather than rebuilding a Map on every call. Result: O(P) bracket lookups per call, with the underlying record amortized to zero rebuild cost across multiple pin-side getters on the same row. Added an early return when the pin side is empty (consistent with the rest of the codebase). Behavior preserved: cell.position = 'left' | 'right' mutation, ordering by pin-array index, and hidden-column exclusion via the visible-cells record.
Location: src/features/column-pinning/columnPinningFeature.utils.ts:216β224, 250β257
Category: big-o
Each pinned column triggers a linear .find over all visible cells of a row. With P pinned and C visible per row, this is O(P Γ C) per row, per render. Build a Map<columnId, cell> once at the top.
Before
for (const columnId of left) {
const cell = allVisibleCells.find((c) => c.column.id === columnId)
if (cell) {
cell.position = 'left'
cells.push(cell)
}
}After
const cellsByColumnId = new Map<string, (typeof allVisibleCells)[number]>()
for (let i = 0; i < allVisibleCells.length; i++) {
cellsByColumnId.set(allVisibleCells[i].column.id, allVisibleCells[i])
}
for (let i = 0; i < left.length; i++) {
const cell = cellsByColumnId.get(left[i])
if (cell) {
cell.position = 'left'
cells.push(cell)
}
}Big-O: O(P Γ C) β O(P + C) per row.
Scale impact (.find comparisons saved per render β dimension: rows Γ pinned cols Γ visible cells; average .find walks Β½ the visible-cell list):
| Rows (R) | Visible cells/row (C) | Pinned cols (P) | Before (R Γ P Γ C/2) | After (R Γ (P + C)) | Saved |
|---|---|---|---|---|---|
| 10 | 10 | 2 | 100 | 120 | -20 |
| 100 | 20 | 4 | 4,000 | 2,400 | 1,600 |
| 1,000 | 50 | 6 | 150,000 | 56,000 | 94,000 |
| 10,000 | 100 | 10 | 5,000,000 | 1,100,000 | 3,900,000 |
Risk: None. The mutation cell.position = 'left' is unchanged.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-pinning/columnPinningFeature.utils.ts:189, 430
Category: big-o
Builds an array, then .includes() on it for every cell/column. Use a Set.
Before
const leftAndRight: Array<string> = [...left, ...right]
return allCells.filter((d) => !leftAndRight.includes(d.column.id))After
const leftAndRight = new Set<string>()
for (let i = 0; i < left.length; i++) leftAndRight.add(left[i])
for (let i = 0; i < right.length; i++) leftAndRight.add(right[i])
return allCells.filter((d) => !leftAndRight.has(d.column.id))Big-O: O(C Γ (P_l + P_r)) β O(C + P_l + P_r) per row, per call.
Scale impact (.includes compares per render β dimension: rows Γ cells Γ pinned total):
| Rows (R) | Cells/row (C) | Pinned (P) | Before (R Γ C Γ P) | After (R Γ (C + P)) | Saved |
|---|---|---|---|---|---|
| 10 | 10 | 2 | 200 | 120 | 80 |
| 100 | 20 | 4 | 8,000 | 2,400 | 5,600 |
| 1,000 | 50 | 6 | 300,000 | 56,000 | 244,000 |
| 10,000 | 100 | 10 | 10,000,000 | 1,100,000 | 8,900,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-resizing/columnResizingFeature.utils.ts:320β343
Category: bug, micro
passiveSupported is declared inside the function (let passiveSupported: boolean | null = null), so the cache check if (typeof passiveSupported === 'boolean') return passiveSupported is unreachable on first call and the cache is reset on every call. Each resize call probes the DOM via addEventListener('test', ...).
Before
export function passiveEventSupported() {
let passiveSupported: boolean | null = null
if (typeof passiveSupported === 'boolean') return passiveSupported
let supported = false
try { window.addEventListener('test', noop, options); ... }
...
passiveSupported = supported
return passiveSupported
}After
let passiveSupported: boolean | null = null
export function passiveEventSupported() {
if (typeof passiveSupported === 'boolean') return passiveSupported
let supported = false
try { window.addEventListener('test', noop, options); ... }
...
passiveSupported = supported
return passiveSupported
}Big-O: Avoid a DOM listener add+remove on every resize-handler hook-up.
Risk: None. Behavior is what the original obviously intended.
Status: [x] done
Implementation note: Added memoDeps: () => [table.atoms.columnSizing?.get(), table.getHeaderGroups()] to all four entries (table_getTotalSize, table_getLeftTotalSize, table_getCenterTotalSize, table_getRightTotalSize) in columnSizingFeature.ts. Matches the pattern already used by table_getFooterGroups / table_getFlatHeaders: table.getHeaderGroups() is itself memoized against every input that can change the header-row composition (columns, columnOrder, grouping, columnPinning, columnVisibility, groupedColumnMode), so its ref is a compact proxy that holds steady while the underlying inputs don't change. The only other dep is columnSizing for per-column width state. Deliberately omitted columnResizing β with columnResizeMode: 'onChange' (the typical resize-aware setup) the resize handler writes through to columnSizing directly, so depending on columnResizing would cause redundant invalidations on every drag-move tick without changing the output.
Location: src/features/column-sizing/columnSizingFeature.ts:142β154
Category: memoization, big-o
All four (getTotalSize, getLeftTotalSize, getCenterTotalSize, getRightTotalSize) have no memoDeps in the feature config. Each call does .reduce(...) over the header group, summing header_getSize per header (which is itself memoized but still walks the entire array). Layout code reads these every render β for virtualizers, every scroll tick.
Before
table_getTotalSize: { fn: () => table_getTotalSize(table) },
table_getLeftTotalSize: { fn: () => table_getLeftTotalSize(table) },
table_getCenterTotalSize: { fn: () => table_getCenterTotalSize(table) },
table_getRightTotalSize: { fn: () => table_getRightTotalSize(table) },After
table_getTotalSize: {
fn: () => table_getTotalSize(table),
memoDeps: () => [
table.atoms.columnSizing?.get(),
table.atoms.columnPinning?.get(),
table.atoms.columnVisibility?.get(),
table.options.columns,
],
},
// (same memoDeps for the L/C/R variants)Big-O: O(H) per call β O(1) until column sizing/visibility/pinning changes. High-frequency read path.
Scale impact (header_getSize invocations skipped β dimension: renders Γ headers per render; assumes deps unchanged):
| Renders (R) | Headers (H) | Before (R Γ H) | After (1 Γ H + later invalidations) | Saved (steady state) |
|---|---|---|---|---|
| 10 | 10 | 100 | 10 | 90 |
| 100 | 50 | 5,000 | 50 | 4,950 |
| 1,000 | 100 | 100,000 | 100 | 99,900 |
| 10,000 | 500 | 5,000,000 | 500 | 4,999,500 |
Virtualizers calling getTotalSize() per scroll tick amplify this dramatically.
Risk: None. Deps fully capture inputs.
Status: [x] done
Implementation note: Original audit proposed a single-pass partition iterating allCells directly and dispatching each cell to left/center/right via Set membership. On review that's incorrect: it would push pinned cells in cell order rather than pin order, changing user-visible behavior (pinning column B then A should display B, A). The existing per-side loop honors pin order correctly. The consistency win available here, matching the approach in #12, is to drop the leftSet/rightSet allocations used for the center-cell partition and use .includes() on the small left/right arrays directly.
Additional refactor on top: the per-call cellsByColumnId local Map was promoted to a new memoized row API, row.getVisibleCellsByColumnId() (returns Record<string, Cell> with narrower deps [row.getAllCells(), columnVisibility] β pinning doesn't invalidate it). Inside row_getVisibleCells, the pinned path now reads from this memoized record instead of building a fresh Map per call.
Do not "optimize" by deriving visibleCells from Object.values(visibleCellsByColumnId). Object.values() returns integer-index-like string keys (e.g. "0", "1", "42") first in ascending numeric order, regardless of insertion order. Column IDs come from accessorKey, so a user with numeric-string accessor keys ("2", "10", "1") would see their cell order reorder after a round-trip. The Record is safe for bracket-lookup (record[columnId]) but unsafe for ordered iteration. Keep visibleCells built directly from row.getAllCells() to preserve leaf-column order.
Location: src/features/column-visibility/columnVisibilityFeature.utils.ts:157β166
Category: micro
Before
// Center cells: visible cells in natural column order, minus pinned ones.
const leftSet = new Set(left)
const rightSet = new Set(right)
const centerCells: Array<Cell<...>> = []
for (const cell of cells) {
const id = cell.column.id
if (!leftSet.has(id) && !rightSet.has(id)) centerCells.push(cell)
}After
// Center cells: visible cells in natural column order, minus pinned ones.
// .includes() on the small left/right arrays is cheaper than building Sets
// for the typical 1β2 pinned columns per side.
const centerCells: Array<Cell<...>> = []
for (const cell of cells) {
const id = cell.column.id
if (!left.includes(id) && !right.includes(id)) centerCells.push(cell)
}Big-O: Same asymptotic complexity; constant-factor win at typical pin counts. With P_l = P_r = 2, .includes() is ~4 reference comparisons per cell vs Set hashing + bucket traversal + an upfront Set allocation per side per row.
Scale impact (Set object allocations saved per pinned row β dimension: rows that hit the pinned path per render):
| Rows with pinning active per render | Set allocs before (2 per row) | After | Saved Sets |
|---|---|---|---|
| 10 | 20 | 0 | 20 |
| 100 | 200 | 0 | 200 |
| 1,000 | 2,000 | 0 | 2,000 |
| 10,000 | 20,000 | 0 | 20,000 |
(For very heavily pinned tables β P > ~8 per side β Sets would start to pay off again. Reconsider if a user reports that case.)
Risk: None. Output is byte-identical for the typical small-P case; ordering is preserved (Map handles left/right order, center remains in cell order).
Status: [ ] not started
Implementation note: (none)
Location: src/features/column-visibility/columnVisibilityFeature.ts:131β140
Category: memoization
Called by toolbar checkboxes on every render. .some() walks all leaf columns each call.
table_getIsAllColumnsVisible: {
fn: () => table_getIsAllColumnsVisible(table),
memoDeps: () => [table.atoms.columnVisibility?.get(), table.options.columns],
},
table_getIsSomeColumnsVisible: {
fn: () => table_getIsSomeColumnsVisible(table),
memoDeps: () => [table.atoms.columnVisibility?.get(), table.options.columns],
},Big-O: O(C) per call β O(1) until visibility changes.
Scale impact (.some() walks saved per render β dimension: renders Γ leaf cols):
| Renders Γ Cols | Walks before | After (steady state) | Saved |
|---|---|---|---|
| 10 Γ 10 | 100 | 0 | 100 |
| 100 Γ 50 | 5,000 | 0 | 5,000 |
| 1,000 Γ 100 | 100,000 | 0 | 100,000 |
| 10,000 Γ 500 | 5,000,000 | 0 | 5,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/global-filtering/globalFilteringFeature.ts:55β63
Category: memoization
Default getColumnCanGlobalFilter reads flatRows[0].getAllCellsByColumnId()[column.id].getValue() every call. Called once per column when computing globally-filterable columns; with 50 columns that's 50 row[0]-cell rebuilds per filter pass. Memoize across calls keyed on getCoreRowModel().
Before
getColumnCanGlobalFilter: (column) => {
const value = table
.getCoreRowModel()
.flatRows[0]?.getAllCellsByColumnId()
[column.id]?.getValue()
return typeof value === 'string' || typeof value === 'number'
}After (closure-captured cache)
let cachedFor: any = undefined
let cache: Map<string, boolean> | undefined
return {
getColumnCanGlobalFilter: (column) => {
const coreRowModel = table.getCoreRowModel()
if (cachedFor !== coreRowModel) {
cachedFor = coreRowModel
cache = new Map()
const cells = coreRowModel.flatRows[0]?.getAllCellsByColumnId()
if (cells)
for (const id in cells) {
const v = cells[id]?.getValue?.()
cache.set(id, typeof v === 'string' || typeof v === 'number')
}
}
return cache!.get(column.id) ?? false
},
}Big-O: O(C) row-zero cell rebuilds per filter pass β O(C) total, amortized O(1) per column lookup.
Scale impact (row[0] cell-collection rebuilds saved β dimension: cols Γ filter passes):
| Cols (C) | Filter passes (F) | Before (C Γ F rebuilds) | After (β€ F rebuilds) | Saved |
|---|---|---|---|---|
| 10 | 10 | 100 | 10 | 90 |
| 50 | 100 | 5,000 | 100 | 4,900 |
| 100 | 1,000 | 100,000 | 1,000 | 99,000 |
| 500 | 10,000 | 5,000,000 | 10,000 | 4,990,000 |
Risk: None. Cache invalidates whenever core row model identity changes.
Status: [x] done
Implementation note: One-character fix β changed row_getIsExpanded(row) to row_getIsExpanded(currentRow) inside the parent-walk loop. Previously the function checked the original row on every iteration instead of the parent it had just walked to, which made the loop a no-op past the first iteration and returned wrong results (e.g. a leaf row would report "all parents expanded" whenever the leaf itself was expanded, regardless of any collapsed ancestor). Added a comment explaining the intent. Downstream caller in rowPinningFeature.utils.ts:122 (the if (row_getIsAllParentsExpanded(fullRow)) check that decides whether a pinned row should appear) automatically gets the correct semantic β pinned rows now correctly account for their ancestor chain's expansion state instead of accidentally tracking the pinned row's own expansion.
Location: src/features/row-expanding/rowExpandingFeature.utils.ts:324β337
Category: bug
The loop walks parents but calls row_getIsExpanded(row) (original row) instead of row_getIsExpanded(currentRow). Returns wrong result and the loop iterations are wasted.
Before
while (isFullyExpanded && currentRow.parentId) {
currentRow = row.table.getRow(currentRow.parentId, true)
isFullyExpanded = row_getIsExpanded(row)
}After
while (isFullyExpanded && currentRow.parentId) {
currentRow = row.table.getRow(currentRow.parentId, true)
isFullyExpanded = row_getIsExpanded(currentRow)
}Big-O: Correctness fix. Currently the loop is effectively a no-op past one iteration (always re-checks the same row).
Risk: Behavior changes β verify with tests; this is the intended logic.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-expanding/rowExpandingFeature.ts registration
Category: memoization
.some(row_getCanExpand) over flatRows every call. Add memoDeps: () => [table.getPrePaginatedRowModel().flatRows, table.options.getRowCanExpand, table.options.enableExpanding].
Scale impact (worst case .some() walks saved when no expandable rows exist β dimension: calls Γ flat rows):
| Calls | Flat rows | Before (calls Γ rows) | After (steady state) | Saved |
|---|---|---|---|---|
| 10 | 10 | 100 | 0 | 100 |
| 100 | 100 | 10,000 | 0 | 10,000 |
| 1,000 | 1,000 | 1,000,000 | 0 | 1,000,000 |
| 10,000 | 10,000 | 100,000,000 | 0 | 100,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-pagination/rowPaginationFeature.utils.ts:215β225
Category: micro, bundle-size
Before
let pageOptions: Array<number> = []
if (pageCount && pageCount > 0) {
pageOptions = [...new Array(pageCount)].fill(null).map((_, i) => i)
}After
if (pageCount <= 0) return []
return Array.from({ length: pageCount }, (_, i) => i)Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-pinning/rowPinningFeature.utils.ts:247β261
Category: micro
.map(({ id }) => id).indexOf(row.id) β findIndex(r => r.id === row.id).
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-selection/rowSelectionFeature.utils.ts:78β107
Category: micro
When deselecting all, the function spreads old, then deletes every row id. Just return {} (or a fresh map of forced-selected ids) without the spread.
Scale impact (per deselect-all action β dimension: prior selection size):
| Prior selections | Before (spread + delete per row) | After (return {}) |
Saved ops |
|---|---|---|---|
| 10 | 1 spread + 10 deletes | 0 | 11 ops |
| 100 | 1 spread + 100 deletes | 0 | 101 ops |
| 1,000 | 1 spread + 1,000 deletes | 0 | 1,001 ops |
| 10,000 | 1 spread + 10,000 deletes | 0 | 10,001 ops |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-selection/rowSelectionFeature.utils.ts:247β300
Category: micro
Replace let isAll = β¦; if (cond) isAll = false; return isAll with return !preGroupedFlatRows.some(...). Engine inlining better.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-selection/rowSelectionFeature.utils.ts:618β658
Category: micro
If the recursive recurseRows(row.subRows) returns the same reference, skip the spread:
if (newSubRows !== row.subRows) row = { ...row, subRows: newSubRows }Big-O: Same. Saves O(depth Γ n) shallow clones when nothing in a subtree matched.
Scale impact (row spread allocations skipped when subtree unchanged β dimension: parent rows with subrows Γ renders where selection didn't change them):
| Parent rows with subrows | Skip-clone renders | Before clones | After clones | Saved |
|---|---|---|---|---|
| 10 | 10 | 100 | 0 | 100 |
| 100 | 100 | 10,000 | 0 | 10,000 |
| 1,000 | 1,000 | 1,000,000 | 0 | 1,000,000 |
| 10,000 | 10,000 | 100,000,000 | 0 | 100,000,000 |
Risk: Need to confirm the recursion never mutates row.subRows in-place. (It does construct a new filtered array, so the reference will differ when results differ.)
Status: [x] done
Implementation note: Investigated why the clone existed: the post-sort loop assigns row.subRows = sortData(row.subRows), which would corrupt the source row model if row were the original. So the clone is genuinely necessary for rows with subRows, but pointless for leaf rows. Refactored: rows.slice() produces a sortable array copy (one allocation), the sort runs as before, and the post-sort loop clones only rows where row.subRows.length > 0. Leaf rows pass through as their original references. For a flat table (the common case) this drops from N heavy clones to zero per-row clones plus one slice(). For nested tables, only parent rows are cloned (typically a small fraction of total rows). The native Array.prototype.sort is stable since ES2019; the explicit row.index tiebreaker was preserved in the comparator for any caller that relied on it.
Location: src/features/row-sorting/createSortedRowModel.ts:81β89
Category: big-o, micro
const sortedData = rows.map((row) => {
const cloned = Object.create(Object.getPrototypeOf(row))
return Object.assign(cloned, row)
})This allocates N row clones every time the sorted row model rebuilds. Array.prototype.sort is stable since ES2019, so the clones are unnecessary. Sort the original references with a tie-break index for stability or rely on engine stability.
After
const indexed = rows.map((row, index) => ({ row, index }))
indexed.sort((a, b) => {
// existing comparator on a.row vs b.row, falling back to a.index - b.index
})
return indexed.map((x) => x.row)Big-O: Drops O(n) heavy object allocations per sort.
Scale impact (heavy row clones replaced with lightweight {row, index} wrappers β dimension: rows sorted per sort pass):
| Rows sorted | Before (full row clones via Object.create + Object.assign) |
After ({row, index} wrappers) |
Saved |
|---|---|---|---|
| 10 | 10 heavy clones | 10 small wrappers | ~10 wide β narrow allocations |
| 100 | 100 | 100 | ~100 |
| 1,000 | 1,000 | 1,000 | ~1,000 |
| 10,000 | 10,000 | 10,000 | ~10,000 |
(Memory is the bigger win than count: each "heavy clone" copies all enumerable fields on a constructed Row, vs {row, index} which is 2 fields.)
Risk: Behavior depends on whether downstream code mutates the returned rows. The current clone is defensive against mutation. Verify nothing post-sort writes to row instances (the project uses prototype methods, so mutations should not occur).
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-sorting/rowSortingFeature.utils.ts:79β114
Category: bug
const firstRows = column.table.getFilteredRowModel().flatRows.slice(10)This takes rows from index 10 onwards, not the first 10. The intent (per the variable name firstRows) is the first 10 samples for auto-detection of sortFn. With β€10 rows the array is empty β fallback to alphanumeric sort regardless of actual data types.
After
const firstRows = column.table.getFilteredRowModel().flatRows.slice(0, 10)Risk: Changes auto-detected sort fn for tables that have β₯11 rows. Existing tests may need adjustment if they relied on the broken behavior.
Status: [ ] not started
Implementation note: (none)
Location: src/features/row-sorting/rowSortingFeature.utils.ts:388β418
Category: memoization
Both walk the sorting array; called for every visible sortable column on every render. Memoize per column with deps [sorting, column.id], or add table.getSortingById().
Scale impact (.find/.findIndex compares per render β dimension: visible sortable cols Γ active sorts Γ renders):
| Cols (C) | Active sorts (S) | Renders (R) | Before (β C Γ S/2 Γ R, Γ 2 fns) | After (memoized: ~0) | Saved |
|---|---|---|---|---|---|
| 10 | 1 | 10 | 100 | 0 | 100 |
| 50 | 3 | 100 | 15,000 | 0 | 15,000 |
| 100 | 5 | 1,000 | 500,000 | 0 | 500,000 |
| 500 | 10 | 10,000 | 50,000,000 | 0 | 50,000,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/fns/sortFns.ts:154β200
Category: big-o, micro
aStr.split(re).filter(Boolean) runs O(n log n) times during a sort (once per comparison). Each call allocates two arrays. Drop the .filter(Boolean) by skipping empty pieces inline.
Before
const a = aStr.split(reSplitAlphaNumeric).filter(Boolean)
const b = bStr.split(reSplitAlphaNumeric).filter(Boolean)After (sketch)
const a = aStr.split(reSplitAlphaNumeric)
const b = bStr.split(reSplitAlphaNumeric)
let ai = 0, bi = 0
while (ai < a.length || bi < b.length) {
while (ai < a.length && !a[ai]) ai++
while (bi < b.length && !b[bi]) bi++
...
}Big-O: Halves array allocations per comparison; total saves O(N log N) intermediate arrays for sort of N rows.
Scale impact (intermediate .filter() arrays saved across a single sort β dimension: rows sorted, comparisons β N logβ N, each saves 2 arrays):
| Rows sorted (N) | Comparisons (β N logβ N) | Before arrays (2 Γ comps) | After arrays (0) | Saved arrays |
|---|---|---|---|---|
| 10 | ~33 | ~66 | 0 | ~66 |
| 100 | ~664 | ~1,328 | 0 | ~1,328 |
| 1,000 | ~9,966 | ~19,932 | 0 | ~19,932 |
| 10,000 | ~132,877 | ~265,754 | 0 | ~265,754 |
Risk: Careful logic β empty-string skipping must mirror the .filter(Boolean) semantics exactly.
Status: [ ] not started
Implementation note: (none)
Location: src/fns/sortFns.ts:99β114
Category: micro
Normalize Date β getTime() once at the top, then compare numbers (or fall through to >/< for strings). Marginal but the comparator runs O(n log n) times.
Risk: None when only used for true datetime columns. Verify mixed-type columns don't rely on coercion.
Status: [ ] not started
Implementation note: (none)
Location: src/fns/filterFns.ts:210β216, 231β237
Category: micro
Hoist to a module constant.
Scale impact (array allocations saved per filter evaluation β dimension: rows evaluated per filter pass):
| Rows evaluated | Before (2 arrays/row) | After (0) | Saved arrays |
|---|---|---|---|
| 10 | 20 | 0 | 20 |
| 100 | 200 | 0 | 200 |
| 1,000 | 2,000 | 0 | 2,000 |
| 10,000 | 20,000 | 0 | 20,000 |
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: src/fns/filterFns.ts:287β296, 321β332
Category: micro
Replace with indexed for loops with early return. Removes closure-per-row.
Scale impact (closure allocations saved per filter evaluation β dimension: rows evaluated):
| Rows evaluated | Before (.some closures) |
After | Saved closures |
|---|---|---|---|
| 10 | 10 | 0 | 10 |
| 100 | 100 | 0 | 100 |
| 1,000 | 1,000 | 0 | 1,000 |
| 10,000 | 10,000 | 0 | 10,000 |
Risk: None.
56. filterFn_greaterThanOrEqualTo / lessThanOrEqualTo delegate via 2β3 function calls β Score: 2
Status: [ ] not started
Implementation note: (none)
Location: src/fns/filterFns.ts:149β195
Category: micro, bundle-size (tradeoff)
Currently >= runs > then =. Could inline the comparison directly, at the cost of more code. Worth it only if profiling shows these in hot loops.
Risk: Bundle size grows slightly.
Status: [~] partial
Implementation note: The quickselect-vs-sort question (the headline of this finding) was not addressed β .sort() is still used because quickselect adds ~50 LOC of complexity that isn't justified without profiling evidence that median is hot for very large groups. The smaller win was captured though: fused .map((row) => row.getValue(columnId)) with the previous isNumberArray(values) validation pass into a single loop that extracts values into a preallocated array and bails immediately on the first non-number. Removes one full walk over the values array per call. The full-sort cost remains.
Location: src/fns/aggregationFns.ts:156β166
Category: big-o
Median requires only the middle element; quickselect is O(n) average vs .sort() O(n log n). Worth it only for large groups; skip otherwise to keep bundle slim.
Risk: Quickselect adds bytes and complexity. Recommend leaving as-is unless real-world data shows hot.
Status: [~] partial
Implementation note: The cross-function memoization the original finding proposed (sharing a Set between aggregationFn_unique and aggregationFn_uniqueCount when both run on the same column in the same pass) was not implemented β the use case is rare enough that it's not worth the API plumbing. The per-call fusion was captured though: both functions now iterate leafRows directly into a Set instead of building an intermediate Array via .map and then constructing the Set from it. Saves one Array allocation of size leafRows.length per call.
Location: src/fns/aggregationFns.ts:172β193
Category: memoization
Only useful if both are called on the same column in the same aggregation pass. Not a common pattern; skip unless a consumer hits it.
Risk: None.
Status: [ ] not started
Implementation note: (none)
Location: filterFns, faceting, grouping, pinning, global filtering
Category: memoization
getAllLeafColumns() is memoized at the table level, but its deps are sometimes computed inline (see #16 type defects). Verify the memo holds across the row-model rebuild lifecycle. If it doesn't, this is the most-leveraged optimization in the package.
Risk: Already memoized in coreColumnsFeature; just audit for accidental dep churn.
Status: [ ] not started
Implementation note: (none)
Location: constructCell.ts, constructColumn.ts, constructHeader.ts, constructRow.ts
Category: bundle-size
Each file has a getXyzPrototype(table) function with identical shape β if (!table._xyzPrototype) { table._xyzPrototype = { table }; for (...) feature.assignXyzPrototype?.(...) }. Could collapse to a shared utility keyed by prototypeKey/assignMethodName. Saves ~300β500 bytes gzipped at the cost of indirection at construction time only.
Risk: Slight loss of readability. Worth doing only if running close to a size-limit budget.
Anything β₯ 7:
| # | Title | Score | Category |
|---|---|---|---|
| 12 | centerColumns filter uses .includes β Set |
8 | big-o |
| 35 | row_getLeftVisibleCells uses .find over visible cells |
8 | big-o |
| 37 | passiveEventSupported cache bug |
8 | bug |
| 38 | table_getTotalSize & L/C/R variants unmemoized |
8 | memoization |
| 42 | row_getIsAllParentsExpanded checks wrong row |
8 | bug |
| 1 | memo() deps .some β loop |
7 | micro |
| 14 | recurseHeadersForSpans spread + filter chain |
7 | big-o / micro |
| 16 | table_getLeafHeaders memoDeps call expensive fns |
7 | memoization |
| 30 | grouped row's existingGrouping.includes per cell |
7 | big-o |
| 34 | orderColumns grouping.includes β Set |
7 | big-o |
| 49 | createSortedRowModel clones every row |
7 | big-o / micro |
| 50 | column_getAutoSortFn slice(10) should be slice(0, 10) |
7 | bug |
Anything 5β6: a second wave of memoization gaps and partition-loop consolidations (#2, #9, #15, #21, #24, #27, #33, #36, #39, #40, #41, #52). All low-risk.
Anything β€ 4: incremental polish; pursue if budget allows or when adjacent code is being touched.
- No changes to public API arg/return types.
- No changes to the shape of
table.getState(). - New
get*methods are allowed when they unlock big-O wins (e.g.,table.getColumnFiltersById(),table.getSortingById()) β bundle every such addition against the size-limit budget. - Bundle-size wins β€ 200 bytes gzipped per change should be ignored unless they ride along with another refactor.