Edits

Let people change a value in its cell. The table reports each edit, and the application writes it into its rows.

import { useTable } from "@umriss-ui/table";

An edit is reported, never applied: the application owns the rows. validate keeps the editor open with its message until the draft passes.

By default each cell is reported as it is left (onCellEdit) - for another cell, or by a click outside the table. editMode="row" opens a whole row as one draft and reports it only when it is saved (onRowSave); a row with a draft is left only by saving or discarding it (ADR-0036).

onRowAdd puts up "New row" - in the toolbar, or beneath the table without one - and onRowDelete a Delete that asks inside its row. Both work in either mode.

Examples

Edit a comments column

edit="text" lets a column edit; the table reports each edit through onCellEdit and the application writes it into its rows.

import { useState } from "react";
import { useTable } from "@umriss-ui/table";

interface Tour {
  id: string;
  driver: string;
  stops: number;
  comment: string;
}

const TOURS: Tour[] = [
  { id: "T-01", driver: "Martin Hale", stops: 11, comment: "" },
  { id: "T-02", driver: "Nadia Petrova", stops: 9, comment: "Van swapped at 10:40" },
  { id: "T-03", driver: "Owen Carter", stops: 5, comment: "" },
];

export default function CommentsColumn() {
  const [tours, setTours] = useState(TOURS);
  const { Table, Column } = useTable(tours, { rowKey: (t) => t.id });
  return (
    <Table
      grid
      ariaLabel="Today's tours"
      onCellEdit={({ rowKey, value }) => setTours((all) => all.map((t) => (t.id === rowKey ? { ...t, comment: String(value) } : t)))}
    >
      <Column value="id" label="Tour" rowHeader />
      <Column value="driver" label="Driver" />
      <Column value="stops" label="Stops" />
      <Column value="comment" label="Comment" edit="text" width={280} />
    </Table>
  );
}

Check an edit before it is reported

Each editing column gets the core field for its value; validate keeps the editor open with its message until the draft passes.

import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
import type { CellEdit } from "@umriss-ui/table";

type Role = "Developer" | "Designer" | "Product manager" | "QA engineer";

interface Person {
  id: string;
  name: string;
  team: string;
  capacity: number;
  role: Role;
  availableFrom: Date;
}

const PEOPLE: Person[] = [
  { id: "maya", name: "Maya Lindgren", team: "Web", capacity: 32, role: "Product manager", availableFrom: new Date(2026, 2, 2) },
  { id: "arjun", name: "Arjun Mehta", team: "Web", capacity: 40, role: "Developer", availableFrom: new Date(2026, 2, 23) },
  { id: "noah", name: "Noah Fischer", team: "Web", capacity: 24, role: "Designer", availableFrom: new Date(2026, 2, 16) },
  { id: "david", name: "David Kowalski", team: "Apps", capacity: 20, role: "QA engineer", availableFrom: new Date(2026, 2, 16) },
];

export default function ValidatedEdits() {
  const [people, setPeople] = useState(PEOPLE);
  const [last, setLast] = useState<CellEdit<Person> | null>(null);
  const { Table, Column } = useTable(people, { rowKey: (p) => p.id });

  const apply = (edit: CellEdit<Person>) => {
    setPeople((all) => all.map((p) => (p.id === edit.rowKey ? { ...p, [edit.columnId]: edit.value } : p)));
    setLast(edit);
  };

  return (
    <Stack gap={2}>
      <Table grid ariaLabel="Capacity" onCellEdit={apply}>
        <Column value="name" label="Person" rowHeader />
        <Column value="team" label="Team" />
        <Column
          value="capacity"
          label="Capacity"
          width={140}
          edit="number"
          validate={(value) =>
            value === null ? "A person needs a capacity" : value < 4 || value > 40 ? "Between 4 and 40 hours a week" : undefined
          }
        />
        <Column value="role" label="Role" edit="select" editOptions={["Developer", "Designer", "Product manager", "QA engineer"]} width={170} />
        <Column value="availableFrom" label="Available from" format="date" edit="date" width={160} />
      </Table>
      <Text size="xs" tone="muted">
        {last ? `Last edit: ${last.row.name}, ${last.columnId}` : "No edit yet."}
      </Text>
    </Stack>
  );
}

Save a row on purpose

editMode="row" opens every cell of a row that edits at once. Nothing is reported until Save or Enter; onRowSave hands over only the columns that changed.

import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";
import type { RowSave } from "@umriss-ui/table";

interface Project {
  id: string;
  name: string;
  phase: string;
  owner: string;
  days: number;
}

const PROJECTS: Project[] = [
  { id: "P-1", name: "Checkout redesign", phase: "Build", owner: "Ines Duarte", days: 40 },
  { id: "P-2", name: "Mobile app", phase: "Discovery", owner: "Tomasz Wrona", days: 15 },
  { id: "P-3", name: "Data warehouse", phase: "Launch", owner: "Amira Haddad", days: 60 },
];

export default function SaveARowOnPurpose() {
  const [projects, setProjects] = useState(PROJECTS);
  const [last, setLast] = useState<RowSave<Project> | null>(null);
  const { Table, Column } = useTable(projects, { rowKey: (p) => p.id });

  const save = (saved: RowSave<Project>) => {
    setProjects((all) => all.map((p) => (p.id === saved.rowKey ? { ...p, ...saved.changes } : p)));
    setLast(saved);
  };

  return (
    <Stack gap={2}>
      <Table grid editMode="row" ariaLabel="Projects" onRowSave={save}>
        <Column value="name" label="Project" rowHeader />
        <Column value="phase" label="Phase" edit="select" editOptions={["Discovery", "Build", "Launch"]} width={140} />
        <Column value="owner" label="Owner" edit="text" />
        <Column value="days" label="Budget (days)" edit="number" width={130} />
      </Table>
      <Text size="xs" tone="muted">
        {last ? `Saved ${last.row.name}: ${Object.keys(last.changes).join(", ")}` : "Nothing saved yet."}
      </Text>
    </Stack>
  );
}

Check a whole row before it is saved

On Save every open cell runs its validate; each message stands beneath its field, and the row stays open until all of them pass. A click into another row is refused while the draft is open.

import { useState } from "react";
import { useTable } from "@umriss-ui/table";

interface Offer {
  id: string;
  product: string;
  unit: string;
  price: number | null;
  minimum: number | null;
  maximum: number | null;
}

const OFFERS: Offer[] = [
  { id: "o1", product: "Printer paper A4", unit: "box", price: 24.5, minimum: 5, maximum: 200 },
  { id: "o2", product: "Toner cartridge", unit: "piece", price: 68, minimum: 1, maximum: 40 },
  { id: "o3", product: "Desk lamp", unit: "piece", price: 39.9, minimum: 2, maximum: 25 },
];

const required = (label: string) => (value: number | null) => (value === null ? `${label} is required` : undefined);

export default function CheckAWholeRow() {
  const [offers, setOffers] = useState(OFFERS);
  const { Table, Column } = useTable(offers, { rowKey: (o) => o.id });
  return (
    <Table
      grid
      editMode="row"
      ariaLabel="Price list"
      onRowSave={({ rowKey, changes }) => setOffers((all) => all.map((o) => (o.id === rowKey ? { ...o, ...changes } : o)))}
    >
      <Column value="product" label="Product" rowHeader />
      <Column value="unit" label="Unit" width={80} />
      <Column
        value="price"
        label="Price"
        edit="number"
        width={130}
        validate={(value) => (value === null ? "A price is required" : value < 0 ? "Not below 0" : undefined)}
      />
      <Column value="minimum" label="Minimum order" edit="number" width={150} validate={required("A minimum order")} />
      <Column value="maximum" label="Maximum order" edit="number" width={150} validate={required("A maximum order")} />
    </Table>
  );
}

Keep a list: add, change and delete rows

onRowAdd puts up "New row" in the toolbar - an empty row draft above the rows, whatever the sort; newRow gives its first values. onRowDelete adds a Delete that asks inside its row. The application owns the rows: it inserts, changes and removes them itself.

import { useState } from "react";
import { Search, Toolbar, useTable } from "@umriss-ui/table";

type Kind = "Spare part" | "Consumable" | "Tool";

interface Item {
  id: string;
  name: string;
  kind: Kind;
  stock: number | null;
  location: string;
}

const ITEMS: Item[] = [
  { id: "i1", name: "Burner nozzle 4 mm", kind: "Spare part", stock: 6, location: "Store A-3" },
  { id: "i2", name: "Refractory mortar", kind: "Consumable", stock: 14, location: "Yard" },
  { id: "i3", name: "Thermocouple type K", kind: "Spare part", stock: 3, location: "Store A-1" },
  { id: "i4", name: "Torque wrench 40-200 Nm", kind: "Tool", stock: 2, location: "Workshop" },
];

let next = ITEMS.length;

export default function KeepAList() {
  const [items, setItems] = useState(ITEMS);
  const { Table, Column } = useTable(items, { rowKey: (i) => i.id });
  return (
    <Table
      grid
      editMode="row"
      ariaLabel="Stores"
      newRow={() => ({ kind: "Spare part", stock: 0 })}
      onRowSave={({ rowKey, changes }) => setItems((all) => all.map((i) => (i.id === rowKey ? { ...i, ...changes } : i)))}
      onRowAdd={({ values }) => setItems((all) => [...all, { id: `i${++next}`, ...(values as Omit<Item, "id">) }])}
      onRowDelete={({ rowKey }) => setItems((all) => all.filter((i) => i.id !== rowKey))}
    >
      <Toolbar>
        <Search />
      </Toolbar>
      <Column value="name" label="Item" rowHeader edit="text" validate={(value) => (value ? undefined : "An item needs a name")} />
      <Column value="kind" label="Kind" edit="select" editOptions={["Spare part", "Consumable", "Tool"]} width={150} />
      <Column value="stock" label="In stock" edit="number" width={110} validate={(value) => (value === null || value < 0 ? "0 or more" : undefined)} />
      <Column value="location" label="Location" edit="text" width={150} />
    </Table>
  );
}

Edit a dense table

density="compact" holds for a row draft too: its fields and buttons are a step smaller, and a row with actions stands as tall as one without.

import { useState } from "react";
import { useTable } from "@umriss-ui/table";

interface Room {
  id: string;
  name: string;
  seats: number;
  floor: number;
}

const ROOMS: Room[] = [
  { id: "r1", name: "Harbour", seats: 8, floor: 2 },
  { id: "r2", name: "Orchard", seats: 12, floor: 3 },
  { id: "r3", name: "Lantern", seats: 4, floor: 3 },
];

export default function EditADenseTable() {
  const [rooms, setRooms] = useState(ROOMS);
  const { Table, Column } = useTable(rooms, { rowKey: (r) => r.id });
  return (
    <Table
      grid
      editMode="row"
      density="compact"
      ariaLabel="Meeting rooms"
      onRowSave={({ rowKey, changes }) => setRooms((all) => all.map((r) => (r.id === rowKey ? { ...r, ...changes } : r)))}
      onRowDelete={({ rowKey }) => setRooms((all) => all.filter((r) => r.id !== rowKey))}
    >
      <Column value="name" label="Room" rowHeader edit="text" />
      <Column value="seats" label="Seats" edit="number" width={120} />
      <Column value="floor" label="Floor" edit="number" width={120} />
    </Table>
  );
}

Keyboard

Key Action
Tab (while editing) Commits the edit and moves to the next cell that edits; in a row draft, walks its fields and buttons.
Enter (row draft) Saves the row.
Escape (row draft) Discards the row's changes.

Props on this page

From TableProps — the full table stands on First table.

NameTypeDefaultDescription
density"regular" | "compact"the density of the UmrissProvider, else "regular"How tightly the rows stand.
gridboolean—Grid mode: the table is one tab stop, and the arrows walk its cells - Home and End in the row, Ctrl+Home and Ctrl+End to the table's ends, PageUp and PageDown by the rows in view. A cell's own controls are reached with Enter or F2 and left with Escape; a column with edit is edited in place. Without it the table stays a native table.
editMode"cell" | "row"—Grid mode (ADR-0036): "cell" reports every cell as it is left (onCellEdit); "row" opens every cell of a row that edits at once, as one Row draft, and reports it only when the row is saved on purpose (onRowSave) - by its Save button or Enter; Escape or Discard drops it. A row with a draft is left only by saving or discarding it.
newRow() => Partial<Z>—What a new row starts with - the values the columns read before anything is typed. Without it, nothing.
childrenReactNode—Columns, RowDetail, RowActions and the unbound parts.

Events

NameTypeDefaultDescription
onCellEdit(edit: CellEdit<Z>) => void—Grid mode: an edit the user committed. The table applies nothing; the cell shows the new value when the rows passed in carry it.
onRowSave(save: RowSave<Z>) => void—editMode="row": a saved Row draft, with the columns it changed.
onRowAdd(add: RowAdd) => void—Grid mode: puts up a "New row" button - in the table toolbar, or beneath the table without one. It opens an empty Row draft above the rows; saved, it is reported whole. The row appears when the rows passed in carry it.
onRowDelete(remove: RowDelete<Z>) => void—Grid mode: a Delete button on every row, which asks inside its row before the delete is reported. The table removes nothing.

Accessibility

NameTypeDefaultDescription
ariaLabelstring—Accessible name of the table (not of the outer frame).

From TableOptions — the full table stands on First table.

NameTypeDefaultDescription
rowKeyrequired(row: Z) => string—A stable key per row – the basis of selection and expansion.

From FieldColumn — the full table stands on Column.

NameTypeDefaultDescription
valuerequiredK—A field of the row. Supplies the column's id at the same time.
formatFormatFor<Z[K]>—A standard presentation by name: "percent", "count" or { decimals } for numbers, "date", "time" or "dateTime" for points in time. Alignment, sorting, export and footer stay with the value.
editEditFor<Z[K], Z>—Edits the column's cells in place in grid mode: Enter, F2 or typing starts it, Enter or Tab commits, Escape cancels. The table applies nothing - the edit is reported through the table's onCellEdit. from EditOptions
editOptionsreadonly Present<Z[K]>[]the values that occurWhat edit="select" offers. from EditOptions
validate(value: Z[K] | null, row: Z) => string | null | undefined—Checks a draft before it is reported: a message keeps the editor open and stands beneath the field, as in a FormField; nothing lets it through. from EditOptions

From ColumnBase — the full table stands on Column.

NameTypeDefaultDescription
labelrequiredstring—Names the column in its header, in the column menu and in the header row of the export.
rowHeaderboolean—This column names the row: row header cells, never hideable, the column stickyRowHeader pins, and the name by which the row's selection, expander and actions are read out. At most one per table.
widthnumberthe browser'sInitial width in pixels.

From ToolbarProps — the full table stands on Toolbar.

NameTypeDefaultDescription
childrenReactNode—Search, column menu, export – on the left in the table toolbar, before the conditions.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/table

Getting started

Columns

Finding rows

Grouping

Rows

Around the table

Many rows

Editing

Limits and alarms

API index