React Data Grid Inline Editing

Inline editing

Inline cell editing for the React Data Grid — built-in text, number and select editors, validation, and a popup contract for rich editors like date pickers and multi-selects.

editing turns cells editable in place. Press Enter or F2 on the active cell — or double-click any cell — to start; Enter commits, Escape cancels, Tab commits and moves to the next editable cell. Editing builds on keyboard navigation, so editing enables cellNavigation automatically.

import { CDataGrid } from '@coreui/react-data-grid'
import { useState } from 'react'

const roles = ['admin', 'editor', 'viewer']

const initialItems = Array.from({ length: 200 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  age: 20 + (i % 40),
  role: roles[i % roles.length]
}))

export const DataGridEditingExample = () => {
  const [items, setItems] = useState(initialItems)

  return (
    <CDataGrid
      columns={[
        { key: 'id', label: '#', width: 90 },
        {
          key: 'name',
          label: 'Name',
          editable: true,
          editValidate: value => (value === '' ? 'Name is required' : true)
        },
        {
 key: 'age', label: 'Age', width: 110, editable: { type: 'number', min: 0, max: 120 }
},
        {
 key: 'role', label: 'Role', width: 130, editable: { type: 'select', options: roles }
}
      ]}
      editing
      items={items}
      itemKey={item => String(item.id)}
      onEditCommit={({ item, columnId, value }) =>
        // The grid never mutates items - apply the committed change yourself.
        setItems(current =>
          current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
        )
      }
    />
  )
}
import { CDataGrid } from '@coreui/react-data-grid'
import { useState } from 'react'

const roles = ['admin', 'editor', 'viewer']

const initialItems = Array.from({ length: 200 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  age: 20 + (i % 40),
  role: roles[i % roles.length]
}))

export const DataGridEditingExample = () => {
  const [items, setItems] = useState(initialItems)

  return (
    <CDataGrid
      columns={[
        { key: 'id', label: '#', width: 90 },
        {
          key: 'name',
          label: 'Name',
          editable: true,
          editValidate: value => (value === '' ? 'Name is required' : true)
        },
        {
 key: 'age', label: 'Age', width: 110, editable: { type: 'number', min: 0, max: 120 }
},
        {
 key: 'role', label: 'Role', width: 130, editable: { type: 'select', options: roles }
}
      ]}
      editing
      items={items}
      itemKey={item => String(item.id)}
      onEditCommit={({ item, columnId, value }) =>
        // The grid never mutates items - apply the committed change yourself.
        setItems(current =>
          current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
        )
      }
    />
  )
}

Usage

Editing is opt-in per column — editable picks a built-in editor, a custom editor component is itself the opt-in:

<CDataGrid
  editing
  columns={[
    { key: 'name', editable: true },                                   // text input
    { key: 'age', editable: { type: 'number', min: 0 } },              // number input
    { key: 'role', editable: { type: 'select', options: ['admin', 'user'] } },
  ]}
  items={items}
/>

The app owns the data

The grid never mutates items. A commit calls onEditCommit with { item, columnId, value, previousValue } — apply the change to your state and the grid re-renders (server-side, PATCH and refetch):

<CDataGrid
  editing
  columns={columns}
  items={items}
  onEditCommit={({ item, columnId, value }) =>
    setItems((current) =>
      current.map((row) => (row.id === item.id ? { ...row, [columnId]: value } : row))
    )
  }
/>

onEditStart and onEditCancel fire around it with (item, columnId).

Tracking edits

editTracking keeps the value each cell had before its first commit, keyed by itemKey. A cell whose value in items differs from it gets data-grid-cell-edited: a marker in its corner, and the description “Edited” for screen readers (labels.editedCell). The grid compares with the data you pass, so a commit you refuse or apply later, an undo, and an edit back to the old value all show the right state. Edit a name or a role below, then save.

import { CDataGrid, type CDataGridApi, type DataGridEditedCell } from '@coreui/react-data-grid'
import { useRef, useState } from 'react'

const roles = ['admin', 'editor', 'viewer']

const initialItems = Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  role: roles[i % roles.length]
}))

export const DataGridEditTrackingExample = () => {
  const gridRef = useRef<CDataGridApi>(null)
  const [items, setItems] = useState(initialItems)
  const [editedCells, setEditedCells] = useState<DataGridEditedCell[]>([])
  const [status, setStatus] = useState('')

  const save = () => {
    if (editedCells.length > 0) {
      gridRef.current?.clearEdited()
      setStatus('Changes saved')
    }
  }

  const onEditedChange = (cells: DataGridEditedCell[]) => {
    setEditedCells(cells)
    if (cells.length > 0) {
      setStatus('')
    }
  }

  return (
    <>
      <div className="mb-3">
        <button type="button" className="btn btn-primary" onClick={save}>
          Save changes ({editedCells.length})
        </button>
        <span className="ms-2" role="status">{status}</span>
      </div>
      <CDataGrid
        columns={[
          { key: 'id', label: '#', width: 90 },
          { key: 'name', label: 'Name', editable: true },
          {
            key: 'role', label: 'Role', width: 130, editable: { type: 'select', options: roles }
          }
        ]}
        editing
        editTracking
        gridRef={gridRef}
        itemKey={item => String(item.id)}
        items={items}
        onEditCommit={({ item, columnId, value }) =>
          setItems(current =>
            current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
          )
        }
        onEditedChange={onEditedChange}
      />
    </>
  )
}
import { CDataGrid, type CDataGridApi, type DataGridEditedCell } from '@coreui/react-data-grid'
import { useRef, useState } from 'react'

const roles = ['admin', 'editor', 'viewer']

const initialItems = Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  role: roles[i % roles.length]
}))

export const DataGridEditTrackingExample = () => {
  const gridRef = useRef<CDataGridApi>(null)
  const [items, setItems] = useState(initialItems)
  const [editedCells, setEditedCells] = useState<DataGridEditedCell[]>([])
  const [status, setStatus] = useState('')

  const save = () => {
    if (editedCells.length > 0) {
      gridRef.current?.clearEdited()
      setStatus('Changes saved')
    }
  }

  const onEditedChange = (cells: DataGridEditedCell[]) => {
    setEditedCells(cells)
    if (cells.length > 0) {
      setStatus('')
    }
  }

  return (
    <>
      <div className="mb-3">
        <button type="button" className="btn btn-primary" onClick={save}>
          Save changes ({editedCells.length})
        </button>
        <span className="ms-2" role="status">{status}</span>
      </div>
      <CDataGrid
        columns={[
          { key: 'id', label: '#', width: 90 },
          { key: 'name', label: 'Name', editable: true },
          {
            key: 'role', label: 'Role', width: 130, editable: { type: 'select', options: roles }
          }
        ]}
        editing
        editTracking
        gridRef={gridRef}
        itemKey={item => String(item.id)}
        items={items}
        onEditCommit={({ item, columnId, value }) =>
          setItems(current =>
            current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
          )
        }
        onEditedChange={onEditedChange}
      />
    </>
  )
}

Pass a ref as gridRef to reach the grid API: isDirty() says whether anything changed, getEditedCells() returns { rowId, columnId, item, value, originalValue } for each edited cell, and clearEdited() forgets the original values once you have saved. onEditedChange receives the edited cells whenever their set changes.

Without itemKey a row’s id is its position in items, so an insert or a removal would move a mark to another row; tracking stays off and logs an error. editEquals(value, originalValue) on a column decides what counts as a change, for values a new object replaces on every commit such as dates or arrays; the default is Object.is. The same comparison runs when an editor closes: a commit that keeps the value fires the cancel event instead of the commit, before editValidate, and adds nothing to the undo history, with or without tracking. To tint whole rows as well, return a class from rowClass for the rows listed in the edited cells.

Validation

editValidate gates the commit. Return true to accept, or a message to block it — the editor gets aria-invalid, the is-invalid class and an aria-errormessage pointing at the message:

{ key: 'name', editable: true, editValidate: (value, item) => value !== '' || 'Name is required' }

An invalid value keeps the editor open; Escape still cancels.

Validating the data

validate checks the data itself, not only the cell being edited. It receives a row and returns messages keyed by column: a string is an error, and { message, level: 'warning' } is a warning. Every rendered cell with an issue is marked, data loaded from the server included: an error gets data-grid-cell-invalid (a solid outline), and aria-invalid when cell navigation is on, a warning gets data-grid-cell-warning (a dashed outline), and both carry the message as a tooltip and as their description for screen readers. Because the function sees the whole row, one rule can compare fields, and fixing either field clears the mark.

import { CDataGrid, type CDataGridApi, type DataGridItem } from '@coreui/react-data-grid'
import { useEffect, useRef, useState } from 'react'

const initialItems: DataGridItem[] = [
  {
    id: 1, task: 'Plan the release', start: '2026-01-10', end: '2026-01-20'
  },
  {
    id: 2, task: '', start: '2026-02-10', end: '2026-02-01'
  },
  {
    id: 3, task: 'Ship', start: '2026-03-01', end: '2026-03-05'
  }
]

const validate = (item: DataGridItem) => ({
  task: item.task === '' ? 'Task is required' : undefined,
  start: String(item.start) > String(item.end) ? 'Start is after the end' : undefined,
  end: String(item.end).startsWith('2026-03') ? { message: 'Release month, check the freeze', level: 'warning' as const } : undefined
})

export const DataGridValidationExample = () => {
  const gridRef = useRef<CDataGridApi>(null)
  const [items, setItems] = useState(initialItems)
  const [errors, setErrors] = useState(0)

  useEffect(() => {
    setErrors(gridRef.current?.getInvalidCells('error').length ?? 0)
  }, [items])

  return (
    <>
      <p className="mb-3" role="status">{errors === 0 ? 'Ready to save' : `${errors} ${errors === 1 ? 'error' : 'errors'} to fix`}</p>
      <CDataGrid
        columns={[
          { key: 'task', label: 'Task', editable: true },
          {
 key: 'start', label: 'Start', width: 150, editable: true
},
          {
 key: 'end', label: 'End', width: 150, editable: true
}
        ]}
        editing
        gridRef={gridRef}
        itemKey={item => String(item.id)}
        items={items}
        onEditCommit={({ item, columnId, value }) =>
          setItems(current => current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row)))
        }
        validate={validate}
        virtualization={false}
      />
    </>
  )
}
import { CDataGrid, type CDataGridApi, type DataGridItem } from '@coreui/react-data-grid'
import { useEffect, useRef, useState } from 'react'

const initialItems: DataGridItem[] = [
  {
    id: 1, task: 'Plan the release', start: '2026-01-10', end: '2026-01-20'
  },
  {
    id: 2, task: '', start: '2026-02-10', end: '2026-02-01'
  },
  {
    id: 3, task: 'Ship', start: '2026-03-01', end: '2026-03-05'
  }
]

const validate = (item: DataGridItem) => ({
  task: item.task === '' ? 'Task is required' : undefined,
  start: String(item.start) > String(item.end) ? 'Start is after the end' : undefined,
  end: String(item.end).startsWith('2026-03') ? { message: 'Release month, check the freeze', level: 'warning' as const } : undefined
})

export const DataGridValidationExample = () => {
  const gridRef = useRef<CDataGridApi>(null)
  const [items, setItems] = useState(initialItems)
  const [errors, setErrors] = useState(0)

  useEffect(() => {
    setErrors(gridRef.current?.getInvalidCells('error').length ?? 0)
  }, [items])

  return (
    <>
      <p className="mb-3" role="status">{errors === 0 ? 'Ready to save' : `${errors} ${errors === 1 ? 'error' : 'errors'} to fix`}</p>
      <CDataGrid
        columns={[
          { key: 'task', label: 'Task', editable: true },
          {
 key: 'start', label: 'Start', width: 150, editable: true
},
          {
 key: 'end', label: 'End', width: 150, editable: true
}
        ]}
        editing
        gridRef={gridRef}
        itemKey={item => String(item.id)}
        items={items}
        onEditCommit={({ item, columnId, value }) =>
          setItems(current => current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row)))
        }
        validate={validate}
        virtualization={false}
      />
    </>
  )
}

When an edit commits, the grid validates the row as it will be after the change, after editValidate and only when the value changed, so a cell loaded with an error can still be left with Enter. An error on the edited column blocks the commit and shows the message in the editor, like editValidate. An error the commit causes on another column lets it through, marks that cell and is announced to screen readers; warnings never block.

isValid() and getInvalidCells(level?) on gridRef check every row the grid holds, not only the rendered ones, for example to disable a save button. Read them in an effect, as the example does: during your own render the grid has not seen the new items yet. With dataProvider they see the rows the grid holds: the current page, or every page loaded so far with infiniteScroll, never the rest of the server’s data. The grid keeps no state for this: errors your server returns can live in your own state and be read by validate. Key them to the value the server rejected (serverErrors[item.id]?.email === item.email ? 'Email is taken' : undefined), otherwise the old message blocks every new value the user types into that cell. When that state changes, pass a new validate function so the grid renders again.

Custom editors

editor replaces the built-in input with your own component. It renders with the editing context and registers the imperative bits of the contract through handleRef:

import type { CDataGridEditorProps } from '@coreui/react-data-grid'
import { useEffect, useRef } from 'react'

const NoteEditor = ({ value, handleRef }: CDataGridEditorProps) => {
  const inputRef = useRef<HTMLInputElement | null>(null)

  useEffect(() => {
    const input = inputRef.current
    handleRef({
      focus: () => input?.select(),
      getValue: () => input?.value,
    })
    return () => handleRef(null)
  }, [])

  return <input className="form-control form-control-sm" defaultValue={String(value ?? '')} ref={inputRef} />
}

// columns: [{ key: 'note', editor: NoteEditor }]

The component receives { item, column, value, invalid, labels, commit, cancel, handleRef }; the handle can provide getValue (feeds Enter/Tab/blur/outside commits), focus, and contains (extends the edit scope to overlays portaled outside the cell).

Rich editors — date pickers, autocompletes, multi-selects

Editors whose UI extends beyond the cell — a date range picker’s calendar, a multi-select’s listbox — set editorPopup: true on the column. The grid renders them in a .data-grid-editor-popup layer anchored to the cell (min-width = cell width), so a tall editor never reflows the row, and the cell keeps its content underneath.

Three rules make components like @coreui/react-pro’s CDateRangePicker, CTimePicker, CAutocomplete and CMultiSelect work as editors:

  • Commit on outside, not on blur alone. Picking a date in a calendar blurs the input mid-edit — so the grid commits on pointerdown outside the edit scope (cell + popup + anything contains() claims). Keep the component’s overlay inside the popup (its container prop), or claim a portaled overlay via the handle’s contains().
  • One Escape, one layer. An Escape the editor consumed (calling preventDefault() to close its own overlay) never cancels the edit — only the next, unconsumed Escape does.
  • Values are not scalars. getValue() can return anything — a range picker commits { startDate, endDate }, a multi-select commits an array; onEditCommit passes it through untouched.
const PeriodEditor = ({ value, handleRef }: CDataGridEditorProps) => {
  const range = useRef(value as { startDate?: string; endDate?: string } | undefined)

  useEffect(() => {
    handleRef({ getValue: () => range.current })
    return () => handleRef(null)
  }, [])

  return (
    <CDateRangePicker
      startDate={range.current?.startDate}
      endDate={range.current?.endDate}
      onStartDateChange={(startDate) => (range.current = { ...range.current, startDate })}
      onEndDateChange={(endDate) => (range.current = { ...range.current, endDate })}
    />
  )
}

// columns: [{ key: 'period', editor: PeriodEditor, editorPopup: true }]

Interaction details

  • Scrolling the editing row out of the virtualized window commits the draft; so do sort, filter and page transitions.
  • Replacing items and server-side data loads cancel an in-flight edit — the incoming data owns the cell.
  • Shift+Tab commits and moves to the previous editable cell.