Row operations
Insert, duplicate, delete, and reorder rows through props, keyboard shortcuts, the context menu, and a marker drag.
Inserting, duplicating, deleting, and reordering whole rows. All four act on view-index
indices (the sorted/filtered positions the user sees), emit one onDataChange with
source: "row-operation" each, and work in both controlled and uncontrolled grids.
Search, sort, and filter act on the same view. See Sorting, filtering & search and the toolbar for the controls.
See the live examples: Row operations · Row markers · Drag & drop · Action buttons · Streaming
Enable props
| Prop | Enables | Without it |
|---|---|---|
createRow: (index: number) => TData | Insert row above/below | insertRow is a silent no-op with a dev-only warning. The context menu hides the two Insert items |
duplicateRow: (row: TData, index: number) => TData | Duplicate row(s) | duplicateRows is a silent no-op with a dev-only warning. The context menu hides Duplicate |
| (none) | Delete row(s) | Always available |
duplicateRow receives the source row and the data index the copy lands at, and must give the
copy a distinct id (a spread copy with a fresh id), so a duplicate never collides with its
source row's React key. createRow receives the data-space insertion index. Row operations apply
while an edit session is open: the operation lands and the open edit re-anchors to its row's new view
position, keeping the typed draft while the row stays in the rendered rows. An operation that
removes the edited row cancels the session, drops the draft, and fires onEditEnd with
result: "cancel".
Confirm a delete
onBeforeDeleteRows runs before every delete the grid starts: the Delete rows shortcut, the context menu item, actions.deleteRows, and actions.deleteRowsById. It gets rowIds, the ids of the rows that go, and hiddenCount, the number of hidden checked rows that stay. Return false, or a promise of false, to cancel. Without the prop a delete runs at once.
The demo confirms every delete:
Check some rows, then press Ctrl/Cmd+Shift+Backspace or right-click a cell and choose Delete rows. A dialog asks first. Cancel keeps the rows. Delete removes them.
The grid ships no dialog. Build one with your own dialog component and resolve a promise from its buttons:
const [ask, setAsk] = useState<{ count: number; hidden: number; answer: (ok: boolean) => void } | null>(null);
const confirmDelete = ({ rowIds, hiddenCount }: { rowIds: readonly string[]; hiddenCount: number }) =>
new Promise<boolean>((resolve) => setAsk({ count: rowIds.length, hidden: hiddenCount, answer: resolve }));
<>
<DataGridProvider onBeforeDeleteRows={confirmDelete} {...grid} columns={columns}>
{/* the grid parts are omitted */}
</DataGridProvider>
{ask && (
<ConfirmDialog
title={ask.count === 1 ? "Delete 1 row?" : `Delete ${ask.count} rows?`}
description={ask.hidden > 0 ? `${ask.hidden} hidden checked ${ask.hidden === 1 ? "row stays" : "rows stay"}.` : undefined}
onConfirm={() => { ask.answer(true); setAsk(null); }}
onCancel={() => { ask.answer(false); setAsk(null); }}
/>
)}
</>While a promise is pending, a second delete does nothing. When the promise resolves, the grid reads its state again. It drops a true result if readOnly turned on, or if the row set or the checks changed. A callback that throws or rejects cancels the delete and warns in development.
Settle the promise on every path, including a dismissed dialog. A promise that never settles blocks every later delete.
Clearing cell contents with Delete is not a row delete and does not call the callback. Undo restores a confirmed delete.
Default shortcuts
| Action | Default | Keymap action |
|---|---|---|
| Insert row above | Ctrl/Cmd+Shift+U | insertRowAbove |
| Insert row below | Ctrl/Cmd+Shift+F | insertRowBelow |
| Duplicate row(s) | Ctrl/Cmd+Shift+X | duplicateRow |
| Delete row(s) | Ctrl/Cmd+Shift+Backspace | deleteRows |
| Move row(s) up | Shift+Alt+PageUp | moveRowsUp |
| Move row(s) down | Shift+Alt+PageDown | moveRowsDown |
With keepCheckedRows, Duplicate and Delete act only on the checked rows while any row is checked. Hidden checked rows (keepHiddenCheckedRows) are never deleted, duplicated, or moved. See Checked rows.
Every binding lives in DEFAULT_KEYMAP, so it can be remapped or dropped through the keymap
prop like every other binding (see Keybindings).
Reorder
See the live example: Drag & drop reorder
Drag a row marker vertically to reorder rows. A thin line marks the drop target, and the row
moves on release. The gesture is zone-based. The press location decides the behavior, not later
movement. In the reorder-family marker modes (reorder, reorder-number, reorder-checkbox,
reorder-both), the drag handle zone, a 20px column at the start of the marker cell (the whole cell in
reorder mode), is the only reorder entry. A press there never changes the selection or the
checkbox, and a vertical drag that leaves the row reorders it. The number
and checkbox glyphs stay pure row-select surfaces: a press on them selects (click) or drags a
row range (drag), never reorders. Plain number, checkbox, and both markers are selection
surfaces across the cell. A selection drag can never accidentally reorder.
A press on the drag handle of a checked row drags every checked row. The rows land together at the drop line, in their current order. A press on an unchecked row drags that row alone and leaves the checks as they are. Every dragged row dims while the drag runs.
moveRowsUp and moveRowsDown move rows from the keyboard. They act on the checked rows. When no
row is checked, they act on the rows of the selected range, then on the active row. Each row moves
one place, and the gaps between the rows stay. The keys do nothing when one of the rows is already
at the edge. They need enableRowReorder and a reorder-family rowMarkers mode, and they
stay silent while an editor is open. Each press is one change and one undo step.
| Prop | Role | Default |
|---|---|---|
rowMarkers (reorder family) | supplies the drag handle zone | none has nothing to grab |
enableRowReorder | grid-wide switch | true |
The programmatic form is reorderRows(rows, to) from useDataGridActions. rows is one view
index or a list of view indices, and to is the final landing position of the first row
(arrayMove semantics: moving row 0 to index 3 shifts rows 1–3 up). A list moves as one block: the
rows keep their order and land next to each other, and duplicate indices count once.
moveRowsBy(rows, delta) shifts every listed row by delta and keeps the gaps.
Both emit one change with one row-id-based move operation per moved row, so undo/redo restores
the previous order in a single step. Row-channel selection remaps by row id (the moved rows keep
their selection state) and the active cell follows its row, and the open edit's editor does too.
A range stays selected when its rows stay next to each other, and collapses onto the active cell
otherwise. Each completed move is announced through the grid's aria-live region
(labels.markers.reorderAnnouncement for one row, labels.markers.reorderRowsAnnouncement for
several).
The move operations of one change describe a batch: from is the row's index before the
batch, and to is its index after the batch. Apply the operations together with applyChange.
A server that applies them one at a time ends with the wrong order.
reorderRows is a no-op in these situations
It returns false (dev-warning where the cause is a misconfiguration) in these situations:
enableRowReorderis off, or the grid isreadOnly- A sort or filter is active (the view order belongs to the sort/filter, not the data)
- A lazy grid has unloaded holes (a reorder shifts the lazy add-on's index bookkeeping)
- The drop leaves the rows where they already are
- A list is empty, or holds an index outside the grid
- For
moveRowsBy, a row would leave the grid
Position-based data goes stale
Search match counts and presence highlights in the view-index range form reference
positions, not row ids, so they go stale after a move (the same parity as insertRows).
Presence's rowId-native range form (Multiplayer presence) tracks
its rows through the move.
Context menu
The row-operation items ship with the context menu add-on, which installs a right-click menu on the grid's cell and header surfaces. Right-clicking a cell makes it the active cell with a single-cell selection before the menu opens, so the items always act on a known target.
The cell-surface items, in order:
- Cut / Copy / Paste: the native clipboard pipeline (see Clipboard).
- Clear contents: empties the selection to each type's
clearValue()(theclearContentsaction, Delete/Backspace). - Insert row above (Ctrl/Cmd+Shift+U) / Insert
row below (Ctrl/Cmd+Shift+F): anchored at the
right-clicked row. Hidden without
createRow. - Duplicate row(s): Ctrl/Cmd+Shift+X. Hidden
without
duplicateRow. - Delete row(s): Ctrl/Cmd+Shift+Backspace, destructive item.
The row-operation items act on every view row covered by the current selection, falling back
to the right-clicked row when nothing is selected, so a range selection duplicates or deletes all
of its rows at once. Every mutating item (everything but Copy) is disabled on a readOnly grid.
The add-on's wrapper resolves each surface's items through the getCellMenuItems and
getHeaderMenuItems resolvers, which receive the built-in set and return the complete item set.
See Context menu: custom menu items.
Row markers, the empty space below the last row, and the scrollbar gutter have no menu (and suppress the browser's). The header surface gets its own menu, documented on Columns.
Programmatic actions
import { useDataGridActions } from "@/components/data-grid/data-grid";
function MyToolbar() {
const actions = useDataGridActions();
return (
<button
onClick={() => {
// view-space indices: [0] is the top visible row
actions.insertRow(2, "below");
actions.insertRows(0, 5, "above"); // a row-count dialog is one gesture, one undo step
actions.duplicateRows([1, 2]);
actions.deleteRows([4]);
}}
>
{/* the button label is omitted */}
</button>
);
}insertRow(viewRowIndex, "above" | "below")inserts one row built bycreateRow.insertRows(viewRowIndex, count, "above" | "below")insertscountrows as ONE batch (oneonDataChange, one history entry) instead ofcountconsecutive gestures.createRowis called once per row with its landing data index. A no-op forcount <= 0.deleteRowsalready takes a range, so this is the batch counterpart of a range delete.duplicateRows(viewRowIndexes)inserts each copy directly after its source row (a duplicate of a range lands interleaved, each after its own source).deleteRows(viewRowIndexes)deletes the named rows in one batch.deleteRowsById(rowIds)deletes the rows with the given ids in one batch, for example the ids fromgetRowIds(). An id that matches no row is skipped.
Selection behavior: insert and duplicate keep the selection, offsetting it around the inserted
rows. Delete clears the entire selection and the active cell. All three fire onDataChange with
source: "row-operation" and row-id-based insert/delete operations, which is what undo/redo and export consume.
Row markers
A pinned-left marker column sits entirely outside the data column index space. It is separate
from the columns array, and reorder and resize do not affect it. Set rowMarkers on
DataGridProvider (or DataGrid). The default is "none":
Pick "both" and hover a row: the row number is replaced by a checkbox while hovered or selected.
<DataGridProvider rowMarkers="both" {...grid} columns={columns}>| Mode | Shows |
|---|---|
"none" | No marker column (default). |
"number" | The 1-based view row index, non-interactive. |
"checkbox" | A per-row checkbox that drives the rows selection channel, plus a select-all checkbox in the marker header. |
"both" | The row number, replaced by the checkbox on hover or when the row is selected (a group-hover pattern). The number is never gone. The checkbox only sits in front of it at the moment the checkbox is actionable. |
"reorder" | A drag handle. The whole cell is the drag handle zone that drag-reorders the row. No number or checkbox. |
"reorder-number" / "reorder-checkbox" / "reorder-both" | The drag handle zone (a 20px column with the drag handle) plus the number and/or checkbox content, which stay row-select surfaces. |
Only the reorder family arms the drag-to-reorder gesture. The plain modes are pure
row-select surfaces. Marker press and drag, in the plain modes, selects a contiguous multi-row
range that follows the pointer from the press row. Dragging back over selected rows de-selects
them (contrast the header gesture, whose column range only grows). See
Selection & keyboard. The aria-labels of the marker
column (markers.selectAll and markers.selectRow) are translatable through labels. See
i18n.
Try all four modes alongside every other toggle in the playground.
Custom markers
DataGridRoot and DataGrid accept two render slots that replace the marker column's content
while keeping its chrome and gestures:
renderMarkerreplaces the built-in row number/checkbox in every marker cell. It receives the row'srowId, the 0-basedviewRowIndex, and two independent selection signals:isRowChannelSelected(the rows channel only, so a cell click never flips it) andisCellSelected(the cell or column channel covers at least one cell of the row). A renderer can therefore tell "this row is selected" apart from "cells in this row are selected" and render them differently. Pass a function with a stable identity (useCallbackor a module-level function). A new function on every render re-renders every row.renderMarkerHeaderreplaces the select-all checkbox in the marker header. It receives theallSelectedstate ("checked","indeterminate","unchecked"). In"number"mode there is no select-all checkbox, so the renderer replaces the blank header cell instead.
The rowMarkers mode still sets the track width ("number" 44px, "checkbox" 36px, "both"
56px, "reorder" 32px, the reorder- family 48–64px), and "none" renders no marker column
at all, so neither renderer is ever called. The
cell's own press and drag row-selection gesture stays on the wrapper: with a custom renderer,
clicking or dragging a marker cell still selects rows.
import { Check, Circle, List, ListChecks, Minus } from "lucide-react";
import { useDataGridActions, type MarkerCellRenderer, type MarkerHeaderRenderer } from "@/components/data-grid/data-grid";
import { Button } from "@/components/ui/button";
const renderMarker: MarkerCellRenderer = ({ isRowChannelSelected, isCellSelected }) => (
<span className="flex size-5 items-center justify-center">
{isRowChannelSelected ? (
<Check className="size-3.5 text-muted-foreground" aria-hidden="true" />
) : isCellSelected ? (
<Minus className="size-3.5 text-muted-foreground" aria-hidden="true" />
) : (
<Circle className="size-3.5 text-muted-foreground" aria-hidden="true" />
)}
</span>
);
function SelectAllMarker({ allSelected }: { allSelected: "checked" | "indeterminate" | "unchecked" }) {
const { setAllRowsSelected } = useDataGridActions();
const checked = allSelected === "checked";
return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={checked ? "Unselect all rows" : "Select all rows"}
onClick={() => setAllRowsSelected(!checked)}
>
{checked ? <ListChecks aria-hidden="true" /> : <List aria-hidden="true" />}
</Button>
);
}
const renderMarkerHeader: MarkerHeaderRenderer = (ctx) => <SelectAllMarker {...ctx} />;Define both renderers at module scope (stable identity, the same rule as getRowClassName, see
Performance). A component-body function re-renders every
marker cell on every root update.
The built-in checkboxes carry the translatable labels.markers.* aria-labels. A custom renderer
with interactive content must supply its own accessible name on that content.
The marker has three states: row selected (check), only cells of the row selected (minus), or neither (circle). Pressing or dragging a marker cell selects the row; a plain cell click selects that cell and clears the row selection (Ctrl+click adds a range without clearing it).