Cell Editing
Learn the core cell-editing flow, from declaring editable cells and entering by pointer or keyboard to IME input, save, cancel, and movement.
import { t } from './i18n';
import * as React from 'react';
import { BGrid, BGridDataItemStatus, type BGridCellAddress, type BGridColumn, type BGridRef } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
applyEditingDataChange,
cloneEditingOrders,
type EditingOrder,
withEditingCellClasses,
} from './editing/shared';
export default function BasicEditingExample() {
const [data, setData] = React.useState(cloneEditingOrders);
const [activeCell, setActiveCell] = React.useState<BGridCellAddress | null>({ rowIndex: 0, columnIndex: 1 });
const [checkedRowKeys, setCheckedRowKeys] = React.useState<React.Key[]>([]);
const gridRef = React.useRef<BGridRef>(null);
const nextOrderNumber = React.useRef(5);
const pendingScrollRowKey = React.useRef<string | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
React.useEffect(() => {
const rowIndex = data.findIndex(item => item.values.id === pendingScrollRowKey.current);
if (rowIndex < 0) return;
gridRef.current?.scrollToRow(rowIndex, { align: 'end' });
pendingScrollRowKey.current = null;
}, [data]);
const addRow = () => {
const orderNumber = nextOrderNumber.current++;
const id = `ORDER-${String(orderNumber).padStart(3, '0')}`;
pendingScrollRowKey.current = id;
setData(current => [...current, {
status: BGridDataItemStatus.new,
values: {
id,
orderCode: `ORD-${2600 + orderNumber}`,
customerCode: '',
customerName: '',
customerGrade: '',
status: t('접수', 'Receipt'),
deliveryDate: '',
approved: false,
quantity: 0,
unitPrice: 0,
amount: 0,
note: '',
mergeGroup: id,
},
}]);
};
const deleteRows = () => {
const checkedKeys = new Set(checkedRowKeys);
const remaining = data.filter(item => !checkedKeys.has(item.values.id));
const activeRowKey = activeCell && data[activeCell.rowIndex]?.values.id;
const remainingActiveIndex = remaining.findIndex(item => item.values.id === activeRowKey);
setData(remaining);
setCheckedRowKeys([]);
setActiveCell(activeCell && remaining.length ? {
...activeCell,
rowIndex: remainingActiveIndex >= 0
? remainingActiveIndex
: Math.min(activeCell.rowIndex, remaining.length - 1),
} : null);
};
const columns = React.useMemo<BGridColumn<EditingOrder>[]>(
() => withEditingCellClasses<EditingOrder>([
{ key: 'orderCode', label: t('주문 코드', 'Order Code'), width: 150, editable: false },
{
key: 'customerName',
label: t('고객명 · 더블클릭', 'Customer Name · Double Click'),
width: 190,
editable: true,
editor: { type: 'text', inputProps: { maxLength: 50, autoComplete: 'off' } },
},
{
key: 'note',
label: t('메모 · 한 번 클릭', 'Note · Click Once'),
width: 210,
editable: true,
editTrigger: 'click',
editor: { type: 'text', inputProps: { maxLength: 80, autoComplete: 'off' } },
},
{ key: 'status', label: t('상태 · 읽기 전용', 'Status · Read Only'), width: 130, editable: false },
]),
[],
);
return (
<div className='flex min-h-0 flex-col gap-3'>
<div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
<strong>{t('마우스:', 'Mouse:')}</strong> {t('고객명은 Grid 기본값인 더블클릭, 메모는 컬럼의', 'Customer name is double-clicked as the Grid default, and memo is edited with the column\'s')} <code>editTrigger='click'</code>{t('으로', '')}
<br />
{t('편집합니다.', 'edit.')}
<br />
<strong>{t('키보드:', 'Keyboard:')}</strong> {t('방향키로 셀을 이동하고 바로 입력하면 기존 값을 대체합니다.', 'Moving cells with arrow keys and typing directly replaces existing values.')} <kbd>Enter</kbd> {t('또는', 'or')}{' '}
<kbd>F2</kbd>{t('는 기존 값을 유지하며 시작하고,', ' starts editing keeping the existing value,')} <kbd>Tab</kbd>{t('은 저장 후 이동,', ' saves and moves,')} <kbd>Escape</kbd>{t('는 취소합니다.', ' cancels.')}
<output aria-live='polite' className='mt-1 block font-mono text-xs text-blue-700'>
{activeCell
? `${t('활성 셀: 행', 'Active cell: Row')} ${activeCell.rowIndex + 1}, ${t('열', 'Col')} ${activeCell.columnIndex + 1}`
: t('활성 셀: 없음', 'Active cell: None')}
</output>
</div>
<div className='flex flex-wrap items-center gap-2'>
<button type='button' onClick={addRow} className='editing-example-row-button editing-example-row-button-add'>
<svg aria-hidden='true' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round'>
<path d='M12 5v14M5 12h14' />
</svg>
{t('행추가', 'Add Row')}
</button>
<button type='button' onClick={deleteRows} disabled={checkedRowKeys.length === 0} className='editing-example-row-button editing-example-row-button-delete'>
<svg aria-hidden='true' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='1.8' strokeLinecap='round' strokeLinejoin='round'>
<path d='M3 6h18M9 6V4h6v2M5 6l1 14h12l1-14M10 10v6M14 10v6' />
</svg>
{t('행삭제', 'Delete Rows')}
</button>
<output aria-live='polite' className='text-sm text-slate-600'>
{t(`선택 ${checkedRowKeys.length}개 / 전체 ${data.length}개`, `${checkedRowKeys.length} selected / ${data.length} total`)}
</output>
</div>
<DataGridContainer ref={containerRef} style={{ height: 340 }}>
<BGrid<EditingOrder>
ref={gridRef}
width={width}
height={height}
data={data}
columns={columns}
rowKey='id'
rowChecked={{
checkedRowKeys,
onChange: (_indexes, keys) => setCheckedRowKeys(keys),
}}
editable
variant='vertical-bordered'
editTrigger='dblclick'
showLineNumber
cellSelectionOptions={{ enabled: true }}
cellNavigationOptions={{
enabled: true,
editOnEnter: true,
activeCell,
onActiveCellChange: setActiveCell,
}}
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
}}
/>
</DataGridContainer>
</div>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}import { t } from '../i18n';
import type { BGridChangeDataMeta, BGridColumn, BGridDataItem } from 'beautiful-grid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: string;
status: string;
deliveryDate: string;
approved: boolean;
quantity: number;
unitPrice: number;
amount: number;
note: string;
mergeGroup: string;
}
export const editingOrders: BGridDataItem<EditingOrder>[] = [
{
values: {
id: 'ORDER-001',
orderCode: 'ORD-2601',
customerCode: 'C001',
customerName: t('서울상사', 'Seoul Sangsa'),
customerGrade: 'VIP',
status: t('접수', 'Receipt'),
deliveryDate: '2026-08-25',
approved: true,
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: t('오전 배송', 'Morning Delivery'),
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: t('서울상사', 'Seoul Sangsa'),
customerGrade: 'VIP',
status: t('진행', 'In Progress'),
deliveryDate: '2026-08-26',
approved: false,
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: t('담당자 확인', 'Assignee Confirmation'),
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: t('한빛물산', 'Hanbit Mulsan'),
customerGrade: t('우수', 'Excellent'),
status: t('완료', 'Completed'),
deliveryDate: '2026-08-28',
approved: true,
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: t('일반', 'General'),
status: t('접수', 'Receipt'),
deliveryDate: '2026-09-01',
approved: false,
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: t('영문 송장', 'English Invoice'),
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = (): BGridDataItem<EditingOrder>[] =>
editingOrders.map(item => ({
...item,
values: { ...item.values },
editedColumnIds: item.editedColumnIds ? [...item.editedColumnIds] : undefined,
changedKeys: item.changedKeys ? [...item.changedKeys] : undefined,
}));
export const applyEditingDataChange = <T,>(
current: BGridDataItem<T>[],
sourceIndex: number,
values: T,
meta?: BGridChangeDataMeta<T>,
): BGridDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: BGridColumn<T>[]): BGridColumn<T>[] =>
columns.map(column => ({
...column,
className: [
column.className,
column.editable === false ? 'editing-example-cell-readonly' : 'editing-example-cell-editable',
]
.filter(Boolean)
.join(' '),
}));.editing-example-row-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
padding: 6px 12px;
border: 1px solid var(--site-border-strong, #cbd5e1);
border-radius: var(--site-radius-sm, 8px);
background: var(--site-control-bg, #ffffff);
color: var(--site-text-secondary, #526178);
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
cursor: pointer;
transition: background-color 150ms, border-color 150ms, color 150ms;
}
.editing-example-row-button-add {
border-color: var(--site-accent, #2563eb);
background: var(--site-accent, #2563eb);
color: var(--site-accent-contrast, #ffffff);
}
.editing-example-row-button-add:hover {
border-color: var(--site-accent-strong, #1d4ed8);
background: var(--site-accent-strong, #1d4ed8);
}
.editing-example-row-button-delete:not(:disabled) {
border-color: #fca5a5;
color: #dc2626;
}
.editing-example-row-button-delete:hover:not(:disabled) {
border-color: #ef4444;
background: color-mix(in srgb, #ef4444 8%, var(--site-control-bg, #ffffff));
}
.editing-example-row-button:focus-visible {
outline: 2px solid var(--site-focus-ring, #93c5fd);
outline-offset: 2px;
}
.editing-example-row-button:disabled {
border-color: var(--site-border, #e1e7ef);
background: var(--site-surface, #f7f9fc);
color: var(--site-text-muted, #8491a5);
cursor: not-allowed;
}
.editing-example-cell-editable {
--editing-example-bg: #ffffff;
--editing-example-hover-bg: #dbeafe;
}
.editing-example-cell-readonly {
--editing-example-color: #525252;
--editing-example-bg: #f5f5f5;
--editing-example-hover-bg: #e5e5e5;
}
.bgrid-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.bgrid-body-table tr.bgrid-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}To enable cell editing, set editable on both the Grid and the target column, then define column.editor. This page covers the core flow from pointer activation through keyboard and IME input, saving, canceling, and navigation. Separate guides cover Select, lookup, and editing-event extensions.
1. Minimum setup
const columns: BGridColumn<Order>[] = [
{
key: 'customerName',
label: 'Customer name',
width: 180,
editable: true,
editor: { type: 'text' },
},
];
<BGrid<Order>
width={720}
height={360}
data={data}
columns={columns}
rowKey='id'
editable
/>
itemRender controls how a cell appears when it is not being edited, while editor provides the input UI during editing. You do not need an editor merely to change display formatting.
2. Grid defaults and column overrides
The default edit trigger is a double-click. Change the Grid-wide default with editTrigger, then override individual columns that should open immediately, such as a Select editor.
<BGrid editTrigger='dblclick' {...props} />
const columns: BGridColumn<Order>[] = [
{ key: 'name', editable: true, editor: { type: 'text' } },
{
key: 'status',
editable: true,
editTrigger: 'click',
editor: statusEditor,
},
];
The resolution order is column.editTrigger → grid.editTrigger → 'dblclick'. There is no editTrigger: 'none'. Use editable: false to make a cell read-only. Put button actions that are independent of cell editing in editorIcon.onClick or itemRender.
3. Start editing with the pointer or keyboard
- Cell click or double-click: opens the editor according to the configured
editTrigger. - Direct character input: opens a text editor and replaces the existing value.
EnterorF2: opens the editor while preserving the existing value.- Icon click: opens the editor when
editorIcon.onClickis not defined.
Cell focus and editor focus are separate states. Activate a cell first, then open its editor from the keyboard. When editing ends, the Grid returns focus to the active cell.
4. Keyboard behavior
| Key | Cell focused | Editing |
|---|---|---|
| Character input | Starts a text edit by replacing the existing value | Normal text input |
Enter / F2 |
Starts editing while preserving the existing value | Enter saves |
Tab / Shift+Tab |
Moves to the next/previous cell | Saves, then moves to the next/previous cell |
Escape |
Clears the selection range | Cancels changes and returns to the same cell |
| Arrow keys | Moves the active cell | Uses the input control’s default behavior |
Ctrl/Cmd+C, V |
Copies or pastes the selected range | Uses the input control’s default behavior |
The built-in text editor defaults startOnInput to true. Set startOnInput: false to prevent direct character input on a focused cell from opening the editor.
editor: {
type: 'text',
startOnInput: false,
}
5. IME and keyboard movement settings
During IME composition, such as Korean text input, the Grid does not save an incomplete string even if Enter or blur occurs first. It commits the final string only after composition ends. For external plugins, also verify the composition behavior of the UI component you use.
<BGrid
cellNavigationOptions={{
enabled: true,
editOnEnter: true,
wrap: false,
}}
/>
To control the active cell or review Home/End and PageUp/PageDown behavior, continue to Cell Focus and Keyboard Navigation.
6. Update application state
After the Grid saves a change internally, it calls onChangeData. Its first argument is the source index in the original data, even when sorting or filtering is active.
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
),
);
}}
The actual row data always lives in BGridDataItem<T>.values. Saving meta.dataItem also preserves the editedColumnIds for directly edited columns and the changedKeys for changed data fields. Directly edited cells receive the bgrid-cell-edited style, while all cells that share a changed key receive bgrid-cell-value-changed.
7. Add, select and delete rows, then scroll
Add Row appends a new row and scrolls it into view. Use the left checkboxes to select rows, then Delete Rows to remove just those rows. Deletion is disabled when nothing is selected. New rows use status: BGridDataItemStatus.new; deletion immediately updates the example’s React state without saving to a server.
The application owns additions and deletions through data, and explicitly requests scrolling through BGridRef.scrollToRow(). Ordinary data updates preserve the current scroll position. This example remembers the inserted row’s unique key and requests scrolling after the data update, so subsequent cell edits do not trigger another scroll.
import { useEffect, useRef, useState, type Key } from 'react';
import { BGrid, type BGridDataItem, type BGridRef } from 'beautiful-grid';
const gridRef = useRef<BGridRef>(null);
const pendingRowKey = useRef<string | null>(null);
const [checkedRowKeys, setCheckedRowKeys] = useState<Key[]>([]);
function addRow(item: BGridDataItem<Order>) {
pendingRowKey.current = item.values.id;
setData(current => [...current, item]);
}
useEffect(() => {
const rowIndex = data.findIndex(item => item.values.id === pendingRowKey.current);
if (rowIndex < 0) return;
gridRef.current?.scrollToRow(rowIndex, { align: 'end' });
pendingRowKey.current = null;
}, [data]);
function deleteCheckedRows() {
const keys = new Set(checkedRowKeys);
setData(current => current.filter(item => !keys.has(item.values.id)));
setCheckedRowKeys([]);
}
<BGrid<Order>
ref={gridRef}
width={720}
height={360}
data={data}
columns={columns}
rowKey='id'
editable
rowChecked={{
checkedRowKeys,
onChange: (_indexes, keys) => setCheckedRowKeys(keys),
}}
/>
The index in scrollToRow(rowIndex, { align }) is a zero-based index in the displayed, sorted/filtered data on the current page. It differs from the source index passed to onChangeData. With sorting or filtering, resolve the position in the displayed data; the API does not reveal filtered-out rows or load another page.
nearest(default): keep a visible row in place, otherwise scroll only as far as needed.start/center/end: align the row at the top / center / bottom of the scrollable area, clamped to the scroll limits.- Frozen rows, out-of-range indexes and non-integer indexes are ignored.
- Horizontal scroll, the active cell and selection are preserved. The last of consecutive requests wins.
- A call in the same event as a data update runs after the Grid synchronizes its data. For asynchronous data, call after the response arrives.
8. Choose the next guide
| Goal | Next guide |
|---|---|
| Use text, Select, and Date editors | Built-in Editors |
| Integrate Ant Design or another external UI library | External Editor Plugins |
| Show dropdown or search icons in idle cells | Editor Icons |
| Combine autocomplete input with a lookup modal | Lookup Editor |
| Validate changes and update related cells | Editing Events and Transactions |
| Edit merged cells across frozen boundaries | Merged Cell Editing |