Featuring Design System
Selection & Editing

Editing model

그리드는 편집 상태와 라우팅만 소유하고, 에디터 UI는 column.cell에 위임합니다 — 얇은 인프라 편집 모델.

편집은 누가 소유하는가

이 그리드의 편집은 얇은 인프라(thin-infra) 모델을 따릅니다. 그리드는 편집의 상태와 키/포인터 라우팅만 소유하고, 실제 에디터 UI(TextInput·NumberInput·Select·DatePicker)는 소유하지 않습니다. 에디터는 소비자의 column.cell이 그립니다.

그리드가 소유소비자가 소유
진입 트리거(더블클릭/F2/Enter/타이핑)·종료(commit/cancel)에디터 컴포넌트(<DataGrid.CellEditor> children)
commit 후 이동(Enter↓/Tab→)·undo/redo·클립보드데이터 state 갱신(onCellEdit)
편집 가능 게이팅(meta.editable/isCellEditable)표시 포맷·입력 파싱(parseValue/parse)

이 경계 덕분에 그리드는 "어떤 에디터를 쓸지"에 어떤 의견도 갖지 않습니다. 검증된 텍스트 입력이 필요하면 패키지 기본 <DataGrid.TextEditor>를 꽂고, 그 외에는 useDataGridCellEdit로 어떤 컴포넌트든 직접 만듭니다.

배선 — 세 개의 명시 DI

편집은 useDataGridEditing이 만든 인스턴스를 <DataGrid.Root>주입해야 동작합니다. commit 후 이동과 붙여넣기 타일링이 selection을 쓰므로, useDataGridSelection을 만들어 editing 옵션으로 명시 주입합니다(타입이 호출 순서를 강제).

아래 그리드는 셀을 더블클릭(또는 F2/Enter)하면 편집에 진입합니다. Enter는 저장 후 아래로, Tab은 오른쪽으로 이동하고 Esc는 취소합니다. onCellEdit가 로컬 useState 데이터를 갱신합니다.

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',
  meta: { editable: true },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{String(info.getValue() ?? '')}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
},
{
  accessorKey: 'followers',
  meta: { editable: true, parseValue: (raw) => Number(String(raw).replace(/[, ]/g, '')) || 0 },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{Number(info.getValue() ?? 0).toLocaleString()}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor parse={(raw) => Number(String(raw).replace(/[, ]/g, '')) || 0} />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
},
];

function EditableGrid() {
const [data, setData] = useState(initialData);
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
});
const selection = useDataGridSelection(table);
const editing = useDataGridEditing(table, {
  selection,
  // 동기 void 반환 → record-on-commit. 소비자가 data state 갱신을 책임진다.
  onCellEdit: ({ coord, newValue }) =>
    setData((rows) => rows.map((row) => (row.id === coord.rowId ? { ...row, [coord.columnId]: newValue } : row))),
});
return (
  <DataGrid.Root table={table} selection={selection} editing={editing} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<EditableGrid />);

editing을 Root에 주입하지 않으면 모든 편집 인터랙션이 조용히 no-op이 되고, 개발 모드에서 1회 경고합니다. selection을 editing 옵션에서 빠뜨리면 commit 이동·붙여넣기 타일링만 건너뛰며(셀 선택 없는 순수 편집 그리드는 정상 케이스), 실사용 시점에 1회 경고합니다.

컬럼을 편집 가능하게 — meta.editable

진입 트리거는 meta.editable === true인 컬럼에서만 발동합니다. 표시 안 한 컬럼은 read-only입니다.

const columns: ColumnDef<Row>[] = [
	{ accessorKey: 'name', meta: { editable: true } },
	{ accessorKey: 'createdAt' }, // editable 미표시 → read-only
];

셀 렌더 패턴 — display ↔ editor 분기

<DataGrid.CellDisplay>는 편집 중이 아닐 때만, <DataGrid.CellEditor>는 편집 중일 때만 children을 렌더합니다. CellEditor는 매 편집 세션마다 fresh mount되므로, 에디터의 useState 초기값을 한 줄로 안전하게 박을 수 있습니다.

{
	accessorKey: 'name',
	meta: { editable: true },
	cell: (info) => (
		<DataGrid.Cell cell={info.cell}>
			<DataGrid.CellDisplay>{String(info.getValue() ?? '')}</DataGrid.CellDisplay>
			<DataGrid.CellEditor>
				<DataGrid.TextEditor />
			</DataGrid.CellEditor>
		</DataGrid.Cell>
	),
}

숫자·날짜 컬럼은 표시 포맷과 입력 파싱을 분리합니다. 표시는 CellDisplay children에서, 입력 문자열 → 저장값 변환은 <DataGrid.TextEditor>parse로 합니다.

{
	accessorKey: 'price',
	meta: { editable: true, parseValue: (raw) => Number(raw) }, // paste/cut 경로의 파싱
	cell: (info) => (
		<DataGrid.Cell cell={info.cell}>
			<DataGrid.CellDisplay>{krw.format(info.getValue() as number)}</DataGrid.CellDisplay>
			<DataGrid.CellEditor>
				{/* 인라인 편집 경로의 파싱. throw하면 commit하지 않고 편집 유지 + aria-invalid. */}
				<DataGrid.TextEditor parse={(raw) => Number(raw.replace(/[, ₩]/g, ''))} />
			</DataGrid.CellEditor>
		</DataGrid.Cell>
	),
}

헤드리스 탈출구 — useDataGridCellEdit

<DataGrid.TextEditor>는 강제가 아니라 검증된 기본일 뿐입니다. native <select>, DS Select/DatePicker, Popover 호스팅 에디터 등 무엇이든 useDataGridCellEdit로 현재 셀의 편집 상태/액션을 읽어 직접 만듭니다. <DataGrid.CellEditor> children 안에서 인자 없이 호출하면 cell을 context에서 자동 조회합니다.

function CategoryEditor() {
	const { initialValue, commit, cancel } = useDataGridCellEdit<Category>();
	return (
		<select
			autoFocus
			defaultValue={initialValue}
			onChange={(e) => commit(e.target.value as Category, 'down')} // 선택 즉시 저장 + 아래 이동
			onBlur={(e) => commit(e.target.value as Category)} // 제자리 저장
			onKeyDown={(e) => {
				if (e.key === 'Escape') {
					e.stopPropagation();
					e.preventDefault();
					cancel();
				}
			}}
		>
			{options.map((o) => (
				<option key={o.value} value={o.value}>
					{o.label}
				</option>
			))}
		</select>
	);
}

useDataGridCellEdit가 돌려주는 표면입니다.

Prop

Type

트리거와 commit+이동

편집 진입과 종료의 키맵은 스프레드시트 표준을 따릅니다. <DataGrid.TextEditor>는 이 전부를 내장하고, 커스텀 에디터는 commit(value, moveTo)로 이동 방향을 직접 정합니다.

동작trigger / moveTo
진입더블클릭'doubleclick' (전체 선택)
진입F2 / Enter'F2' / 'Enter' (전체 선택)
진입그냥 타이핑'typing' (첫 글자가 prefillChar로 초기값 대체)
저장 + 아래Entercommit(value, 'down')
저장 + 위Shift+Entercommit(value, 'up')
저장 + 오른쪽Tabcommit(value, 'right')
저장 + 왼쪽Shift+Tabcommit(value, 'left')
제자리 저장바깥 클릭(blur)commit(value) (이동 없음)
취소Esccancel()

<DataGrid.TextEditor>는 줄바꿈(Alt/Cmd/Ctrl+Enter)과 IME 가드도 내장합니다 — 한글/일본어/중국어 조합 확정 Enter가 다음 셀로 새지 않습니다. 커스텀 에디터에서 같은 가드가 필요하면 nativeEvent.isComposing을 직접 확인하세요.

record-on-commit vs record-on-confirm

onCellEdit반환 타입이 그리드의 기록 시점을 결정합니다. 이것이 동기/비동기 저장의 유일한 분기점입니다.

  • void 반환 (동기)record-on-commit: commit 즉시 히스토리에 push. 로컬 state 편집의 기본 경로입니다.
  • Promise<void> 반환 (비동기)record-on-confirm: resolve돼야 히스토리에 push되고, reject되면 미기록 + 해당 셀 saveStatus='error'가 됩니다. 저장 중(pending)인 셀은 재편집이 차단됩니다.

record-on-confirm은 실패한 편집이 히스토리에 남지 않게 해 화면과 undo 히스토리의 정합을 지킵니다. 권장 패턴은 낙관적 갱신 + 실패 시 롤백입니다 — 먼저 화면에 반영하고, reject되면 이전 값으로 되돌립니다(React Query onError 정합).

const editing = useDataGridEditing(table, {
	selection,
	onCellEdit: async ({ coord, newValue, oldValue }) => {
		setData((prev) => writeCell(prev, coord, newValue)); // 낙관적 반영
		try {
			await api.save(coord, newValue);
		} catch (err) {
			setData((prev) => writeCell(prev, coord, oldValue)); // 실패 → 롤백
			toast.error('저장에 실패했습니다.'); // 실패 사유 표면화는 소비자 책임
			throw err; // reject → 그리드는 미기록 + saveStatus='error'
		}
	},
});

셀의 저장 상태는 editing.getCellSaveStatus(coord)(또는 useDataGridCellEdit().saveStatus)로, 전체에 진행 중 저장이 하나라도 있는지는 editing.isAnyPending으로 읽습니다. 그리드는 실패 사유·재시도 UI를 표면화하지 않습니다 — saveStatus='error'는 셀 강조용 신호로만 제공하고, 재시도·토스트는 소비자가 처리합니다.

Prop

Type

Undo / Redo

editing.undo() / editing.redo()로 편집을 되돌리고 다시 적용합니다. 한 그룹의 단위는 commit 1회 / paste 1회 / clearRange 1회이며, 한 그룹이 여러 셀 레코드를 담습니다(붙여넣기·범위 비우기는 한 번의 undo로 전체 복원). canUndo/canRedo로 버튼을 게이팅하고, historyLimit(기본 100)으로 보관 그룹 수를 제한합니다(초과 시 오래된 것부터 drop).

const editing = useDataGridEditing(table, { selection, onCellEdit, historyLimit: 50 });

<Button.Root disabled={!editing.canUndo} onClick={() => editing.undo()}>
	<Button.Text>실행취소</Button.Text>
</Button.Root>
<Button.Root disabled={!editing.canRedo} onClick={() => editing.redo()}>
	<Button.Text>다시실행</Button.Text>
</Button.Root>

undo의 되돌림 쓰기에는 아래 equals(no-op skip)를 적용하지 않습니다 — 되돌림은 항상 그대로 재방출돼 화면과 히스토리의 정합을 유지합니다. 비동기 저장이 진행 중(isAnyPending)이면 undo/redo는 차단됩니다.

정책 seam — 편집 동작을 좁히는 지점

useDataGridEditing의 옵션으로 편집 동작을 정밀하게 제어합니다.

Prop

Type

셀 단위 잠금 — isCellEditable

컬럼은 meta.editable로 막고, 같은 컬럼 안에서 특정 셀만 잠그려면 isCellEditable을 씁니다(제한 전용 — false를 돌려준 셀만 read-only). 컬럼 잠금이 우선이라, meta.editable: false면 이 술어는 호출되지 않습니다.

const editing = useDataGridEditing(table, {
	selection,
	onCellEdit,
	// inactive 행의 금액 셀만 잠근다 — 같은 행의 다른 셀은 편집 가능.
	isCellEditable: (coord) => {
		const row = data.find((r) => r.id === coord.rowId);
		if (row?.status !== 'inactive') return true;
		return coord.columnId !== 'price';
	},
});

no-op skip — equals

이전 값과 새 값이 "같다"고 판정된 셀은 히스토리와 onCellEdit에서 제외됩니다(불필요한 저장 방지). 기본은 Object.is이며, 객체 값이나 도메인 동등성에는 직접 주입합니다(AG Grid colDef.equals 패리티).

const editing = useDataGridEditing(table, {
	selection,
	onCellEdit,
	// 단가를 만원 단위로 같으면 변경으로 치지 않음(소액 차이는 no-op).
	equals: (oldV, newV, coord) => {
		if (coord.columnId === 'price' && typeof oldV === 'number' && typeof newV === 'number') {
			return Math.round(oldV / 10_000) === Math.round(newV / 10_000);
		}
		return Object.is(oldV, newV);
	},
});

파싱·복사 변환 — column.meta

붙여넣기/잘라내기에서 raw 문자열을 컬럼 값으로 바꾸는 parseValue, 복사/잘라내기에서 셀 값을 문자열로 바꾸는 getCopyValuecolumn.meta에 둡니다. 인라인 편집 경로의 파싱은 <DataGrid.TextEditor>parse가 따로 담당합니다.

{
	accessorKey: 'price',
	meta: {
		editable: true,
		parseValue: (raw) => Number(raw.replace(/[, ₩]/g, '')), // paste/cut: string → number
		getCopyValue: (info) => String(info.getValue()), // copy/cut: number → string
	},
}

대량 편집·붙여넣기 가로채기 — onBulkEdit / onPaste

onBulkEdit은 fill·paste·clear·commit·undo·redo를 셀별 onCellEdit 대신 한 콜백으로 받습니다 — 수만 셀 fill을 setData 한 번으로 처리해 행 재맵을 피합니다(정의되면 onCellEdit은 호출되지 않습니다). onPaste는 붙여넣기만 통째로 가로채 2D 문자열 매트릭스를 넘깁니다(이 경로는 그리드 히스토리를 거치지 않으니 undo는 소비자가 직접 관리).

<DataGrid.TextEditor> props

패키지 기본 인라인 텍스트 에디터입니다. native <textarea>를 확장하며, value/onChange/onKeyDown/onBlur는 내부 commit/cancel 라우팅이 소유합니다(override 불가). 단일행 입력·IME 가드·키 라우팅·시트식 grow가 내장됩니다.

Prop

Type

parse가 throw하면 잘못된 입력이 저장되지 않고 편집이 유지되며, aria-invalid="true"가 붙어 보조기술에 알립니다. 빈 셀로 시작하는 폼식 입력에는 commitOnBlur={false}로 "바깥 클릭=취소" 동작을 줄 수 있습니다.

<DataGrid.CellEditor>
	<DataGrid.TextEditor
		parse={(raw) => {
			const n = Number(raw);
			if (Number.isNaN(n)) throw new Error('숫자만 입력하세요'); // 편집 유지 + aria-invalid
			return n;
		}}
		commitOnBlur={false}
	/>
</DataGrid.CellEditor>

다음 단계

  • Clipboard & Fill — TSV 복사/붙여넣기·잘라내기 이동·자동 채우기(meta.fill/getFillValue).
  • Selection model — editing이 의존하는 anchor/range 선택 모델.
  • Extending — 모든 override seam과 veto 패턴 전수.
  • API reference — 공개 export 표.
  • 라이브 스토리 — 트리거·commit 이동·커스텀 에디터·undo/redo를 Storybook에서 직접 실행.