View state
loading/error/empty는 Root prop이 아니라 useDataGrid 옵션 — skeleton·뷰 슬롯·레이아웃 무의견·가상화.
뷰 상태는 데이터 인스턴스에 속한다
loading·error·empty는 <DataGrid.Root>의 prop이 아니라 useDataGrid의 옵션입니다.
이 셋은 모두 데이터의 상태이지 컴포넌트 트리의 상태가 아니기 때문입니다. fetch 결과를 그대로 흘려
넣으면, 인스턴스가 그 상태를 들고 다니며 Body·Empty·Loading·Error가 알아서 분기합니다.
const table = useDataGrid<Row, FetchError>({
data: query.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
loading: query.isLoading, // undefined → false
error: query.error, // undefined → null
skeletonRows: 8, // 미지정 시 pagination.pageSize, 없으면 0
});empty는 옵션이 따로 없습니다 — 데이터 행이 0개 + loading 아님 + error 없음이면 그리드가 스스로
empty로 판정합니다.
Prop
Type
인스턴스는 이 값을 세 표면으로 다시 노출합니다.
interface DataGridInstance<TData, TError = unknown> extends Table<TData> {
loading: boolean; // = options.loading ?? false
error: TError | null; // = options.error ?? null
getSkeletonRowCount: () => number; // = skeletonRows ?? pagination.pageSize ?? 0
}getSkeletonRowCount()가 method인 이유는 pagination.pageSize가 런타임 상태라서입니다 — 페이지
크기를 바꾸면 skeleton 행 수도 따라옵니다. skeletonRows를 명시하면 그 값이 고정 우선합니다.
Skeleton — 컬럼이 자기 placeholder를 소유한다
<DataGrid.Body>는 loading이고 skeleton 행 수가 1 이상이면 children을 무시하고 placeholder
행을 자동으로 그립니다. 이때 각 셀은 그 컬럼의 meta.skeleton(info)을 호출합니다. 즉 skeleton의 모양은
셀의 모양과 똑같이 컬럼이 소유합니다 — 그리드는 행 골격만 깔고, 셀 내용은 위임합니다.
const columns: ColumnDef<Row>[] = [
{
accessorKey: 'name',
header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue() as string}</DataGrid.Cell>,
meta: {
// loading 시 cell 대신 호출. ctx = { table, column, rowIndex }
skeleton: ({ column, rowIndex }) => (
<DataGrid.Cell column={column}>
<Skeleton
$css={{ width: rowIndex % 2 === 0 ? '80%' : '60%', height: '0.75rem' }}
// rowIndex로 staggered animation — 행마다 시작점을 어긋나게
style={{ animationDelay: `${rowIndex * 60}ms` }}
/>
</DataGrid.Cell>
),
},
},
];skeleton 콜백 인자는 meta/cell 컨텍스트와 같은 결입니다.
interface DataGridSkeletonContext<TData, TValue> {
table: Table<TData>; // 인스턴스 — 분기/조회용
column: Column<TData, TValue>; // 이 컬럼 — <DataGrid.Cell column={column}>로 wrap 권장
rowIndex: number; // 0-based placeholder 행 인덱스 — staggered delay에 사용
}반환값은 <DataGrid.Cell column={column}>…</DataGrid.Cell>로 감싸길 권장합니다(sticky·pinned 레이아웃
보존). 어떤 컬럼이 meta.skeleton을 정의하지 않으면, 그리드가 그 자리에 빈 <DataGrid.Cell column={column}/>
를 fallback으로 깔아 가로 정렬과 고정 컬럼 정렬을 유지합니다. placeholder 행은 가짜 데이터이므로
aria-hidden으로 표시되어 보조기술이 읽지 않습니다.
skeleton의 너비/모양은 컬럼 데이터의 형태를 흉내 내는 것이 목적입니다. 숫자 컬럼은 짧게, 이름 컬럼은 길게, 태그 컬럼은 칩 형태로 —
column/rowIndex로 분기하세요. 모양 정책은 전적으로 소비자 몫입니다.
뷰 슬롯 — Empty · Loading · Error
세 슬롯은 <DataGrid.Root> 안에 두면 각자 조건에서만 렌더됩니다. 조건 분기는 그리드가 하고, 내용물은
소비자가 채웁니다.
const initialData = [ { id: '1', name: '김민준', followers: 128000 }, { id: '2', name: '이서연', followers: 84500 }, { id: '3', name: '박도윤', followers: 233000 }, { id: '4', name: '최하은', followers: 45200 }, { id: '5', name: '정시우', followers: 67800 }, ]; const columns = [ { accessorKey: 'name', header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>, meta: { skeleton: ({ column, rowIndex }) => ( <DataGrid.Cell column={column}> <Skeleton $css={{ width: rowIndex % 2 === 0 ? '80%' : '60%', height: '0.75rem' }} /> </DataGrid.Cell> ), }, }, { accessorKey: 'followers', header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>, cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>, meta: { skeleton: ({ column }) => ( <DataGrid.Cell column={column}> <Skeleton $css={{ width: '50%', height: '0.75rem' }} /> </DataGrid.Cell> ), }, }, ]; const VIEWS = [ { key: 'data', label: '데이터' }, { key: 'loading', label: '로딩' }, { key: 'empty', label: '빈 상태' }, { key: 'error', label: '에러' }, ]; function ViewStateGrid() { const [view, setView] = useState('data'); const table = useDataGrid({ data: view === 'data' ? initialData : [], columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, loading: view === 'loading', error: view === 'error' ? { message: '데이터를 불러오지 못했습니다.' } : null, skeletonRows: 5, }); return ( <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', gap: 8 }}> {VIEWS.map((v) => ( <Button.Root key={v.key} size="sm" variant={view === v.key ? 'primary' : 'secondary'} onClick={() => setView(v.key)} > <Button.Text>{v.label}</Button.Text> </Button.Root> ))} </div> <DataGrid.Root table={table}> <DataGrid.Header /> <DataGrid.Body /> <DataGrid.Loading $css={{ display: 'flex', justifyContent: 'center', padding: 'spacing-400' }}> <Loader /> </DataGrid.Loading> <DataGrid.Empty $css={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '8rem' }} > <Typo>표시할 데이터가 없습니다.</Typo> </DataGrid.Empty> <DataGrid.Error $css={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '8rem' }} > {({ error }) => <Typo>{error.message}</Typo>} </DataGrid.Error> </DataGrid.Root> </div> ); } render(<ViewStateGrid />);
<DataGrid.Body>가 loading 중 skeleton을 그리므로, <DataGrid.Loading>은 추가 표면입니다 — 둘은
배타적이지 않습니다. skeleton만으로 충분하면 Loading 슬롯을 생략해도 됩니다.
Error — 에러 객체로 분기
<DataGrid.Error>의 children은 ReactNode 또는 (state) => ReactNode 함수입니다. 함수형이면
state.error로 statusCode·message 등을 읽어 화면을 분기할 수 있습니다. retry 같은 액션은 별도 prop 없이
함수 내부 closure로 직접 호출합니다.
<DataGrid.Error<FetchError>>
{({ error }) =>
error.statusCode === 403 ? (
<Typo>이 데이터에 접근할 권한이 없습니다.</Typo>
) : (
<VStack $css={{ gap: 'spacing-200' }}>
<Typo>{error.message}</Typo>
<Button.Root onClick={() => query.refetch()}>
<Button.Text>다시 시도</Button.Text>
</Button.Root>
</VStack>
)
}
</DataGrid.Error>TError generic을 useDataGrid<Row, FetchError>에 주면 error가 그 타입으로 흘러 cast 없이
error.statusCode를 읽습니다.
레이아웃은 소비자의 의견이다
세 슬롯은 어떤 레이아웃도 강제하지 않습니다 — minHeight도, 가운데 정렬도 패키지가 넣지 않습니다.
빈/에러/로딩 화면이 어떻게 자리 잡을지는 디자인의 결정이므로 소비자에게 남깁니다. 슬롯은 안정적인 hook
앵커([data-empty-state]·[data-loading-state]·[data-error-state])만 노출합니다.
가운데 정렬이 필요하면 $css로 직접 줍니다.
<DataGrid.Empty
$css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '12rem', // skeleton → empty 전환 시 layout shifting 방지
}}
>
<Typo>표시할 데이터가 없습니다.</Typo>
</DataGrid.Empty>minHeight를 고정해 두면 skeleton(여러 행) → empty(빈 상태)로 바뀔 때 그리드 높이가 출렁이지 않습니다.
같은 맥락에서, 행마다 일관된 minHeight(size 토큰이 자동 부여)를 유지하는 것이 skeleton과 실데이터의
높이를 맞춰 layout shifting을 줄이는 핵심입니다.
가상화 — 보이는 행만 렌더
수천 행 데이터는 virtualization 옵션으로 윈도잉합니다. 보이는 행만 DOM에 그리고 위/아래를 spacer로
오프셋하므로, sticky 헤더와 pinned 컬럼이 그대로 작동합니다(행은 normal flow를 유지).
const table = useDataGrid<Row>({
data, // 수천 행
columns,
getCoreRowModel: getCoreRowModel(),
virtualization: {
rows: true,
estimateRowHeight: 44, // 가변 높이면 반드시 지정 (아래 caveat)
overscan: 8,
},
});Prop
Type
스크롤로 화면 밖에 나간 활성 셀을 다시 끌어오려면 useDataGridScrollToActive를 함께 씁니다 —
가상화가 켜진 인스턴스에서 scrollToIndex로 보정합니다.
가변 높이 caveat — render-prop에서는 추정치를 고정한다
<DataGrid.Body>가 auto-render 모드(children 없음)일 때는 각 행에 measureElement를 붙여 멀티라인
셀 같은 가변 높이를 실측 보정합니다. 하지만 function-children(render-prop) 모드에서는 소비자가 만든
노드라 measureElement를 붙일 수 없어 estimateRowHeight를 고정값으로 사용합니다.
{/* render-prop + 가변 높이 + 가상화 → estimateRowHeight를 반드시 정확히 */}
<DataGrid.Body>
{(row, rowIndex) => (
<DataGrid.Row row={row} rowIndex={rowIndex}>
{/* 멀티라인 셀 등 가변 높이 콘텐츠 */}
</DataGrid.Row>
)}
</DataGrid.Body>추정치가 실제 높이와 어긋나면 스크롤 위치가 밀립니다. function-children + 가변 높이 + 가상화를 함께 쓰면
실제 행 높이에 맞는 estimateRowHeight를 반드시 지정하세요. 행 가상화는 세로 병합(rowSpan)과 병용할 수
없어 rowSpan이 있으면 자동으로 전체 렌더로 fallback합니다(개발 모드에서 1회 경고).
다음 단계
- Architecture —
DataGridInstance·runtime registry·context 전파. - Cell merging —
rowSpan/colSpan과 가상화의 상호작용. - Accessibility — skeleton
aria-hidden·뷰 슬롯의 로케일 중립 정책. - Extending —
useDataGridScrollToActive로 활성 셀 스크롤 보정. - 라이브 스토리 — loading/skeleton·empty·error 상태를 Storybook에서 직접 실행.