A table over rows a server holds: the application hands in one page and the server's count, and the table reports what to fetch next (also called server-side paging, sorting and filtering).
import { useTable } from "@umriss-ui/table";
onRequest reports the request whenever what decides the rows changes – search, conditions, sort, page, page size – complete, defaults included, since a server has no default of the table's.
Requests are the application's: the table shows whatever rows it is given, so an answer to a view the user has already left is the application's to drop.
server takes the rows of one page and the server's rowCount; onRequest reports the page the user asks for next.
import { useState } from "react";
import { Pagination, useTable } from "@umriss-ui/table";
import type { TableRequest } from "@umriss-ui/table";
/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Shipment {
id: string;
customer: string;
/** In kg. */
weight: number;
status: "delivered" | "out for delivery" | "failed attempt";
window: readonly [number, number];
tour: string;
}
/** One shipment per stop. Before now, delivered - or a failed attempt, where
nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
one.stops.map((stop) => {
const n = Number(stop.shipment.slice(3));
return {
id: stop.shipment,
customer: stop.customer,
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
window: stop.window,
tour: one.id,
} satisfies Shipment;
}),
);
/* The server is a slice of an array here; in an application it is a request. */
const serve = (view: TableRequest) => {
const start = (view.page - 1) * view.pageSize;
return { rows: SHIPMENTS.slice(start, start + view.pageSize), rowCount: SHIPMENTS.length };
};
export default function OnlyPages() {
const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 }));
const { Table, Column } = useTable(answer.rows, {
rowKey: (s) => s.id,
server: true,
rowCount: answer.rowCount,
onRequest: (view) => setAnswer(serve(view)),
});
return (
<Table ariaLabel="Shipments on the server">
<Column value="id" label="Shipment" rowHeader sortable={false} />
<Column value="customer" label="Customer" sortable={false} />
<Column value="weight" label="Weight (kg)" sortable={false} />
<Pagination />
</Table>
);
}
The request onRequest reports is complete: search, sort, page and page size, defaults included. The line below shows what the server received.
import { useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { Pagination, Search, Toolbar, useTable } from "@umriss-ui/table";
import type { TableRequest } from "@umriss-ui/table";
/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Shipment {
id: string;
customer: string;
/** In kg. */
weight: number;
status: "delivered" | "out for delivery" | "failed attempt";
window: readonly [number, number];
tour: string;
}
/** One shipment per stop. Before now, delivered - or a failed attempt, where
nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
one.stops.map((stop) => {
const n = Number(stop.shipment.slice(3));
return {
id: stop.shipment,
customer: stop.customer,
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
window: stop.window,
tour: one.id,
} satisfies Shipment;
}),
);
const serve = (view: TableRequest) => {
const term = view.search.trim().toLowerCase();
const hits = SHIPMENTS.filter((s) => !term || s.id.toLowerCase().includes(term) || s.customer.toLowerCase().includes(term));
const sort = view.sort[0];
if (sort) {
const key = sort.column as keyof Shipment;
const sign = sort.direction === "asc" ? 1 : -1;
hits.sort((a, b) => (a[key] < b[key] ? -sign : a[key] > b[key] ? sign : 0));
}
const start = (view.page - 1) * view.pageSize;
return { view, rows: hits.slice(start, start + view.pageSize), rowCount: hits.length };
};
const FIRST: TableRequest = { search: "", conditions: {}, sort: [{ column: "id", direction: "asc" }], page: 1, pageSize: 10 };
export default function SortAndSearch() {
const [answer, setAnswer] = useState(() => serve(FIRST));
const { Table, Column } = useTable(answer.rows, {
rowKey: (s) => s.id,
defaultSort: FIRST.sort[0],
server: true,
rowCount: answer.rowCount,
onRequest: (view) => setAnswer(serve(view)),
});
const { search, sort, page, pageSize } = answer.view;
return (
<Stack gap={2}>
<Table ariaLabel="Shipments on the server">
<Toolbar>
<Search placeholder="Shipment or customer" />
</Toolbar>
<Column value="id" label="Shipment" rowHeader />
<Column value="customer" label="Customer" />
<Column value="weight" label="Weight (kg)" />
<Pagination />
</Table>
<Text size="xs" tone="muted" mono>
Received: page {page} · {pageSize} rows · {sort.map((s) => `${s.column} ${s.direction}`).join(", ")}
{search && ` · “${search}”`}
</Text>
</Stack>
);
}
The table holds one page and cannot count the values of the rest; filterOptions names them, and the chosen ones arrive in view.conditions.
import { useState } from "react";
import { Pagination, useTable } from "@umriss-ui/table";
import type { TableRequest } from "@umriss-ui/table";
/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Shipment {
id: string;
customer: string;
/** In kg. */
weight: number;
status: "delivered" | "out for delivery" | "failed attempt";
window: readonly [number, number];
tour: string;
}
/** One shipment per stop. Before now, delivered - or a failed attempt, where
nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
one.stops.map((stop) => {
const n = Number(stop.shipment.slice(3));
return {
id: stop.shipment,
customer: stop.customer,
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
window: stop.window,
tour: one.id,
} satisfies Shipment;
}),
);
const STATUSES = ["delivered", "out for delivery", "failed attempt"] as const;
const serve = (view: TableRequest) => {
const chosen = view.conditions.status as readonly string[] | undefined;
const hits = SHIPMENTS.filter((s) => !chosen || chosen.includes(s.status));
const start = (view.page - 1) * view.pageSize;
return { rows: hits.slice(start, start + view.pageSize), rowCount: hits.length };
};
export default function ListFilter() {
const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 }));
const { Table, Column } = useTable(answer.rows, {
rowKey: (s) => s.id,
server: true,
rowCount: answer.rowCount,
onRequest: (view) => setAnswer(serve(view)),
filterOptions: (column) => (column === "status" ? STATUSES : []),
});
return (
<Table ariaLabel="Shipments on the server">
<Column value="id" label="Shipment" rowHeader sortable={false} />
<Column value="customer" label="Customer" sortable={false} />
<Column value="status" label="Status" filter="list" sortable={false} />
<Pagination />
</Table>
);
}
loading marks the table while a request runs: the page it has stays, dimmed after a moment, and nothing below it moves. An answer to a view the user has already left is the application's to drop; the table shows whatever rows it is given.
import { useRef, useState } from "react";
import { Pagination, useTable } from "@umriss-ui/table";
import type { TableRequest } from "@umriss-ui/table";
/* Data from the logistics world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (hours: number, minutes = 0) => new Date(2026, 2, 17, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(10, 30);
interface Vehicle {
id: string;
plate: string;
type: "van" | "e-van" | "truck";
/** Payload, in kg. */
capacity: number;
depot: string;
}
const VEHICLES: readonly Vehicle[] = [
{ id: "v1", plate: "FP 214 K", type: "van", capacity: 1200, depot: "north" },
{ id: "v2", plate: "FP 377 K", type: "e-van", capacity: 900, depot: "north" },
{ id: "v3", plate: "FP 118 R", type: "truck", capacity: 7500, depot: "north" },
{ id: "v4", plate: "FP 402 R", type: "van", capacity: 1200, depot: "river" },
{ id: "v5", plate: "FP 455 R", type: "e-van", capacity: 900, depot: "river" },
{ id: "v6", plate: "FP 290 E", type: "van", capacity: 1200, depot: "east" },
{ id: "v7", plate: "FP 311 E", type: "e-van", capacity: 900, depot: "east" },
{ id: "v8", plate: "FP 520 E", type: "truck", capacity: 7500, depot: "east" },
];
const DRIVERS = [
{ id: "d1", name: "Martin Hale", depot: "north" },
{ id: "d2", name: "Nadia Petrova", depot: "north" },
{ id: "d3", name: "Owen Carter", depot: "north" },
{ id: "d4", name: "Lucia Romero", depot: "river" },
{ id: "d5", name: "Ben Adeyemi", depot: "river" },
{ id: "d6", name: "Hanna Berg", depot: "east" },
{ id: "d7", name: "Yusuf Demir", depot: "east" },
{ id: "d8", name: "Clara Wendt", depot: "east" },
] as const;
const CUSTOMERS = [
"Holloway Garden Supplies",
"Marlow & Finch Books",
"Oakridge Pharmacy",
"Brixley Cycles",
"Tamsin's Bakery",
"Northfold Office",
"Pellham Hardware",
"Greywick Studio",
"Juniper Lane Florist",
"Ashcombe Dental",
] as const;
interface Stop {
id: string;
shipment: string;
customer: string;
/** The window the customer was promised, `[from, to]`. */
window: readonly [number, number];
/** When the tour plans to be there. */
arrival: number;
}
interface Tour {
id: string;
vehicle: string;
driver: string;
depot: string;
/** Leaves the depot, loaded; back at the depot. */
from: number;
to: number;
/** Minutes of loading before `from`. */
loading: number;
km: number;
stops: readonly Stop[];
}
/** One tour per vehicle. Stops every half hour or so, each with a two-hour
window; every seventh arrival misses its window - the late ones a
dispatcher looks for. */
function tour(index: number): Tour {
const r = random(700 + index);
const vehicle = VEHICLES[index]!;
const truck = vehicle.type === "truck";
const from = at(truck ? 6 : 7, index % 2 === 0 ? 0 : 30);
const count = truck ? 5 : 8 + Math.floor(r() * 4);
const stops: Stop[] = [];
let arrival = from + (20 + Math.floor(r() * 15)) * MINUTE;
for (let i = 0; i < count; i++) {
const n = index * 20 + i;
const slot = Math.floor((arrival - at(0)) / (2 * 60 * MINUTE)) * 2;
/* A late stop's window closed half an hour or more before the arrival. */
const late = i >= 2 && n % 7 === 3;
const halfHour = 30 * MINUTE;
const windowFrom = late ? Math.floor(arrival / halfHour) * halfHour - 5 * halfHour : at(slot);
stops.push({
id: `s-${index + 1}-${i + 1}`,
shipment: `FP-${(1_004_210 + n * 13).toString()}`,
customer: CUSTOMERS[(n * 3) % CUSTOMERS.length]!,
window: [windowFrom, windowFrom + 2 * 60 * MINUTE],
arrival,
});
arrival += (truck ? 45 : 22) * MINUTE + Math.floor(r() * 18) * MINUTE;
}
return {
id: `T-${String(index + 1).padStart(2, "0")}`,
vehicle: vehicle.id,
driver: DRIVERS[index]!.id,
depot: vehicle.depot,
from,
to: arrival + 30 * MINUTE,
loading: truck ? 45 : 30,
km: Math.round((truck ? 140 : 70) + r() * 40),
stops,
};
}
const TOURS: readonly Tour[] = VEHICLES.map((_, index) => tour(index));
interface Shipment {
id: string;
customer: string;
/** In kg. */
weight: number;
status: "delivered" | "out for delivery" | "failed attempt";
window: readonly [number, number];
tour: string;
}
/** One shipment per stop. Before now, delivered - or a failed attempt, where
nobody was in; after now, out for delivery. */
const SHIPMENTS: readonly Shipment[] = TOURS.flatMap((one) =>
one.stops.map((stop) => {
const n = Number(stop.shipment.slice(3));
return {
id: stop.shipment,
customer: stop.customer,
weight: Math.round((VEHICLES.find((v) => v.id === one.vehicle)!.type === "truck" ? 180 : 2) + ((n * 7919) % 230) / 10),
status: stop.arrival > NOW ? "out for delivery" : n % 11 === 0 ? "failed attempt" : "delivered",
window: stop.window,
tour: one.id,
} satisfies Shipment;
}),
);
/* Every answer takes 600 ms. Page on quickly: only the last request lands. */
const LATENCY = 600;
const serve = (view: TableRequest) => {
const start = (view.page - 1) * view.pageSize;
return { rows: SHIPMENTS.slice(start, start + view.pageSize), rowCount: SHIPMENTS.length };
};
export default function LoadingAndLateAnswers() {
const [answer, setAnswer] = useState(() => serve({ search: "", conditions: {}, sort: [], page: 1, pageSize: 10 }));
const [loading, setLoading] = useState(false);
const latest = useRef<TableRequest | null>(null);
const { Table, Column } = useTable(answer.rows, {
rowKey: (s) => s.id,
server: true,
rowCount: answer.rowCount,
onRequest: (view) => {
latest.current = view;
setLoading(true);
setTimeout(() => {
if (latest.current !== view) return;
setAnswer(serve(view));
setLoading(false);
}, LATENCY);
},
});
return (
<Table ariaLabel="Shipments on the server" loading={loading}>
<Column value="id" label="Shipment" rowHeader sortable={false} />
<Column value="customer" label="Customer" sortable={false} />
<Column value="weight" label="Weight (kg)" sortable={false} />
<Pagination />
</Table>
);
}
server hands the table one page and the server's rowCount; onRequest reports search, conditions, sort and page to fetch the next.
import { useRef, useState } from "react";
import { Stack, Text } from "@umriss-ui/core";
import { ColumnMenu, Export, Pagination, Search, Toolbar, useTable } from "@umriss-ui/table";
import type { TableRequest } from "@umriss-ui/table";
/* The server below is a fake: a million requests computed from their index,
every answer delayed by 400 ms. Dropping a late answer to an older view is
the application's part, and it stands here in full. */
interface Request {
id: string;
service: string;
duration: number;
}
const COUNT = 1_000_000;
const LATENCY = 400;
const SERVICES = ["Checkout", "Billing", "Sign-in", "Search", "Image service", "Notifications", "Webhooks", "Reporting"] as const;
const BY_ID = { column: "id", direction: "asc" } as const;
/* A row from its index alone - the same every run, and no array of a million. */
const mix = (i: number, salt: number) => {
let h = Math.imul(i ^ salt, 2654435761) >>> 0;
h = Math.imul(h ^ (h >>> 15), 2246822519) >>> 0;
return (h ^ (h >>> 13)) >>> 0;
};
const request = (i: number): Request => ({
id: `REQ-${String(i + 1).padStart(7, "0")}`,
service: SERVICES[mix(i, 7) % SERVICES.length]!,
duration: 20 + (mix(i, 11) % 900),
});
/* Filter, sort, then the page. The order of the last filter and sort is kept,
the way a database keeps an index warm. */
let warm: { key: string; order: Int32Array | null } = { key: "", order: null };
function serve(view: TableRequest): { rows: Request[]; rowCount: number } {
const search = view.search.trim().toLowerCase();
const services = view.conditions.service as readonly string[] | undefined;
const sort = view.sort[0] ?? BY_ID;
const key = JSON.stringify([search, services, sort]);
if (warm.key !== key) {
let order: Int32Array | null = null;
if (search || services || sort.column !== "id") {
const hits: number[] = [];
for (let i = 0; i < COUNT; i++) {
const r = request(i);
if (services && !services.includes(r.service)) continue;
if (search && !r.id.toLowerCase().includes(search) && !r.service.toLowerCase().includes(search)) continue;
hits.push(i);
}
order = Int32Array.from(hits);
if (sort.column === "duration") {
const values = new Float64Array(COUNT);
for (const i of order) values[i] = request(i).duration;
order.sort((a, b) => values[a]! - values[b]! || a - b);
}
}
warm = { key, order };
}
const { order } = warm;
const rowCount = order ? order.length : COUNT;
const descending = sort.direction === "desc";
const start = (view.page - 1) * view.pageSize;
const rows: Request[] = [];
for (let n = start; n < Math.min(start + view.pageSize, rowCount); n++) {
const at = descending ? rowCount - 1 - n : n;
rows.push(request(order ? order[at]! : at));
}
return { rows, rowCount };
}
const FIRST: TableRequest = { search: "", conditions: {}, sort: [BY_ID], page: 1, pageSize: 10 };
const same = (a: TableRequest, b: TableRequest) =>
JSON.stringify([a.search, a.conditions, a.sort, a.page, a.pageSize]) ===
JSON.stringify([b.search, b.conditions, b.sort, b.page, b.pageSize]);
export default function AMillionRowsOnAServer() {
/* The first page comes with the document, as from a server that renders it. */
const [answer, setAnswer] = useState(() => ({ view: FIRST, ...serve(FIRST) }));
const [loading, setLoading] = useState(false);
const latest = useRef(FIRST);
const t = useTable(answer.rows, {
rowKey: (r) => r.id,
defaultSort: BY_ID,
server: true,
rowCount: answer.rowCount,
onRequest: (view) => {
latest.current = view;
/* Back to the view on screen before its successor was answered: the
pending answer is dropped, so nothing is loading any more. */
if (same(view, answer.view)) return setLoading(false);
setLoading(true);
setTimeout(() => {
if (latest.current !== view) return;
setAnswer({ view, ...serve(view) });
setLoading(false);
}, LATENCY);
},
filterOptions: (column) => (column === "service" ? SERVICES : []),
});
const { Table, Column } = t;
const { search, sort, page, pageSize } = answer.view;
return (
<Stack gap={2}>
<Table selectable loading={loading} ariaLabel="Requests on the server">
<Toolbar>
<Search placeholder="Request or service" />
<ColumnMenu />
<Export />
</Toolbar>
<Column value="id" label="Request" rowHeader />
<Column value="service" label="Service" filter="list" sortable={false} />
<Column value="duration" label="Duration (ms)" />
<Pagination />
</Table>
<Text size="xs" tone="muted" mono>
Answered: page {page} · {pageSize} rows · {sort.map((s) => `${s.column} ${s.direction}`).join(", ")}
{search && ` · “${search}”`} · {t.selection.count} selected
</Text>
</Stack>
);
}
The keys of First table, Input, ColumnMenu, Select, Button and Checkbox apply here.
From TableProps — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
selectable | boolean | — | Adds the selection column. "All" means: the filtered set. |
loading | boolean | — | Shows placeholder rows instead of the rows. |
children | ReactNode | — | Columns, RowDetail, RowActions and the unbound parts. |
| 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. |
defaultSort | | SortLevel | null | The application's sort; it is the default that view leaves out. |
From TableSnapshot — the full table stands on First table.
| Name | Type | Default | Description |
|---|---|---|---|
selectionrequired | TableSelection<string> | — | The selection – its own or the one handed in through selection. |
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. |
filter | FilterFor<Z[K]> | — | A filter in the column header: "list" offers the values that occur,
"range" two bounds (numbers or points in time), a filter from
columnFilter asks whatever it asks itself. Its condition stands in the
table toolbar. |
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. |
sortable | boolean | true when the value is text, a number, a point in time or a boolean | Whether the column can be sorted by. |
From SearchProps — the full table stands on Search.
| Name | Type | Default | Description |
|---|---|---|---|
placeholder | string | the wording's "Search …" | The placeholder in the field. |
filterOptions names; the table cannot count values it does not hold.What umriss deliberately does not build, and why: ADR-0032.