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.
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
| 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. |
From TableProps — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
density | "regular" | "compact" | the density of the UmrissProvider, else "regular" | How tightly the rows stand. |
grid | boolean | — | 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. |
children | ReactNode | — | Columns, RowDetail, RowActions and the unbound parts. |
| Name | Type | Default | Description |
|---|---|---|---|
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. |
| Name | Type | Default | Description |
|---|---|---|---|
ariaLabel | string | — | Accessible name of the table (not of the outer frame). |
From TableOptions — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
rowKeyrequired | (row: Z) => string | — | A stable key per row – the basis of selection and expansion. |
From FieldColumn — the full table stands on Column.
| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | K | — | A field of the row. Supplies the column's id at the same time. |
format | FormatFor<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. |
edit | EditFor<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 |
editOptions | readonly Present<Z[K]>[] | the values that occur | What 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.
| Name | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Names the column in its header, in the column menu and in the header row of the export. |
rowHeader | boolean | — | 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. |
width | number | the browser's | Initial width in pixels. |
What umriss deliberately does not build, and why: ADR-0032.