← Back to question bank
React AppSeniorHard#4027 · 55m

Implement virtualized rendering in a product component

Use virtualized rendering to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

Virtualization is the question that asks whether you can render a 100k-row list without paying for 100k DOM nodes. The mechanic is straightforward: compute the visible window from scrollTop and rowHeight, render only that slice with an absolute-positioned offset, and back the scrollbar with a tall spacer so scrollTop math still works. The interview detail is what separates "I read a tutorial" from "I shipped this" — preserving scroll position when the rows array changes.

Three correctness boundaries. The viewport math (start, end, offsetTop) must include a small buffer above and below so fast scrolling does not flash empty rows. The scrollbar height must match rowCount * rowHeight exactly so the user can reach the bottom. The anchor id must survive rows-array changes so filtering, sorting, or new data does not snap the user to the top — that is the surprise senior detail.

Adjacent traps: variable row heights (use a measured height map and a binary search, not the formula), using ResizeObserver per row (death by observers — one container observer is enough), and forgetting that the anchor must point at a stable row identity, not an index, because indices shift when data reorders. The reference uses fixed rowHeight to keep the math readable; the playground in a real codebase would extend with a measurer.

Reference Implementation: Windowed List With Anchor Restore

A VirtualList that renders a buffered slice and restores the scroll position to the same logical row when the rows prop changes.

// Virtualization renders only the rows in the current viewport. The
// interview detail is preserving scroll position on data swap — naive
// virtualizers reset to the top whenever the row array reference changes,
// which makes filtering and sorting feel broken.

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

type VirtualListProps = {
  rows: Row[];
  rowHeight: number;
  height: number;
};

export function VirtualList({ rows, rowHeight, height }: VirtualListProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = React.useState(0);
  // Anchor a logical row id while scrolling so a filter that drops earlier
  // rows does not snap the user to the wrong offset.
  const anchorIdRef = React.useRef<string | null>(null);

  const visibleCount = Math.ceil(height / rowHeight) + 4; // 2 above and below as buffer
  const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 2);
  const end = Math.min(rows.length, start + visibleCount);
  const slice = rows.slice(start, end);
  const offsetTop = start * rowHeight;

  // When the rows array changes, restore scroll to the anchor row if it
  // still exists. This is what differentiates senior from junior answers.
  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' }}
      onScroll={(event)=> {
        const top= event.currentTarget.scrollTop;
        setScrollTop(top);
        const anchorIndex= Math.floor(top / rowHeight);
        anchorIdRef.current= rows[anchorIndex]?.id ?? null;
      }}
      aria-label="Virtual list"
    >
      <div style={{ height: rows.length * rowHeight, position: 'relative' }}>
        <ul style={{ position: 'absolute', top: offsetTop, left: 0, right: 0, margin: 0 }}>
          {slice.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.

Window correctness
Render with 1000 rows and a 200px viewport at 20px per row; assert only ~14 list items are present (10 visible + 4 buffer). The DOM count is the test that proves virtualization is doing its job.
Scroll shifting
Scroll to a known offset; assert the rendered slice contains the rows that should be visible at that offset and not the rows from the start. Use scrollTop event firing in jsdom; for real browsers add a Playwright check.
Anchor restore
Scroll to row 100, mutate the rows prop to a filtered subset that still contains row 100, and assert the new scrollTop places row 100 in the viewport. Without the anchor, the user would be sent back to row 0 — the most reported virtualization bug.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

const rows = Array.from({ length: 1000 }, (_, index) => ({
  id: String(index),
  label: 'row-' + index,
}));

describe('VirtualList', () => {
  it('renders only the windowed slice plus a small buffer', () => {
    render(<VirtualList rows={rows} rowHeight={20} height={200} />);
    // Viewport holds 10 rows; with a buffer of 4, render around 14, never 1000.
    const items = screen.getAllByRole('listitem');
    expect(items.length).toBeLessThan(20);
    expect(items.length).toBeGreaterThan(8);
  });

  it('shifts the slice on scroll', () => {
    render(<VirtualList rows={rows} rowHeight={20} height={200} />);
    const container = screen.getByLabelText('Virtual list');
    fireEvent.scroll(container, { target: { scrollTop: 2000 } });
    // Rows around index 100 are now visible; row-0 is no longer rendered.
    expect(screen.queryByText('row-0')).toBeNull();
    expect(screen.getByText('row-100')).toBeInTheDocument();
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?