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/fillHandle은DataGridRootProps의 타입된 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로 엮입니다. useDataGridEditing에 selection을 넘기면 commit 후 active 이동
(moveTo)과 붙여넣기 타일링이 그 selection 인스턴스를 source로 씁니다. useDataGridFillHandle은 selection
(단일 range = 채우기 소스)과 editing(값 쓰기)을 잇는 브릿지라 둘 다 받습니다. 셀선택 없는 순수 편집 그리드는
정상 케이스이며, 이때 moveTo/타일링은 selection을 못 찾아 조용히 skip됩니다(dev에서 1회 경고).
Prop
Type
자동 렌더 vs render-prop — Header / Body
Header와 Body는 두 가지 모드로 동작합니다. 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를 못 붙여 추정 높이를 고정으로 씁니다. 이 경우useDataGrid의virtualization.estimateRowHeight를 정확히 지정하세요. 자세한 내용은 View state에서 다룹니다.
Cell / HeaderCell 유니온 — cell vs column
Cell과 HeaderCell은 discriminated union입니다. 정확히 하나의 prop을 받습니다.
cell/header(일반 모드) — TanStackCell/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 (반응형)
size는 ResponsiveValue<'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 / style의 state callback으로 줍니다. 각 컴포넌트는 자기
상태 객체를 콜백 인자로 넘깁니다(Cell → DataGridCellState, HeaderCell → DataGridHeaderCellState). 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>
DataGridCellState는isPinned·isLastPinned·isReadonly·isActive·isInRange·isEditing을,DataGridHeaderCellState는isPinned·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·가상화·
rowIndexcaveat 상세. - Extending —
onCellKeyDown/onCellPointerDownveto와 모든 override seam.