Vivek Jalondhara. All writing

React

Large React Tables: Find the Bottleneck Before Virtualizing

By Vivek Jalondhara · 4 min read
Published

Separate network, transformation, and rendering costs in a price-review table, then apply pagination, stable row identity, or virtualization.

A table can feel slow because it downloads too much data, recalculates every row, mounts too many cells, or performs expensive layout work. Virtualizing the rows addresses one of those costs. It does not repair an oversized response or an incorrect filtering strategy.

This illustrative Sheet Parser review screen compares extracted vendor prices and lets a reviewer edit individual rows. The approach below identifies which work becomes expensive before choosing an optimization. It makes no claim about the project’s actual row counts or measured performance.

Measure a specific interaction

Choose one action, such as opening a sheet, typing into a price field, changing a filter, or scrolling. Record request timing and response size, inspect browser scripting and layout activity, and use React’s Profiler to understand rendering work for the relevant subtree.

Compare representative data with the same interaction after each change. React render duration does not include every cost visible in a browser trace. Profiling also introduces overhead and is not enabled in ordinary production builds by default, so state the measurement environment before interpreting numbers.

Bound the data before mounting the rows

If fetching the complete dataset is already expensive, move pagination or cursor traversal to the server. Filtering and sorting must use the same data boundary. Sorting only the currently downloaded page can make the interface appear globally ordered when it is not.

For a review workflow, include the relevant import or dataset revision in that boundary when supported. Otherwise, rows arriving during navigation can move between pages. Reset the current page when filters change, and decide whether counts are exact, approximate, or unavailable rather than presenting an invented total.

Preserve identity for unchanged rows

Give each row a durable identifier and use it for rendering keys, selection, and edits. Array positions change under sorting and filtering. An edit stored against position five can silently move to a different product after the user changes the ordering.

Update only the row object that changed while retaining references to its neighbors. This lets memoized row components avoid work when their other props are stable. The example still searches and copies an array, so it remains linear in row count; its benefit is reducing unnecessary downstream rendering.

Apply virtualization to a rendering problem

When mounting many visible-capable cells dominates the cost, a virtualizer can render the current window plus a small overscan region. TanStack Virtual exposes item measurement, stable item keys, and overscan controls. Increasing overscan can make scrolling smoother but mounts more work.

Variable-height validation messages complicate measurement. Fixed row-height assumptions can cause overlaps or jumping after a reviewer expands an error. Decide how expanded content is measured, how the scroll position is preserved, and what happens when the focused editor approaches the edge of the mounted window.

Keep edits independent of mounted cells

A virtualized row can unmount while the user scrolls. If its unsaved value lives only inside that cell component, the draft may disappear. Keep drafts in a row-identified store owned above the virtualized window, and preserve the original server value separately when a save can fail.

Selection needs an equally explicit rule. “Select all” could mean visible rows, loaded rows, or every row matching the server filter. Show the intended scope and send stable identities or a validated filter selection to the server. Rendering fewer rows must not silently change a bulk action’s meaning.

Verify usability after the optimization

Repeat the original measured interaction, then check keyboard navigation, sorting during an edit, scrolling away from a draft, and expanding validation messages. Confirm that table headers remain associated with cells and that reading order is meaningful in the chosen markup.

Offer an intentional route for reviewing or exporting the full result set when browser search only sees mounted rows. Avoid adding pagination, virtualization, and extensive memoization together without evidence: changing one measured cause at a time makes both regressions and improvements easier to explain.

Keep unchanged row objects stable

This local update assumes a validated decimal string and unique row identifiers. Use it in a functional React state update; server validation and save reconciliation are separate responsibilities.

Illustrative example · TypeScript · Simplified for explanation

type PriceRow = Readonly<{
  id: string;
  sku: string;
  amount: string;
}>;

function patchPrice(
  rows: readonly PriceRow[], id: string, amount: string
): readonly PriceRow[] {
  const index = rows.findIndex(row => row.id === id);
  const current = rows[index];
  if (!current || current.amount === amount) return rows;
  const next = rows.slice();
  next[index] = { ...current, amount };
  return next;
}

// In a component with the corresponding state:
// setRows(current => patchPrice(current, editedId, validatedAmount));
How the example fits together
  1. Measure one slow interaction
  2. Identify data or rendering cost
  3. Apply one targeted change
  4. Recheck timing, edits, and keyboard behavior

Conceptual flow for the example above.

Tradeoff to keep in mind

Stable references help only when consumers can skip unchanged work. Virtualization reduces mounted content; server pagination reduces transferred data. Neither substitutes for measuring the actual bottleneck.

Related project: Sheet Parser →

References & further reading

Related posts