Virtualization
수천 행·열을 windowing으로 렌더 — 보이는 범위만 DOM에 두고 나머지는 spacer로 오프셋합니다.
개요
가상화(windowing) 는 보이는 행/컬럼과 overscan만 DOM에 렌더하고, 그 위아래(또는 좌우)를 [data-virtual-spacer] 엘리먼트로 오프셋하는 방식입니다. 행은 normal flow를 유지하므로 sticky 헤더, pinned 컬럼, 컬럼 리사이즈가 그대로 작동합니다.
행 가상화(virtualization.rows)와 열 가상화(virtualization.columns)는 독립적으로 켤 수 있습니다. 1,000행 × 1,000열(100만 셀)도 부드럽게 스크롤됩니다.
가상화는 useDataGrid 옵션의 virtualization 필드 한 곳에서만 제어합니다. View state 페이지에 기본 예시가 있으며, 이 페이지는 각 옵션과 동작 세부사항을 다룹니다.
라이브 예시는 Storybook에서 확인할 수 있습니다.
행 가상화
virtualization: { rows: true } 한 줄이면 됩니다. DataGrid.Root에 maxHeight를 주면 내부 스크롤 컨테이너가 켜지고, Body가 row virtualizer를 통해 보이는 행만 렌더합니다.
const data = Array.from({ length: 1000 }, (_, i) => ({ id: String(i + 1), name: '채널 ' + (i + 1), followers: 30000 + ((i * 137) % 220) * 1000, })); const columns = [ { accessorKey: 'idx', header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>, size: 64, }, { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, { accessorKey: 'followers', header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>, }, ]; function VirtualizedRows() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, virtualization: { rows: true }, }); return ( <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<VirtualizedRows />);
estimateRowHeight
행 높이 추정치(px)입니다. 미지정 시 size prop을 기반으로 자동 결정됩니다(sm: 36, md: 44, lg: 52).
추정치는 spacer 높이 계산에 사용되어 스크롤 막대 비율을 결정합니다. auto-render 모드(children 없는 <DataGrid.Body>)에서는 각 행에 measureElement를 붙여 실측 높이로 보정하므로 추정치가 정확하지 않아도 스크롤이 결국 맞아갑니다. render-prop 모드의 주의사항은 아래를 참고하세요.
overscan
뷰포트 밖에 추가로 렌더할 행 수입니다. 기본값은 8입니다. overscan을 키우면 빠른 스크롤 시 빈 칸이 덜 보이는 대신 DOM 노드가 늘어납니다.
virtualization: {
rows: true,
estimateRowHeight: 44,
overscan: 8,
}spacer 메커니즘
Body는 렌더된 행 앞뒤에 [data-virtual-spacer="top"]·[data-virtual-spacer="bottom"] div를 삽입합니다. 이 spacer가 렌더되지 않은 행들의 높이를 대신해 전체 스크롤 높이를 유지합니다. 행 자체는 normal flow이므로 table 레이아웃(sticky/pinned/resize)이 깨지지 않습니다.
<!-- Body 내부 DOM 구조 (개략) -->
<div aria-hidden data-virtual-spacer="top" style="height: Xpx"></div>
<!-- 보이는 행들 -->
<div aria-hidden data-virtual-spacer="bottom" style="height: Ypx"></div>열 가상화
virtualization: { columns: true }를 추가하면 center(비-pinned) 컬럼도 windowing됩니다. 수백~수천 열에 유용합니다.
const data = Array.from({ length: 1000 }, (_, r) => { const row = { id: String(r + 1) }; for (let c = 0; c < 40; c++) row['m' + c] = ((r + c) * 7) % 100; return row; }); const columns = [ { id: 'idx', header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>, size: 64, }, ...Array.from({ length: 40 }, (_, c) => ({ accessorKey: 'm' + c, header: ({ header }) => <DataGrid.HeaderCell header={header}>{'지표 ' + (c + 1)}</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, size: 96, })), ]; function VirtualizedColumns() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, initialState: { columnPinning: { left: ['idx'] } }, virtualization: { rows: true, columns: true }, }); return ( <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<VirtualizedColumns />);
pinned 컬럼은 항상 렌더
columnPinning.left·columnPinning.right로 고정한 컬럼은 열 가상화에서 제외되어 항상 렌더됩니다. 가로 스크롤 중에도 pinned 컬럼은 화면에 고정되어 있어야 하기 때문입니다. 열 폭은 column.getSize()로 정확히 계산되므로 별도 추정치가 필요 없습니다.
자동 비활성 — 그룹 헤더 / 셀 병합
컬럼 가상화는 단일 레벨(flat) 헤더를 가정합니다. 아래 두 경우에는 window 경계에서 렌더가 깨지므로, 열 가상화가 자동으로 비활성되어 전체 렌더로 fallback합니다.
- 그룹(다단계) 헤더 —
table.getHeaderGroups().length > 1 colSpan/rowSpan셀 병합 — 어느 컬럼이든meta.colSpan또는meta.rowSpan함수가 정의된 경우
개발 모드에서는 비활성 사유(둘 중 해당하는 하나)를 담아 아래 형태로 1회 경고합니다.
[DataGrid] virtualization.columns가 비활성됐습니다 —
colSpan/rowSpan(셀 병합)과 병용 불가
(윈도우 경계 절단). 전체 렌더로 fallback합니다.가변 높이 caveat — render-prop 모드
<DataGrid.Body>가 auto-render 모드(children 없음)일 때는 각 행에 measureElement를 붙여 실측 높이로 보정합니다. function-children(render-prop) 모드에서는 소비자가 만든 노드라 measureElement를 붙일 수 없어, estimateRowHeight를 고정값으로 사용합니다.
const data = Array.from({ length: 1000 }, (_, i) => ({ id: String(i + 1), name: '채널 ' + (i + 1), note: '행 ' + (i + 1) + ' 의 상세 설명입니다. 멀티라인으로 줄바꿈되어 행 높이가 가변적으로 늘어납니다.', })); const columns = [ { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, size: 120, }, { accessorKey: 'note', header: ({ header }) => <DataGrid.HeaderCell header={header}>메모</DataGrid.HeaderCell>, cell: (info) => ( <DataGrid.Cell cell={info.cell} style={{ whiteSpace: 'normal' }}> {info.getValue()} </DataGrid.Cell> ), size: 280, }, ]; function RenderPropVirtualized() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, // render-prop + 가변 높이 + 가상화 → estimateRowHeight를 실제 높이에 맞춰 지정 virtualization: { rows: true, estimateRowHeight: 64 }, }); return ( <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}> <DataGrid.Header /> <DataGrid.Body> {(row, rowIndex) => ( <DataGrid.Row row={row} rowIndex={rowIndex}> {row.getVisibleCells().map((cell) => ( <React.Fragment key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </React.Fragment> ))} </DataGrid.Row> )} </DataGrid.Body> </DataGrid.Root> ); } render(<RenderPropVirtualized />);
추정치가 실제 높이와 어긋나면 스크롤 위치가 밀립니다. 멀티라인 셀 등 가변 높이 콘텐츠를 render-prop으로 그릴 때는 실제 행 높이에 맞는 estimateRowHeight를 반드시 지정하세요.
외부 스크롤 가상화
그리드를 자체 스크롤 박스가 아니라 바깥 컨테이너(페이지·ancestor) 의 스크롤에 얹어 행을 windowing합니다. 그리드가 페이지 흐름에 들어가는 "무한 피드"형 레이아웃에 씁니다.
virtualization.getScrollElement로 스크롤 주체가 될 element를 돌려주고, DataGrid.Root는 $css={{ overflow: 'visible' }}로 자체 스크롤을 풉니다. 그러면 헤더 sticky가 바깥 박스에 붙고, 그리드의 컨테이너 내 offset은 scrollMargin으로 자동 보정돼 첫 행이 헤더 바로 아래에 정렬됩니다.
function ExternalScrollGrid({ data, columns }) {
const [scrollBox, setScrollBox] = useState(null);
const table = useDataGrid({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
// 행 가상화의 스크롤 소스 = 바깥 Box. 안정적인 element를 반환한다.
virtualization: { rows: true, getScrollElement: () => scrollBox },
});
return (
<Box ref={setScrollBox} $css={{ maxHeight: '480px', overflow: 'auto' }}>
{/* Root는 자체 스크롤을 풀어 헤더 sticky가 바깥 Box에 붙는다. */}
<DataGrid.Root table={table} size="md" $css={{ overflow: 'visible' }}>
<DataGrid.Header />
<DataGrid.Body />
</DataGrid.Root>
</Box>
);
}- 미지정(기본) 이면 그리드 내부 스크롤 박스를 그대로 씁니다 — 기존 동작과 동일합니다.
- 상단 offset(페이지 nav 등)은 소비자 CSS 책임입니다.
- 열 가상화와 pinned 컬럼은 내부 박스 기준을 유지해 영향받지 않습니다.
이
getScrollElement(행 windowing의 스크롤 소스)와 아래getScrollContainer(활성 셀 추적의 스크롤 대상)는 다른 옵션입니다. 전자는useDataGrid({ virtualization })에, 후자는useDataGridScrollToActive에 전달합니다.
활성 셀 추적 — useDataGridScrollToActive
가상화가 켜진 상태에서 키보드로 화면 밖 셀로 이동하면, 해당 행이 아직 DOM에 없어 포커스가 사라진 것처럼 보일 수 있습니다. useDataGridScrollToActive를 추가하면 selection.focused가 바뀔 때마다 해당 셀을 뷰포트로 자동으로 끌어옵니다(Excel/Google Sheets 패턴).
const data = Array.from({ length: 1000 }, (_, i) => ({ id: String(i + 1), name: '채널 ' + (i + 1), followers: 30000 + ((i * 137) % 220) * 1000, })); const columns = [ { id: 'idx', header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>, size: 64, }, { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, }, { accessorKey: 'followers', header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>, }, ]; function ScrollToActiveGrid() { const table = useDataGrid({ data, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, virtualization: { rows: true }, }); const selection = useDataGridSelection(table); useDataGridScrollToActive(table, { selection }); // opt-in 한 줄 return ( <DataGrid.Root table={table} selection={selection} size="md" $css={{ maxHeight: '420px' }}> <DataGrid.Header /> <DataGrid.Body /> </DataGrid.Root> ); } render(<ScrollToActiveGrid />);
화면 밖 행/컬럼이 아직 DOM에 없으면, row/column virtualizer의 scrollToIndex로 먼저 렌더시킨 뒤, 다음 프레임에 pinned 컬럼·sticky 헤더 오프셋을 미세 보정합니다.
selection 주입은 필수입니다. 미전달 시 개발 모드에서 경고가 출력되고 훅은 no-op가 됩니다.
외부 스크롤 컨테이너
여기
getScrollContainer는 활성 셀 추적(scroll-to-active)이 스크롤할 대상을 찾는 옵션입니다. 행 가상화의 windowing 스크롤 소스를 외부로 옮기는getScrollElement와는 다른 옵션입니다.
그리드가 자체 overflow 대신 외부 박스 안에 임베드된 경우 getScrollContainer로 스크롤할 엘리먼트를 명시합니다. 미지정 시 auto-discovery 순서는 다음과 같습니다.
cellEl.closest('[data-scroll-container]')cellEl.closest('[data-grid]')— overflow가 scroll/auto일 때만- overflow: auto|scroll|overlay인 첫 번째 ancestor
document.scrollingElement(fallback)
useDataGridScrollToActive(table, {
selection,
getScrollContainer: (cellEl) => cellEl.closest('[data-scroll-container]'),
});
<Box data-scroll-container="" $css={{ maxHeight: '420px', overflow: 'auto' }}>
<DataGrid.Root table={table} selection={selection} size="md">
<DataGrid.Header />
<DataGrid.Body />
</DataGrid.Root>
</Box>UseDataGridScrollToActiveOptions
Prop
Type
DataGridVirtualization 옵션
useDataGrid({ virtualization: { ... } })에 전달하는 옵션입니다.
Prop
Type
다음 단계
- View state — skeleton·loading·error 뷰 상태와 가상화 기본 예시.
- Pinning — pinned 컬럼 설정. 열 가상화와 함께 쓸 때 항상 렌더되는 방식.
- Grouped headers — 그룹(다단) 헤더. 열 가상화와 병용 불가한 이유.
- Storybook 예시 — Overview, HeightAndOverscanTuning, ColumnVirtualization, ScrollToActive, RenderPropEstimate, EditableVirtualized, ExternalScrollVirtualization.