DEV Community

Nainik Mehta
Nainik Mehta

Posted on

React virtualized chat: scroll anchoring & no-jump lists

Intro: the one‑pixel problem

If you build chat or live‑feed UIs in React, you've probably seen it: the viewport jumps when older messages are prepended, or when a streaming item grows while the user is pinned to the bottom. Those micro‑jumps are small but hugely damaging to perceived quality.

This article captures a production‑tested, three‑step pattern I use to make React virtualized list scroll anchoring pixel‑perfect: stable keys → end‑anchored virtualizer → pre‑measure (measure‑then‑commit). The pattern uses features from @tanstack/react-virtual but the principles apply to any virtualizer.

Why this happens (brief)

Virtualizers keep a measurements cache and map items to positions. When you prepend history or a streamed message grows, the library needs to reconcile which item should remain in view and how much the scroll offset must change. Mistakes happen when:

  • measurements are keyed by index (indexes shift when you prepend),
  • the virtualizer anchors to pixels rather than to an item identity, or
  • unmeasured items use rough estimates that later settle and drift the view.

Fix the three root causes and the jumps disappear.

The 3‑step pattern

1) Stable keys

Always identify items by stable IDs (getItemKey / key), not index. Cache any measurement or size info by that ID. When you prepend older messages, indexes change but IDs do not — so your cache stays valid.

Example conceptually:

const measurementCache = new Map();
function getItemKey(i: number) { return items[i].id }

When measuring, store measurementCache.set(items[i].id, height). On subsequent renders look up by id.

2) End‑anchored virtualizer

Anchor the viewport to an item key (a logical anchor) instead of raw pixel offsets. TanStack Virtual now supports end anchoring (anchorTo: 'end') which captures the visible item key before updates and restores it after prepends or growth. This keeps the same message visible.

A minimal TanStack example:

const virtualizer = useVirtualizer({
  count: items.length,
  getItemKey: i => items[i].id, // stable keys
  getScrollElement: () => parentRef.current,
  estimateSize: () => 72,
  anchorTo: 'end',              // end-anchored behavior
  followOnAppend: true,         // follow new messages when at end
  overscan: 6,
  directDomUpdates: true,       // optional: reduce re-renders for scroll-only changes
  useFlushSync: false,          // React 19: avoid flushSync warnings if needed
});
Enter fullscreen mode Exit fullscreen mode

Notes:

  • anchorTo: 'end' tells the virtualizer to find a stable key to pin to. It then adjusts scrollOffset so that the same keyed item remains in view after data changes.
  • followOnAppend and scrollEndThreshold control whether new messages at the tail should auto‑scroll when the user is already at the end.
  • directDomUpdates can write transforms/top directly to DOM to avoid React re-renders for scroll-only updates; follow the adapter docs and don't toggle this flag at runtime.

3) Pre‑measure / measure‑then‑commit

Estimate drift is where virtualizers assume an estimated size and then later correct when the real size arrives — and that correction produces flicker. The cure is to pre‑measure (or aggressively measure offscreen) and commit items only once their true sizes are known.

Pattern:

  • When a new batch of items is added (prepend/append/stream chunk), render inert measurement nodes offscreen or in a hidden measurement pass.
  • Record heights in the measurement cache keyed by ID.
  • Once measurements for the affected items are available, call a small commit that inserts the real nodes into the virtualizer/DOM.

A sketch of a commit flow:

// pre-measure: mount hidden nodes (or reuse an offscreen measurer)
await premeasure(newItems) // fills measurementCache by id

// commit: update items array and tell virtualizer about new measurements
setItems(prev => prepend(prev, newItems))
virtualizer.setOptions({ initialMeasurementsCache: snapshotFromCache() })
// if using TanStack's measureElement API, call measureElement on the real nodes
Enter fullscreen mode Exit fullscreen mode

This avoids one‑frame estimate->actual corrections that cause jumps.

Practical integration (what I wire up in production)

In practice I combine three layers:

  • an anchor tracker that records the focused message key (or isAtEnd state),
  • a measurement cache Map keyed by ID that survives mounts and navigation,
  • a short commit step that only flips items into the visible list after measurements are recorded.

When history is requested (user scrolls to top), I premeasure the incoming older messages, then prepend them while preserving the anchored key. When a streaming message grows, end‑anchoring plus premeasured chunk growth keeps the bottom pinned.

React 19 and useFlushSync

React 19 changed some flushSync semantics and some libraries now warn if flushSync is used from certain lifecycles. @tanstack/react-virtual exposes useFlushSync: you can set useFlushSync: false to avoid React 19 warnings. The tradeoff is you may accept slightly more visual whitespace in extreme fast scrolls. Test on your target devices and set the flag consistently at mount.

Direct DOM updates and mobile caveats

directDomUpdates is powerful: it skips React re-renders for scroll-only writes and applies transforms/top directly. Important gotchas:

  • Follow the adapter requirements: item elements must be absolutely positioned and the virtualizer must own transform/top.
  • The library applies fixes to iOS momentum scrolling — direct writes during native momentum can cancel the gesture. TanStack now defers writes during touch/momentum windows to avoid that jank.

Pragmatic tips

  • Aggressive pre‑measure is worth the idle‑frame cost for chat UIs where flicker is unacceptable.
  • Persist measurement caches across remounts (virtualizer.takeSnapshot / initialMeasurementsCache) to keep history stable when navigating routes.
  • Keep caches keyed by ID and persist them while users scroll back through history.
  • If you have very large lists and find anchor lookup slow, maintain a Map for O(1) resolution during anchor restoration.

Final thoughts

If you're shipping real‑time feeds, this pattern moves you from occasional jumpy UX to pixel‑perfect scrolling: stable keys to survive prepends, an end‑anchored virtualizer to pin items by identity, and pre‑measure/measure‑then‑commit to eliminate estimate drift. With a few pragmatic choices (directDomUpdates, useFlushSync toggles, and measurement persistence) you can ship a robust chat experience that feels polished.

If you've solved this differently, I'd love to hear your approach — particularly if you solved it without a full pre‑measure step.

Top comments (0)