Editing Events and Transactions

Understand the editing event flow from an editor request through onChangeValue validation and normalization, multi-column commits, and onChangeData notifications.

#onChangeValue#commit#onChangeData#transaction#validation
Last reviewed: 2026-08-21
GitHub
import * as React from 'react';
import { BGrid, type BGridColumn } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
  applyEditingDataChange,
  cloneEditingOrders,
  type EditingOrder,
  withEditingCellClasses,
} from './editing/shared';
import './EditingEventsExample.css';

export default function EditingEventsExample() {
  const [data, setData] = React.useState(cloneEditingOrders);
  const [events, setEvents] = React.useState<string[]>(['편집을 시작하면 이벤트가 여기에 기록됩니다.']);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const eventLogRef = React.useRef<HTMLOListElement>(null);
  const { width, height } = useContainerSize(containerRef);

  const appendEvent = React.useCallback((message: string) => {
    setEvents(current => [...current, message].slice(-20));
  }, []);

  React.useEffect(() => {
    const eventLog = eventLogRef.current;
    if (!eventLog) return;
    eventLog.scrollTo({ top: eventLog.scrollHeight });
  }, [events]);

  const columns = React.useMemo<BGridColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: '주문 코드', width: 145, editable: false },
      {
        key: 'quantity',
        label: '수량',
        width: 110,
        align: 'right',
        editable: true,
        editor: {
          type: 'text',
          inputProps: { inputMode: 'numeric' },
          parseValue: text => {
            const value = Number(text);
            if (!Number.isFinite(value) || value < 0) throw new Error('수량은 0 이상의 숫자여야 합니다.');
            return value;
          },
        },
        onChangeValue: async ({ changes, nextValues, commit }) => {
          appendEvent(`onChangeValue: 수량 ${nextValues.quantity}, 합계 재계산`);
          await commit([...changes, { key: 'amount', value: nextValues.quantity * nextValues.unitPrice }]);
        },
      },
      {
        key: 'unitPrice',
        label: '단가',
        width: 130,
        align: 'right',
        editable: true,
        itemRender: ({ value }) => <>{Number(value).toLocaleString()}원</>,
        editor: {
          type: 'text',
          inputProps: { inputMode: 'numeric' },
          formatValue: value => String(value ?? ''),
          parseValue: text => {
            const value = Number(text);
            if (!Number.isFinite(value) || value < 0) throw new Error('단가는 0 이상의 숫자여야 합니다.');
            return value;
          },
        },
        onChangeValue: async ({ changes, nextValues, commit }) => {
          appendEvent(`onChangeValue: 단가 ${nextValues.unitPrice}, 합계 재계산`);
          await commit([...changes, { key: 'amount', value: nextValues.quantity * nextValues.unitPrice }]);
        },
      },
      {
        key: 'amount',
        label: '합계 · 자동 변경',
        width: 170,
        align: 'right',
        editable: false,
        itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
      },
    ]),
    [appendEvent],
  );

  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'>
        <p className='m-0'>
          수량이나 단가를 바꾸면 <code>onChangeValue</code>가 제안 값을 검증하고 합계를 추가한 뒤 한 번의{' '}
          <code>commit(changes[])</code>으로 저장합니다.
        </p>
        <div className='editing-events-terminal'>
          <div className='editing-events-terminal-header' aria-hidden='true'>
            <span>EVENT LOG</span>
            <span>{events.length} entries</span>
          </div>
          <ol
            ref={eventLogRef}
            className='editing-events-log'
            role='log'
            aria-live='polite'
            aria-relevant='additions'
          >
            {events.map((event, index) => <li key={`${event}-${index}`}>{event}</li>)}
          </ol>
        </div>
      </div>
      <DataGridContainer ref={containerRef} style={{ height: 340 }}>
        <BGrid<EditingOrder>
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='id'
          editable
          variant='vertical-bordered'
          editTrigger='click'
          onChangeData={(sourceIndex, columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
            appendEvent(`onChangeData: source ${sourceIndex}, column ${columnIndex ?? 'multi'}, ${meta?.changes.length ?? 0}개 변경`);
          }}
        />
      </DataGridContainer>
    </div>
  );
}

Text editors, Select editors, external plugins, and lookup icons all use the same change transaction. Instead of implementing separate save logic for every editor, perform validation and related-cell updates once in the initiating column’s onChangeValue hook.

Event flow

text / plugin / editorIcon

 requestCommit(changes)

 column.onChangeValue

   commit(changes)

 data update → onChangeData → move and end session

If onChangeValue is not defined, the proposed changes are saved automatically. If you define the hook, it must finish by calling either commit() or cancel().

{
  key: 'quantity',
  editor: { type: 'text', parseValue: Number },
  onChangeValue: async ({ changes, nextValues, commit }) => {
    if (nextValues.quantity < 0) {
      throw new Error('Quantity must be at least 0.');
    }

    await commit([
      ...changes,
      {
        key: 'amount',
        value: nextValues.quantity * nextValues.unitPrice,
      },
    ]);
  },
}
  • changes: changes proposed by the editor or icon
  • values: canonical row values before the change
  • nextValues: immutable preview with only the proposed changes applied
  • rows: every row targeted by merge propagation, with each row’s nextValues
  • commit: saves the final list without calling onChangeValue again
  • cancel: discards the proposal

If the same target appears more than once, the last value wins. For nested data, specify the key as a path array such as { key: ['customer', 'code'], value }.

Completion notification

onChangeData={(sourceIndex, columnIndex, values, column, meta) => {
  // columnIndex and column are null when multiple columns change.
  console.log(meta?.source, meta?.changes);
  console.log(meta?.dataItem.status, meta?.dataItem.editedColumnIds, meta?.dataItem.changedKeys);
  console.log(meta?.transaction.sourceIndexes);
}}

onChangeData is called once for every row whose data is actually changed by the transaction. The existing four-argument callback remains supported; read the fifth meta argument only when you need multi-change or merged-range details. Along with the changed values, meta.dataItem includes the row status, the editedColumnIds of directly edited columns, and the changedKeys of changed data fields.

When using controlled data, save meta.dataItem rather than copying only values into a new object. This preserves the changed-cell indicators on the next render.

onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
  setData(current =>
    current.map((item, index) =>
      index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
    ),
  );
}}

Directly edited cells receive bgrid-cell-edited, while every cell that shares a changed data key receives bgrid-cell-value-changed. Customize these states with the --bgrid-cell-edited-* and --bgrid-cell-value-changed-* CSS variables, respectively.

Failure and asynchronous behavior

If a target column is missing or ambiguous, or if parseValue or onChangeValue validation fails, the entire change is canceled without a partial save. If the commit Promise rejects, text and plugin editors keep the current session open. When commit and cancel race within the same session, only the first final action to complete takes effect.