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로 초기값 대체) |
| 저장 + 아래 | Enter | commit(value, 'down') |
| 저장 + 위 | Shift+Enter | commit(value, 'up') |
| 저장 + 오른쪽 | Tab | commit(value, 'right') |
| 저장 + 왼쪽 | Shift+Tab | commit(value, 'left') |
| 제자리 저장 | 바깥 클릭(blur) | commit(value) (이동 없음) |
| 취소 | Esc | cancel() |
<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, 복사/잘라내기에서 셀 값을 문자열로 바꾸는
getCopyValue는 column.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에서 직접 실행.