Streaming Updates into a React Table without Jank Permalink to this section

Part of React EventSource Hooks & State, under Frontend Consumption & Client Patterns.

A live table is where SSE meets React’s weakest spot: hundreds of updates a second landing in a component tree that reconciles from the root. The fix is not a faster table β€” it is fewer renders, narrower renders, and fewer rendered rows.

Symptom & Developer Intent Permalink to this section

  • Scrolling stutters while the feed is live and is smooth the moment it stops.
  • The React profiler shows the table component rendering dozens of times a second.
  • Input in a filter box lags behind typing while updates are flowing.
  • Sorting or filtering a live table freezes the page for hundreds of milliseconds.
  • Rows visibly flash or lose scroll position when data arrives.

The intent is a table that stays interactive at any event rate the server can produce.

Root Cause Analysis Permalink to this section

Three separate costs stack up, and each has a different fix.

Where the frame budget goes at 200 events per second Bar chart of milliseconds per second spent on render frequency, render breadth and DOM row count for a live table. Where the frame budget goes at 200 events per second 5 000-row table, 200 events/s, budget is ~16 ms per frame One render per event 400 ms Full-grid reconciliation 250 ms 5 000 DOM rows 120 ms After all three fixes 9 ms milliseconds of work per second
Sixteen milliseconds per frame is the whole budget. Two of these three costs exceed it on their own before any application work happens.

Render frequency. Calling setState per event means one render per event. At 200 events a second that is 200 renders, of which at most 60 can ever be painted β€” the rest is work thrown away. This alone accounts for most of the jank.

Render breadth. A table whose rows read from one state object re-renders every row when any row changes. One updated cell costs a full-grid reconciliation, so cost scales with row count rather than with the size of the change.

Row count. Rendering five thousand DOM rows is expensive regardless of how the data arrived. Once the visible window is a fraction of the dataset, virtualisation is the only fix that scales.

Row identity is the fourth, quieter problem: keying rows by array index makes React reuse the wrong DOM nodes when data reorders, producing visible flashing and lost focus.

Step-by-Step Resolution Permalink to this section

Three fixes, each addressing a different cost Pipeline of the fixes: frame batching caps render frequency, memoised rows narrow render breadth, and virtualisation caps DOM node count. Three fixes, each addressing a different cost keying by row id is what lets memoisation work at all Frame batching ≀60 renders/s Memoised rows only changed rows Virtualisation visible window only Smooth interactive at any rate frequency breadth node count
They compose rather than substitute β€” batching alone still reconciles the whole grid, and memoisation alone still renders per event.

Step 1 β€” Batch events onto the animation frame Permalink to this section

// useStreamBuffer.js β€” accumulate incoming events, flush once per painted frame.
import { useEffect, useRef, useState } from 'react';

export function useStreamBuffer(url) {
  const [rows, setRows] = useState(() => new Map());
  const pending = useRef(new Map());
  const queued = useRef(false);

  useEffect(() => {
    const es = new EventSource(url);

    const flush = () => {
      queued.current = false;
      if (!pending.current.size) return;
      const batch = pending.current;
      pending.current = new Map();
      setRows((prev) => {
        const next = new Map(prev);
        for (const [id, row] of batch) next.set(id, row);   // one render for N events
        return next;
      });
    };

    es.addEventListener('row', (e) => {
      const row = JSON.parse(e.data);
      pending.current.set(row.id, row);       // later updates to the same row collapse
      if (!queued.current) {
        queued.current = true;
        requestAnimationFrame(flush);
      }
    });

    return () => es.close();
  }, [url]);

  return rows;
}

Keying the pending map by row id gives free coalescing: five updates to one row between frames become one.

Step 2 β€” Key rows by identity and memoise them Permalink to this section

const Row = React.memo(function Row({ row }) {
  return (
    <tr>
      <td>{row.symbol}</td>
      <td>{row.price.toFixed(4)}</td>
      <td>{row.change}</td>
    </tr>
  );
}, (a, b) => a.row === b.row);           // reference equality: unchanged rows never re-render

function Table({ rows }) {
  return (
    <tbody>
      {[...rows.values()].map((row) => (
        <Row key={row.id} row={row} />     // stable identity, never the array index
      ))}
    </tbody>
  );
}

Because the buffer replaces only changed rows in the map, unchanged rows keep their object identity and React.memo skips them entirely.

Step 3 β€” Subscribe per cell for very high rates Permalink to this section

When a handful of columns update far more often than the rest, let those cells subscribe directly and leave the table out of it.

// An external store means a price tick re-renders one cell, not the grid.
import { useSyncExternalStore } from 'react';

function PriceCell({ id }) {
  const price = useSyncExternalStore(
    (cb) => priceStore.subscribe(id, cb),
    () => priceStore.get(id),
  );
  return <td>{price.toFixed(4)}</td>;
}

Step 4 β€” Virtualise once the list is long Permalink to this section

// Render only the visible window; DOM node count stops tracking dataset size.
function VirtualTable({ rows, rowHeight = 32, height = 600 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const all = [...rows.values()];

  const first = Math.floor(scrollTop / rowHeight);
  const visible = Math.ceil(height / rowHeight) + 4;         // small overscan
  const slice = all.slice(first, first + visible);

  return (
    <div style={{ height, overflowY: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      <div style={{ height: all.length * rowHeight, position: 'relative' }}>
        {slice.map((row, i) => (
          <div key={row.id}
               style={{ position: 'absolute', top: (first + i) * rowHeight, height: rowHeight }}>
            <Row row={row} />
          </div>
        ))}
      </div>
    </div>
  );
}

Step 5 β€” Keep sorting and filtering off the hot path Permalink to this section

// Derive once per render from the raw map, memoised on the inputs that matter.
const sorted = useMemo(
  () => [...rows.values()].sort(comparators[sortKey]),
  [rows, sortKey],
);

// And keep typing responsive by deferring the expensive derived view.
const deferredFilter = useDeferredValue(filterText);
const filtered = useMemo(
  () => sorted.filter((r) => r.symbol.includes(deferredFilter)),
  [sorted, deferredFilter],
);

Validation & Monitoring Permalink to this section

Profile with the feed at its realistic peak, not at idle.

What the profiler should show after each fix Table of profiler observations before and after batching, memoisation and virtualisation. What the profiler should show after each fix Before After Commits per second 200 ≀60 Rows per commit all 5 000 only changed DOM rows 5 000 ~24 visible Long tasks > 50 ms continuous none
Each fix has its own observable signature, so a fix that did not land is visible rather than merely suspected.
// Count renders per second in development to confirm batching works.
let renders = 0;
useEffect(() => {
  renders += 1;
  const t = setInterval(() => { console.log('renders/s', renders); renders = 0; }, 1000);
  return () => clearInterval(t);
});
// Expect ≀60 with frame batching, regardless of event rate.

In the React Profiler, the shape to look for is a small number of commits per second, each touching only changed rows. A commit list showing every row on every commit means memoisation is being defeated β€” usually by a new object identity created in the parent on each render.

For production signal, the browser’s own long-task and frame metrics are more useful than any React-specific number:

// Report long tasks while the stream is live: the user-visible definition of jank.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) beacon('ui_long_task_ms', Math.round(entry.duration));
  }
}).observe({ entryTypes: ['longtask'] });

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Does React 18 automatic batching already solve this?

Only partially. Automatic batching groups updates that happen in the same tick; events arriving from separate network callbacks are separate ticks, so a hundred events still produce a hundred renders. Frame-based batching is what caps renders at the paint rate.

Why do rows flash or lose focus when data reorders?

Because they are keyed by array index. When the order changes, React reuses the DOM node at each index for different data, so a row's node is recycled for another row's content. Key by a stable row id and both problems disappear.

Should I use a state library or plain component state?

For a single table, the frame-batched hook with a Map is enough. Reach for an external store when several distant components need slices of the same feed, or when very high-frequency cells should re-render independently of the container β€” subscription granularity is the reason to add one, not the event rate itself.

How many rows before virtualisation is worth it?

Roughly a few hundred visible rows, though the real trigger is DOM node count rather than data size: a table with many cells per row hits the limit sooner. Profile with the real column set β€” a five-column table and a twenty-column table behave very differently at the same row count.