Featuring Design System
Layout & Scale

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회 경고).

다음 단계

  • ArchitectureDataGridInstance·runtime registry·context 전파.
  • Cell mergingrowSpan/colSpan과 가상화의 상호작용.
  • Accessibility — skeleton aria-hidden·뷰 슬롯의 로케일 중립 정책.
  • ExtendinguseDataGridScrollToActive로 활성 셀 스크롤 보정.
  • 라이브 스토리 — loading/skeleton·empty·error 상태를 Storybook에서 직접 실행.