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.
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?