gridcn1.3.0

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

PropEnablesWithout it
createRow: (index: number) => TDataInsert row above/belowinsertRow is a silent no-op with a dev-only warning. The context menu hides the two Insert items
duplicateRow: (row: TData, index: number) => TDataDuplicate 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.

Row markers
Name
Role
Score
Silent Tiger
Viewer
4
Quick Lion
Viewer
89
Swift Wolf
Manager
80
Eager Lion
User
51
Eager Tiger
Viewer
83
Silent Wolf
User
15
Swift Lion
Viewer
62
Eager Lion
Manager
42
Eager Eagle
Editor
35
Bold Tiger
Editor
52
Bold Bear
User
28
Swift Wolf
Manager
9
Swift Bear
Viewer
45
Eager Wolf
Editor
44
Swift Eagle
Viewer
51
Quick Lion
Manager
28
Bold Wolf
Editor
76
Swift Eagle
Admin
26
Eager Eagle
Manager
70
Eager Bear
Editor
67

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

ActionDefaultKeymap action
Insert row aboveCtrl/Cmd+Shift+UinsertRowAbove
Insert row belowCtrl/Cmd+Shift+FinsertRowBelow
Duplicate row(s)Ctrl/Cmd+Shift+XduplicateRow
Delete row(s)Ctrl/Cmd+Shift+BackspacedeleteRows
Move row(s) upShift+Alt+PageUpmoveRowsUp
Move row(s) downShift+Alt+PageDownmoveRowsDown

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.

PropRoleDefault
rowMarkers (reorder family)supplies the drag handle zonenone has nothing to grab
enableRowReordergrid-wide switchtrue

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:

  • enableRowReorder is off, or the grid is readOnly
  • 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:

  1. Cut / Copy / Paste: the native clipboard pipeline (see Clipboard).
  2. Clear contents: empties the selection to each type's clearValue() (the clearContents action, Delete/Backspace).
  3. Insert row above (Ctrl/Cmd+Shift+U) / Insert row below (Ctrl/Cmd+Shift+F): anchored at the right-clicked row. Hidden without createRow.
  4. Duplicate row(s): Ctrl/Cmd+Shift+X. Hidden without duplicateRow.
  5. 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 by createRow.
  • insertRows(viewRowIndex, count, "above" | "below") inserts count rows as ONE batch (one onDataChange, one history entry) instead of count consecutive gestures. createRow is called once per row with its landing data index. A no-op for count <= 0. deleteRows already 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 from getRowIds(). 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.

Row markers
Name
Role
Score
1
Silent Tiger
Viewer
4
2
Quick Lion
Viewer
89
3
Swift Wolf
Manager
80
4
Eager Lion
User
51
5
Eager Tiger
Viewer
83
6
Silent Wolf
User
15
7
Swift Lion
Viewer
62
8
Eager Lion
Manager
42
9
Eager Eagle
Editor
35
10
Bold Tiger
Editor
52
<DataGridProvider rowMarkers="both" {...grid} columns={columns}>
ModeShows
"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:

  • renderMarker replaces the built-in row number/checkbox in every marker cell. It receives the row's rowId, the 0-based viewRowIndex, and two independent selection signals: isRowChannelSelected (the rows channel only, so a cell click never flips it) and isCellSelected (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 (useCallback or a module-level function). A new function on every render re-renders every row.
  • renderMarkerHeader replaces the select-all checkbox in the marker header. It receives the allSelected state ("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).

Name
Score
Silent Tiger
4
Quick Lion
89
Swift Wolf
80
Eager Lion
51
Eager Tiger
83
Silent Wolf
15
Swift Lion
62
Eager Lion
42
Eager Eagle
35
Bold Tiger
52

On this page