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.

#cell-editing#editable#editTrigger#keyboard#IME#text-editor
Last reviewed: 2026-09-03
LIVE DEMOCell Editing interactive example
Loading the interactive demo…
GitHub
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>
  );
}

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.
  • Enter or F2: opens the editor while preserving the existing value.
  • Icon click: opens the editor when editorIcon.onClick is 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