← All writing

/ 4 min read

Fast tables, honest numbers: keeping data heavy React screens responsive

What actually makes a screen with thousands of rows, live updates and complex filters slow, and the handful of techniques that fix it without rewriting the app.

  • React
  • Performance
  • TypeScript

Most of my working days go into screens that people keep open from morning to evening: supervision dashboards, report builders, admin panels with tables that hold thousands of rows. Nobody opens them for the animations. They open them to find one number, act on it and move on, and every pause between a click and a result is time the product takes from them.

These screens rarely get slow because of one bad decision. They get slow through a dozen reasonable ones that multiply. This is the checklist I go through, in the order I go through it.

Measure before you memo

The cost of a screen is roughly how many components render times how expensive each render is, plus whatever the browser has to lay out and paint afterwards. Guessing which of the three dominates is how teams end up wrapping everything in useMemo and getting nothing back.

So the first step is always the React Profiler with "record why each component rendered" switched on, and the browser Performance panel with CPU throttling at 4x, because the people using the product are not on your laptop. I record one real interaction, such as typing into a filter or selecting a row, and read three things: how many components rendered, which ones took the longest, and whether there is a long purple layout block after the scripting.

Render only what is on screen

A table with five thousand rows and twelve columns is sixty thousand cells. No amount of memoisation makes mounting sixty thousand cells cheap. Virtualisation, rendering only the rows inside the viewport plus a small overscan, turns that into a few hundred cells no matter how large the data set gets.

const ROW_HEIGHT = 40

function Rows({ rows }: { rows: Row[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: () => ROW_HEIGHT,
  overscan: 8,
})

return (
  <div ref={scrollRef} className="h-[70vh] overflow-auto [contain:strict]">
    <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
      {virtualizer.getVirtualItems().map((item) => (
        <RowView
          key={rows[item.index].id}
          row={rows[item.index]}
          style={{ position: 'absolute', top: 0, height: ROW_HEIGHT, transform: 'translateY(' + item.start + 'px)' }}
        />
      ))}
    </div>
  </div>
)
}
A virtualised table body with TanStack Virtual. Fixed row heights keep the maths trivial and scrolling smooth.

Two details matter more than the library. Rows are keyed by a stable id, never by index, so React reuses them while scrolling. And the scroll container gets contain: strict, which tells the browser that nothing inside it affects layout outside it, so a scroll never invalidates the rest of the page.

Keep state where it is used

The most common slow table I see stores row selection in the table component itself: const [selected, setSelected] = useState<Set<string>>(). Click one checkbox and the whole table re-renders, because the state lives at the top. Memoising the rows helps a little, but every row still receives a new selected set and has to compare it.

The fix is to move selection into a small store and let each row subscribe to its own slice. Clicking a checkbox then re-renders exactly one row, plus the header that shows the count.

function createSelection() {
let selected = new Set<string>()
const listeners = new Set<() => void>()
return {
  toggle(id: string) {
    selected = new Set(selected)
    if (selected.has(id)) selected.delete(id)
    else selected.add(id)
    listeners.forEach((notify) => notify())
  },
  subscribe(notify: () => void) {
    listeners.add(notify)
    return () => listeners.delete(notify)
  },
  has: (id: string) => selected.has(id),
  size: () => selected.size,
}
}

function useIsSelected(selection: Selection, id: string) {
return useSyncExternalStore(selection.subscribe, () => selection.has(id))
}
Each row subscribes to one boolean. React only re-renders the rows whose answer changed.

The same idea applies to hover state, expanded rows and inline edits. If a piece of state belongs to one row, it should not live in a component that renders all of them.

Let typing stay instant

Filters are where users feel lag the most, because typing has a rhythm and any delay breaks it. React gives us the tool for this directly: render the input with the urgent value and the results with a deferred one.

const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const visible = useMemo(() => filterRows(rows, deferredQuery), [rows, deferredQuery])

return (
<>
  <input value={query} onChange={(e) => setQuery(e.target.value)} />
  <Rows rows={visible} aria-busy={query !== deferredQuery} />
</>
)

The input updates on every key press. The expensive filter runs in a background render that React abandons whenever a newer key press arrives. On large data sets I move the filter to the server and keep this pattern for the request, so the field never waits for the network either.

Know when the browser is the wrong place

Virtualisation solves rendering, not data. Once a table reaches tens of thousands of rows, sorting, filtering and aggregating them on the client means downloading all of them first, and that cost lands on the slowest phone in the room. For those screens the API does the work: pagination, sorting and filtering happen on the server, and the client renders one page and keeps the previous page visible while the next one loads.

This is a contract decision as much as a frontend one, which is why I like to be in the room when the API is designed. A list endpoint that accepts a cursor, a sort key and typed filters from day one is much cheaper than retrofitting one after the screen is slow in production.

Small costs that add up

  • Reuse formatters. new Intl.NumberFormat(...) inside a cell creates a formatter per cell per render. Create it once at module level and call format in the cell.
  • Batch live updates. When a socket pushes many updates a second, collect them and apply them once per animation frame instead of setting state on every message.
  • Fix row heights. Rows that change height as content loads make the browser recalculate layout for everything below them. Truncate with a tooltip instead.
  • Avoid inline objects in hot paths. A new style or options object on every render defeats memo on the child that receives it.
  • Ship less JavaScript. Charting and date libraries are often the largest part of an admin bundle. Load them on the routes that need them.

The habit behind all of it

None of these techniques are new. What makes the difference is treating responsiveness as a feature with a budget rather than something to fix before a demo: profiling one real interaction after each significant change, and keeping the data contracts shaped for the screens that use them. The people who live in these screens never notice when it is done well. They just get their work done faster, which is the whole point.

Next article ↗

Hide the button, enforce on the server: authorization in multi tenant products