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).
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.
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 (itscontainerprop), or claim a portaled overlay via the handle’scontains(). - 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;onEditCommitpasses 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
itemsand 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.