Inline cell editing for the Angular 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 { Component, signal } from '@angular/core'
import { DataGridComponent } from '@coreui/angular-data-grid'
import type { DataGridColumn, DataGridEditCommitEvent, DataGridItem } from '@coreui/angular-data-grid'
const roles = ['admin', 'editor', 'viewer']
@Component({
selector: 'docs-data-grid-editing-example',
imports: [DataGridComponent],
template: `
<c-data-grid
[columns]="columns"
[editing]="true"
[items]="items()"
[itemKey]="itemKey"
(editCommit)="onEditCommit($event)"
/>
`
})
export class DataGridEditingExample {
readonly columns: DataGridColumn[] = [
{ 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 }
}
]
readonly items = signal<DataGridItem[]>(
Array.from({ length: 200 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
age: 20 + (i % 40),
role: roles[i % roles.length]
}))
)
readonly itemKey = (item: DataGridItem) => String(item.id)
// The grid never mutates items - apply the committed change yourself.
onEditCommit({ item, columnId, value }: DataGridEditCommitEvent) {
this.items.update(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
cDataGridCellEditor template is itself the opt-in:
columns: DataGridColumn[] = [
{ key: 'name', editable: true }, // text input
{ key: 'age', editable: { type: 'number', min: 0 } }, // number input
{ key: 'role', editable: { type: 'select', options: ['admin', 'user'] } },
]<c-data-grid [columns]="columns" [items]="items" [editing]="true" />The app owns the data
The grid never mutates items. A commit emits editCommit with
{ item, columnId, value, previousValue } — apply the change to your state and
the grid re-renders (server-side, PATCH and refetch):
<c-data-grid [columns]="columns" [editing]="true" [items]="items()" (editCommit)="onEditCommit($event)" />onEditCommit({ item, columnId, value }: DataGridEditCommitEvent) {
this.items.update((current) =>
current.map((row) => (row['id'] === item['id'] ? { ...row, [columnId]: value } : row))
)
}editStart and editCancel 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 { Component, signal, viewChild } from '@angular/core'
import { DataGridComponent } from '@coreui/angular-data-grid'
import type {
DataGridColumn,
DataGridEditCommitEvent,
DataGridEditedCell,
DataGridItem
} from '@coreui/angular-data-grid'
const roles = ['admin', 'editor', 'viewer']
@Component({
selector: 'docs-data-grid-edit-tracking-example',
imports: [DataGridComponent],
template: `
<div class="mb-3">
<button type="button" class="btn btn-primary" (click)="save()">
Save changes ({{ editedCells().length }})
</button>
<span class="ms-2" role="status">{{ status() }}</span>
</div>
<c-data-grid
[columns]="columns"
[editing]="true"
[editTracking]="true"
[itemKey]="itemKey"
[items]="items()"
(editCommit)="onEditCommit($event)"
(editedChange)="onEditedChange($event.editedCells)"
/>
`
})
export class DataGridEditTrackingExample {
readonly grid = viewChild.required(DataGridComponent)
readonly editedCells = signal<DataGridEditedCell[]>([])
readonly status = signal('')
readonly columns: DataGridColumn[] = [
{ key: 'id', label: '#', width: 90 },
{ key: 'name', label: 'Name', editable: true },
{
key: 'role', label: 'Role', width: 130, editable: { type: 'select', options: roles }
}
]
readonly items = signal<DataGridItem[]>(
Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: `User ${i + 1}`,
role: roles[i % roles.length]
}))
)
readonly itemKey = (item: DataGridItem) => String(item.id)
onEditCommit({ item, columnId, value }: DataGridEditCommitEvent) {
this.items.update(current =>
current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
)
}
onEditedChange(cells: DataGridEditedCell[]) {
this.editedCells.set(cells)
if (cells.length > 0) {
this.status.set('')
}
}
save() {
if (this.editedCells().length > 0) {
this.grid().clearEdited()
this.status.set('Changes saved')
}
}
}The component exposes the grid API (viewChild(DataGridComponent)):
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. The
editedChange output carries { editedCells } 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.
Ready to save
import {
Component, afterRenderEffect, signal, untracked, viewChild
} from '@angular/core'
import { DataGridComponent } from '@coreui/angular-data-grid'
import type { DataGridColumn, DataGridEditCommitEvent, DataGridItem } from '@coreui/angular-data-grid'
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
})
@Component({
selector: 'docs-data-grid-validation-example',
imports: [DataGridComponent],
template: `
<p class="mb-3" role="status">{{ errors() === 0 ? 'Ready to save' : errors() + (errors() === 1 ? ' error' : ' errors') + ' to fix' }}</p>
<c-data-grid
[columns]="columns"
[editing]="true"
[itemKey]="itemKey"
[items]="items()"
[validate]="validate"
[virtualization]="false"
(editCommit)="onEditCommit($event)"
/>
`
})
export class DataGridValidationExample {
readonly grid = viewChild.required(DataGridComponent)
readonly errors = signal(0)
readonly validate = validate
readonly columns: DataGridColumn[] = [
{ key: 'task', label: 'Task', editable: true },
{
key: 'start', label: 'Start', width: 150, editable: true
},
{
key: 'end', label: 'End', width: 150, editable: true
}
]
readonly items = signal<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'
}
])
readonly itemKey = (item: DataGridItem) => String(item.id)
constructor() {
afterRenderEffect(() => {
this.items()
untracked(() => this.errors.set(this.grid().getInvalidCells('error').length))
})
}
onEditCommit({ item, columnId, value }: DataGridEditCommitEvent) {
this.items.update(current =>
current.map(row => (row.id === item.id ? { ...row, [columnId]: value } : row))
)
}
}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 the component check every row the
grid holds, not only the rendered ones, for example to disable a save button. Read
them in afterRenderEffect, as the example does, so the grid has rendered the new
items. 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.
Keep that state in a signal and read it inside validate, so the grid renders
again when it changes.
Custom editors
A cDataGridCellEditor template replaces the built-in input with your own UI.
It renders with the editing context; push draft values into the session — the
Enter/Tab/blur/outside commits pick up the latest one:
<c-data-grid [columns]="[{ key: 'note' }, ...]" [editing]="true" [items]="items()">
<ng-template cDataGridCellEditor="note" let-value="value" let-session="session">
<input
class="form-control form-control-sm"
[value]="value ?? ''"
(input)="session.setValue($any($event.target).value)"
/>
</ng-template>
</c-data-grid>The template context is { $implicit: item, column, value, invalid, labels, session };
session.setValue(value) updates the draft, session.commit(value) /
session.cancel() end the edit directly.
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
the template 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/angular-pro’s date range picker,
time picker and multi-select 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 (the cell and the popup layer). Keep the component’s overlay inside
the popup rather than portaling it to
body. - 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. The draft can be anything — a range picker
pushes
{ startDate, endDate }throughsession.setValue, a multi-select pushes an array;editCommitpasses it through untouched.
<ng-template cDataGridCellEditor="period" let-value="value" let-session="session">
<c-date-range-picker
[startDate]="value?.startDate"
[endDate]="value?.endDate"
(startDateChange)="session.setValue({ startDate: $event, endDate: value?.endDate })"
(endDateChange)="session.setValue({ startDate: value?.startDate, endDate: $event })"
/>
</ng-template>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.