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로 스크롤할 엘리먼트를 명시합니다.
두 축의 주인은 다를 수 있습니다
먼저 알아둘 게 있습니다. DataGrid.Root의 기본값이 overflow: auto라, 그리드를 세로로 스크롤되는 박스에 넣으면
축이 갈립니다.
| 축 | 주인 | 이유 |
|---|---|---|
| 가로 | DataGrid.Root | 컬럼 총폭이 Root보다 넓으면 Root가 가로로 넘칩니다 |
| 세로 | 바깥 박스 | Root엔 높이 제약이 없어 세로로 넘칠 게 없습니다 |
예외적인 구성이 아닙니다. 패널·모달 본문·페이지에 임베드하면 자연히 이렇게 됩니다.
그래서 getScrollContainer는 축별 지정을 받습니다.
useDataGridScrollToActive(table, {
selection,
getScrollContainer: (cellEl) => ({
vertical: cellEl.closest<HTMLElement>('[data-scroll-container]'),
horizontal: cellEl.closest<HTMLElement>('[data-grid]'),
}),
});<Box data-scroll-container="" $css={{ maxHeight: '420px', overflow: 'auto' }}>
<DataGrid.Root table={table} selection={selection} size="md">
<DataGrid.Header />
<DataGrid.Body />
</DataGrid.Root>
</Box>엘리먼트 하나만 반환하면 두 축 공통으로 씁니다. 두 축을 같은 요소가 쥘 때 쓰세요.
// 바깥 박스가 두 축을 모두 쥐는 구성 (Root에 overflow: visible을 준 경우)
getScrollContainer: (cellEl) => cellEl.closest<HTMLElement>('[data-scroll-container]');한쪽 축만 담아 반환해도 됩니다. 빠진 축은 auto-discovery가 채웁니다. 축 값이 null일 때도 같습니다 —
closest()가 못 찾아 null이 나오면 그 축은 "스크롤 안 함"이 아니라 auto-discovery로 넘어갑니다.
활성 셀이 화면 밖으로 나가는데 그 축을 쥔 요소가 실제로 못 움직이면, 개발 모드에서 경고가 1회 출력됩니다. 축을 잘못 지정했을 때 조용히 아무 일도 안 일어나는 대신 알려줍니다.
auto-discovery 규칙
미지정 시 resolveScrollers가 축마다 따로 찾습니다. 각 축에 대해 위에서부터 먼저 맞는 것을 씁니다.
| 순위 | 후보 |
|---|---|
| 1 | [data-scroll-container] 마커 — 그 축으로 실제로 움직일 수 있을 때 |
| 2 | 그 축으로 실제로 움직일 수 있는 가장 가까운 조상 |
| 3 | 마커 — 그 축이 스크롤 가능하다고 선언돼 있을 때 |
| 4 | 선언된 가장 가까운 조상 |
| 5 | 못 찾으면 document.scrollingElement |
1·2가 3·4보다 앞인 이유는 "스크롤 되는 척하는 요소"를 거르기 위해서입니다. overflow: auto가 걸려 있어도
그 축으로 넘칠 내용이 없으면 스크롤해봐야 아무 일도 일어나지 않습니다.
3·4는 레이아웃이 없는 환경(jsdom 등)을 위한 겁니다. 거기선 크기가 전부 0이라 1·2가 아무도 고르지 못합니다. 측정으로 고르되, 아무도 여유가 없으면 선언으로 고릅니다.
UseDataGridScrollToActiveOptions
Prop
Type
ScrollContainerResolution
getScrollContainer의 반환 타입입니다.
type ScrollContainerResolution = HTMLElement | Partial<ResolvedScrollers>;
interface ResolvedScrollers {
vertical: HTMLElement | null;
horizontal: HTMLElement | null;
}| 반환값 | 해석 |
|---|---|
HTMLElement | 두 축 공통 |
{ vertical, horizontal } | 축별 지정 |
{ horizontal } 처럼 한쪽만 | 담긴 축만 지정, 나머지는 auto-discovery |
null | 전부 auto-discovery |
DataGridVirtualization 옵션
useDataGrid({ virtualization: { ... } })에 전달하는 옵션입니다.
Prop
Type
다음 단계
- View state — skeleton·loading·error 뷰 상태와 가상화 기본 예시.
- Pinning — pinned 컬럼 설정. 열 가상화와 함께 쓸 때 항상 렌더되는 방식.
- Grouped headers — 그룹(다단) 헤더. 열 가상화와 병용 불가한 이유.
- Storybook 예시 — Overview, HeightAndOverscanTuning, ColumnVirtualization, ScrollToActive, RenderPropEstimate, EditableVirtualized, ExternalScrollVirtualization.