Featuring Design System
Getting Started

Composition & DI

기능 훅을 Root에 주입하는 명시 DI 모델, 자동 렌더 vs render-prop, Cell/HeaderCell 유니온.

명시 DI — 왜 table에 붙이지 않고 prop으로 주입하나

그리드는 세 종류의 상호작용 인스턴스를 씁니다. 셀 선택(useDataGridSelection), 편집(useDataGridEditing), 자동 채우기(useDataGridFillHandle). 흔한 설계는 이 기능들을 table 인스턴스에 부착하는 것입니다 (table.enableSelection() 류). 이 그리드는 반대로, 각 훅이 만든 인스턴스를 <DataGrid.Root>에 prop으로 주입합니다.

이 결정에는 세 가지 이유가 있습니다.

  • 순서 무관 — 부착 방식은 "훅을 어떤 순서로, 어디서 호출했는가"가 동작에 영향을 줍니다. prop 주입은 훅 호출 순서와 무관합니다. 인스턴스를 만든 뒤 Root에 넘기기만 하면 됩니다.
  • 타입이 합성을 강제selection/editing/fillHandleDataGridRootProps의 타입된 prop입니다. 잘못된 객체를 넘기면 컴파일 타임에 잡힙니다. 부착은 런타임까지 미뤄집니다.
  • 조용한 no-op 방지 — 훅을 호출했지만 Root에 주입하지 않으면 기능이 동작하지 않습니다. 이 footgun을 막기 위해 각 훅은 개발 모드에서 "반환값이 Root에 소비되지 않았다"를 감지해 1회 경고합니다. Root가 자식 effect로 받은 인스턴스를 "소비됨"으로 마킹하고, 훅의 자기 effect(소비자 컴포넌트 = 부모라 Root effect 이후 실행)가 미마킹을 보면 경고합니다. production 빌드에서는 이 검사가 전부 제거됩니다.

필요한 기능만 만들면 됩니다. 편집 없는 읽기 전용 그리드는 useDataGridEditing을 호출하지 않고, 주입하지도 않습니다. 주입하지 않은 기능은 조용히 비활성됩니다.

3-hook 배선 — 전체 코드

useDataGrid로 인스턴스를 만들고, 같은 table을 기능 훅에 넘겨 각 인스턴스를 만든 뒤, 셋 다 Root에 주입합니다. row model 팩토리는 기본으로 깔리지 않으므로 getCoreRowModel()을 직접 넘깁니다.

import {
	useDataGrid,
	useDataGridSelection,
	useDataGridEditing,
	useDataGridFillHandle,
	useDataGridCellEdit,
	DataGrid,
	getCoreRowModel,
	type ColumnDef,
} from '@featuring-corp/data-grid';

type Row = { id: string; name: string; quota: number };

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>,
	},
	{
		accessorKey: 'quota',
		// editable: true 여야 dblclick/F2/Enter/typing이 편집 진입을 트리거한다.
		meta: { editable: true },
		header: ({ header }) => <DataGrid.HeaderCell header={header}>할당량</DataGrid.HeaderCell>,
		cell: (info) => (
			<DataGrid.Cell cell={info.cell}>
				<DataGrid.CellDisplay>{(info.getValue() as number).toLocaleString()}</DataGrid.CellDisplay>
				<DataGrid.CellEditor>
					<QuotaEditor cell={info.cell} />
				</DataGrid.CellEditor>
			</DataGrid.Cell>
		),
	},
];

// editor UI는 소비자 코드 — useDataGridCellEdit로 셀별 상태/액션을 읽는다.
function QuotaEditor({ cell }: { cell: any }) {
	const { initialValue, commit, cancel } = useDataGridCellEdit<number>(cell);
	// ... 실제 입력 UI. commit(Number(value), 'down') / cancel() 호출.
}

function Grid({ data, setData }: { data: Row[]; setData: (next: Row[]) => void }) {
	const table = useDataGrid<Row>({
		data,
		columns,
		getCoreRowModel: getCoreRowModel(),
		getRowId: (r) => r.id,
	});

	// 같은 table을 세 훅에 넘긴다. 호출 순서는 동작과 무관(명시 DI).
	const selection = useDataGridSelection(table);
	const editing = useDataGridEditing(table, {
		// selection 주입 — commit(moveTo)·paste 타일링이 이 인스턴스를 source로 쓴다.
		selection,
		onCellEdit: ({ coord, newValue }) => {
			setData(data.map((r) => (r.id === coord.rowId ? { ...r, [coord.columnId]: newValue } : r)));
		},
	});
	const fillHandle = useDataGridFillHandle(table, { selection, editing });

	// 셋 다 Root에 주입. 미주입 시 그 기능만 조용히 비활성(dev에서 1회 경고).
	return (
		<DataGrid.Root table={table} selection={selection} editing={editing} fillHandle={fillHandle}>
			<DataGrid.Header />
			<DataGrid.Body />
		</DataGrid.Root>
	);
}

기능 훅끼리도 명시 DI로 엮입니다. useDataGridEditingselection을 넘기면 commit 후 active 이동 (moveTo)과 붙여넣기 타일링이 그 selection 인스턴스를 source로 씁니다. useDataGridFillHandle은 selection (단일 range = 채우기 소스)과 editing(값 쓰기)을 잇는 브릿지라 둘 다 받습니다. 셀선택 없는 순수 편집 그리드는 정상 케이스이며, 이때 moveTo/타일링은 selection을 못 찾아 조용히 skip됩니다(dev에서 1회 경고).

Prop

Type

자동 렌더 vs render-prop — Header / Body

HeaderBody는 두 가지 모드로 동작합니다. children이 없으면 자동 렌더, 함수 children이면 render-prop입니다.

자동 렌더 (children 없음)

<DataGrid.Header />는 모든 헤더 그룹을, <DataGrid.Body />는 모든 행을 순회해 렌더합니다. 셀·헤더의 모양은 컬럼 정의의 column.cell / column.header가 책임지므로(<DataGrid.Cell> / <DataGrid.HeaderCell>로 wrap), 자동 렌더 모드에서도 셀 렌더는 전적으로 소비자 코드입니다. 대부분의 그리드는 이 모드로 충분합니다.

<DataGrid.Root table={table} selection={selection}>
	<DataGrid.Header /> {/* 모든 headerGroup 자동 */}
	<DataGrid.Body /> {/* 모든 row 자동 */}
</DataGrid.Root>

render-prop (함수 children)

행·셀을 직접 구성해야 할 때 함수 children으로 전환합니다. Header(headerGroup) => ReactNode, Body(row, rowIndex) => ReactNode를 받습니다. TData generic이 보존되는 escape hatch입니다.

<DataGrid.Root table={table} selection={selection}>
	<DataGrid.Header>
		{(headerGroup) => (
			<DataGrid.HeaderRow>
				{headerGroup.headers.map((header) => (
					<DataGrid.HeaderCell key={header.id} header={header}>
						{flexRender(header.column.columnDef.header, header.getContext())}
					</DataGrid.HeaderCell>
				))}
			</DataGrid.HeaderRow>
		)}
	</DataGrid.Header>

	<DataGrid.Body>
		{(row, rowIndex) => (
			<DataGrid.Row row={row} rowIndex={rowIndex}>
				{row.getVisibleCells().map((cell) => (
					<DataGrid.Cell key={cell.id} cell={cell}>
						{flexRender(cell.column.columnDef.cell, cell.getContext())}
					</DataGrid.Cell>
				))}
			</DataGrid.Row>
		)}
	</DataGrid.Body>
</DataGrid.Root>

rowIndex와 가상화 — aria-rowindex caveat

render-prop의 rowIndex는 보이는 행 목록 내 0-based 위치입니다. 이것을 <DataGrid.Row rowIndex={rowIndex}>로 넘기면 그리드가 aria-rowindex 절대 좌표를 헤더 행 수만큼 offset해 계산합니다(헤더도 grid 좌표에 참여).

비가상화 그리드에서는 rowIndex를 생략해도 브라우저가 DOM 순서로 위치를 산출하므로 무방합니다. 하지만 가상화에서는 보이는 행만 DOM에 존재하므로, rowIndex를 넘기지 않으면 화면 밖 행이 좌표에서 빠져 AT의 "row X of Y" 안내가 어긋납니다. 가상화와 render-prop을 함께 쓰면 rowIndex를 반드시 전달하세요.

가변 높이(멀티라인 셀) + 가상화 + render-prop 조합은 measureElement를 못 붙여 추정 높이를 고정으로 씁니다. 이 경우 useDataGridvirtualization.estimateRowHeight를 정확히 지정하세요. 자세한 내용은 View state에서 다룹니다.

Cell / HeaderCell 유니온 — cell vs column

CellHeaderCell은 discriminated union입니다. 정확히 하나의 prop을 받습니다.

  • cell / header (일반 모드) — TanStack Cell / Header 인스턴스를 받습니다. row가 있는 정상 데이터 렌더 경로입니다. 셀 선택·편집 상태, aria-colindex, 병합 좌표를 이 인스턴스에서 읽습니다.
  • column (skeleton/dynamic 모드) — cell/header 인스턴스 없이 Column 메타로만 렌더합니다. row 데이터가 아직 없는 상황(로딩 skeleton)이나 동적으로 셀을 그릴 때 씁니다. 컬럼 너비·pin·sticky 레이아웃은 유지되지만 data-row-id나 선택/편집 상태는 없습니다(skeleton은 가짜 placeholder).
// 일반 모드 — column.cell / column.header 안에서.
{
	accessorKey: 'name',
	header: ({ header }) => <DataGrid.HeaderCell header={header}>이름</DataGrid.HeaderCell>,
	cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue() as string}</DataGrid.Cell>,
}

// skeleton/dynamic 모드 — meta.skeleton 안에서 cell 인스턴스 없이.
{
	accessorKey: 'name',
	meta: {
		skeleton: ({ column }) => (
			<DataGrid.Cell column={column}>
				<Skeleton width="60%" />
			</DataGrid.Cell>
		),
	},
	// ...
}

meta.skeleton을 정의하지 않은 컬럼은 로딩 시 라이브러리가 빈 <DataGrid.Cell column={column} />을 fallback 으로 렌더해 sticky/pin 레이아웃을 보존합니다. skeleton 경로의 자세한 동작은 View state를 참고하세요.

공통 렌더 통합 — size · $css · render · state callback

모든 compound 컴포넌트는 컴포넌트 패키지와 동일한 렌더 통합(useRenderComponent)을 공유합니다. 네 가지 확장 지점이 일관되게 동작합니다.

size (반응형)

sizeResponsiveValue<'sm' | 'md' | 'lg'>입니다. Root에 주면 컨텍스트로 모든 셀·헤더에 전파됩니다. Cell/HeaderCell/Row는 자기 size prop으로 컨텍스트 값을 override할 수 있습니다.

// Root에 반응형 — mobile은 sm, desktop부터 md.
<DataGrid.Root table={table} size={{ mobile: 'sm', desktop: 'md' }}>
	{/* 특정 헤더만 lg로 override */}
	<DataGrid.HeaderCell header={header} size="lg">제목</DataGrid.HeaderCell>
</DataGrid.Root>

$css (atomic 토큰)

$css는 rainbow-sprinkles 기반 atomic 스타일입니다. 정적 디자인 토큰 값을 받습니다. 콜백·상태·중첩 셀렉터는 받지 않습니다.

<DataGrid.Cell cell={info.cell} $css={{ textAlign: 'right', color: '$text-2' }}>
	{value}
</DataGrid.Cell>

render (polymorphic)

render는 base-ui의 RenderProp으로, 렌더되는 엘리먼트를 교체합니다(as prop 대체). 내부 props·ref·data-* 상태는 그대로 병합됩니다.

// Cell을 button으로 렌더 — grid 상태/접근성 속성은 유지된 채 엘리먼트만 교체.
<DataGrid.Cell cell={info.cell} render={<button type="button" />}>
	{value}
</DataGrid.Cell>

className / style state callback

상태 기반 스타일은 $css가 아니라 className / stylestate callback으로 줍니다. 각 컴포넌트는 자기 상태 객체를 콜백 인자로 넘깁니다(CellDataGridCellState, HeaderCellDataGridHeaderCellState). atomic $css는 콜백을 받지 않으므로, 활성/범위/정렬 같은 상태에 반응하려면 state callback이나 data-* CSS 셀렉터를 씁니다.

<DataGrid.Cell
	cell={info.cell}
	className={(state) => (state.isActive ? 'cell--active' : '')}
	style={(state) => ({ fontWeight: state.isInRange ? 600 : 400 })}
>
	{value}
</DataGrid.Cell>

DataGridCellStateisPinned · isLastPinned · isReadonly · isActive · isInRange · isEditing을, DataGridHeaderCellStateisPinned · isLastPinned · isSorted · isSortable · isResizable · isResizing을 노출합니다. 같은 상태가 data-* 속성으로도 노출되므로 CSS만으로도 잡을 수 있습니다. (Architecture의 data-* 상태 표면 참고)

다음 단계

  • Selection model — anchor/range 다중 사각형·정책 seam·controlled.
  • Editing model — 얇은 인프라 에디터·record-on-commit/confirm·undo/redo.
  • View state — skeleton·가상화·rowIndex caveat 상세.
  • ExtendingonCellKeyDown/onCellPointerDown veto와 모든 override seam.