← Back to question bank
DebuggingSeniorHard#4028 · 35m

Debug virtualized rendering under interview pressure

A React screen using virtualized rendering passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.

Answer Strategy

The broken pattern in this question is "virtualizer that derives only from scrollTop". Filtering, sorting, or appending data changes the rows array reference; the virtualizer recomputes its window with the same scrollTop but the anchor is now pointing at a different logical row. The user perceives the "scroll back to top" symptom as a navigation bug, but the cause is missing identity tracking. The fix is anchoring on a stable row id and restoring scrollTop to that anchor in a useLayoutEffect when rows change.

Locate the boundary by asking "what does the user perceive as their position?". The answer is "the row I was looking at", not "the offset I had scrolled to". Track the visible-most row id as state during onScroll. When rows change, find the new index of that id and restore scrollTop. If the row was filtered out, fall back to the closest remaining ancestor or the previous sibling — but never reset to 0 silently.

Adjacent traps: tracking by index instead of id (indices shift when data reorders), restoring inside useEffect instead of useLayoutEffect (the user sees a one-frame jump as scrollTop snaps after paint), and forgetting that variable-height rows need a measured offset map (fixed rowHeight is the simple case; production virtualizers maintain a height index). The regression test asserts scrollTop survives a rows reference change.

Regression Fix: Id Anchor + useLayoutEffect Restore

The fixed VirtualList tracks the topmost visible row id and restores scrollTop to that row whenever the rows prop changes.

// THE BUG: the original virtualizer reset scrollTop to 0 every time the
// rows array reference changed (filter, sort, refresh). The user
// scrolled to row 800, applied a filter, and was thrown back to the
// top — perceived as a navigation bug, not a virtualization bug. The
// fix anchors a logical row id and restores scrollTop to that anchor
// in a useLayoutEffect when rows change.

type Row = { id: string; label: string };

export function VirtualList({
  rows,
  rowHeight,
  height,
}: {
  rows: Row[];
  rowHeight: number;
  height: number;
}) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const anchorIdRef = React.useRef<string | null>(null);
  const [scrollTop, setScrollTop] = React.useState(0);

  const visible = Math.ceil(height / rowHeight) + 4;
  const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
  const end = Math.min(rows.length, start + visible);

  React.useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container || anchorIdRef.current === null) return;
    const newIndex = rows.findIndex((row) => row.id === anchorIdRef.current);
    if (newIndex >= 0) container.scrollTop = newIndex * rowHeight;
  }, [rows, rowHeight]);

  return (
    <div
      ref={containerRef}
      style={{ height, overflowY: 'auto' }}
      aria-label="Virtual list"
      onScroll={(event)=> {
        const top= event.currentTarget.scrollTop;
        setScrollTop(top);
        anchorIdRef.current= rows[Math.floor(top / rowHeight)]?.id ?? null;
      }}
    >
      <div style={{ height: rows.length * rowHeight, position: 'relative' }}>
        <ul style={{ position: 'absolute', top: start * rowHeight, margin: 0 }}>
          {rows.slice(start, end).map((row) => (
            <li key={row.id} style={{ height: rowHeight }}>
              {row.label}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reproduce
Scroll to row 200, change the rows array (filter, sort, refresh) so the array reference changes but row 200 still exists. Assert the broken virtualizer resets to 0 and the fixed virtualizer keeps row 200 in view.
Patch
Track anchor id during onScroll, restore in useLayoutEffect when rows change. Test with both shrinking and growing data to confirm the anchor logic finds the new index correctly.
Prevent
Add a Playwright smoke test that scrolls a long list, applies a filter, and asserts the focus row stays in view. The pure-jsdom test catches the math; the browser test catches the rendered offset glitches.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

const make = (count: number, prefix = 'row') =>
  Array.from({ length: count }, (_, index) => ({
    id: prefix + '-' + index,
    label: prefix + '-' + index,
  }));

describe('VirtualList regression', () => {
  it('preserves scroll anchor when rows array reference changes', () => {
    const initial = make(1000);
    const { rerender } = render(
      <VirtualList rows={initial} rowHeight={20} height={200} />
    );
    const container = screen.getByLabelText('Virtual list');
    fireEvent.scroll(container, { target: { scrollTop: 4000 } }); // row 200

    // Filter the dataset — the anchor row (row-200) is still present.
    const filtered = initial.filter((row) => Number(row.id.split('-')[1]) >= 100);
    rerender(<VirtualList rows={filtered} rowHeight={20} height={200} />);

    // After the filter, scrollTop should land row-200 inside the viewport,
    // NOT reset to 0 like the broken virtualizer did.
    expect(container.scrollTop).toBeGreaterThan(0);
  });
});

Interviewer Signal

Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.

Constraints

  • State a hypothesis before changing code.
  • Name what evidence would confirm the bug.
  • Avoid broad rewrites unless the current API cannot express the behavior.

Model Answer Shape

  • Reproduce the failing sequence first.
  • Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
  • Patch the minimal broken boundary and add a regression test.

Tradeoffs

  • A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
  • Adding dependencies can silence lint warnings while still preserving the wrong ownership model.

Edge Cases

  • Double clicks and repeated submissions.
  • Slow network responses arriving out of order.
  • Component remount with stale persisted state.

Testing And Proof

  • Failing interaction sequence.
  • Out-of-order async response.
  • Unmount cleanup.

Follow-Ups

  • What would the code review comment say?
  • What metric or log would show this in production?