# Server mode

> Package `@umriss-ui/table`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/table/server-mode/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/table/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/table/llms-full.txt).

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).

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

Demo page: https://romanhaendler.github.io/umriss-ui/table/server-mode/

`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.

## Examples

### Hand the table one page

`server` takes the rows of one page and the server's `rowCount`; `onRequest` reports the page the user asks for next.

```tsx
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>
  );
}
```

### Sort and search on the server

The request `onRequest` reports is complete: search, sort, page and page size, defaults included. The line below shows what the server received.

```tsx
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>
  );
}
```

### Name the values a list filter offers

The table holds one page and cannot count the values of the rest; `filterOptions` names them, and the chosen ones arrive in `view.conditions`.

```tsx
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>
  );
}
```

### Show loading, and drop a late answer

`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.

```tsx
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>
  );
}
```

### Load pages from a server

`server` hands the table one page and the server's `rowCount`; `onRequest` reports search, conditions, sort and page to fetch the next.

```tsx
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>
  );
}
```

## When to use something else

- Thousands of rows that are all in the browser → Virtualisation

## Keyboard

The keys of [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#keyboard-first-table), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [ColumnMenu](https://romanhaendler.github.io/umriss-ui/table/columnmenu/#keyboard-columnmenu), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select), [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button) and [Checkbox](https://romanhaendler.github.io/umriss-ui/core/checkbox/#keyboard-checkbox) apply here.

## Props on this page

From `TableProps` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`selectable`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-selectable) | `boolean` | — | Adds the selection column. "All" means: the filtered set. |
| [`loading`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-loading) | `boolean` | — | Shows placeholder rows instead of the rows. |
| [`children`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-children) | `ReactNode` | — | Columns, `RowDetail`, `RowActions` and the unbound parts. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableProps-ariaLabel) | `string` | — | Accessible name of the table (not of the outer frame). |

From `TableOptions` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`rowKey`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-rowKey) *required* | `(row: Z) => string` | — | A stable key per row – the basis of selection and expansion. |
| [`defaultSort`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableOptions-defaultSort) | [`SortLevel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-SortLevel)`  \| readonly  `[`SortLevel`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-SortLevel)`[] \| null` | `null` | The application's sort; it is the default that `view` leaves out. |

From `TableSnapshot` — the full table stands on [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableSnapshot).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`selection`](https://romanhaendler.github.io/umriss-ui/table/first-table/#TableSnapshot-selection) *required* | [`TableSelection`](https://romanhaendler.github.io/umriss-ui/table/first-table/#type-TableSelection)`<string>` | — | The selection – its own or the one handed in through `selection`. |

From `FieldColumn` — the full table stands on [Column](https://romanhaendler.github.io/umriss-ui/table/column/#type-FieldColumn).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`value`](https://romanhaendler.github.io/umriss-ui/table/column/#FieldColumn-value) *required* | `K` | — | A field of the row. Supplies the column's `id` at the same time. |
| [`filter`](https://romanhaendler.github.io/umriss-ui/table/column/#FieldColumn-filter) | [`FilterFor`](https://romanhaendler.github.io/umriss-ui/table/column/#type-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](https://romanhaendler.github.io/umriss-ui/table/column/#type-ColumnBase).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`label`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-label) *required* | `string` | — | Names the column in its header, in the column menu and in the header row of the export. |
| [`rowHeader`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-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`](https://romanhaendler.github.io/umriss-ui/table/column/#ColumnBase-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](https://romanhaendler.github.io/umriss-ui/table/search/#type-SearchProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`placeholder`](https://romanhaendler.github.io/umriss-ui/table/search/#SearchProps-placeholder) | `string` | the wording's "Search …" | The placeholder in the field. |

From `ToolbarProps` — the full table stands on [Toolbar](https://romanhaendler.github.io/umriss-ui/table/toolbar/#type-ToolbarProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`children`](https://romanhaendler.github.io/umriss-ui/table/toolbar/#ToolbarProps-children) | `ReactNode` | — | Search, column menu, export – on the left in the table toolbar, before the conditions. |

## Known limits

- The table sorts, filters and pages nothing itself, and it shows no groups and no footer: they would be made of one page.
- “Select all” and the export act on the page the table holds, and the export button says so.
- A list filter offers what `filterOptions` names; the table cannot count values it does not hold.
- No loading more rows while scrolling: paging and virtualisation do not combine.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
