A complete form
Everything on the previous pages, in one screen you can paste and run.
The whole thing
This is the shape almost every screen in the Pro edition takes. Four surfaces come from one list, so they cannot disagree about what a customer is.
app/customers/data.ts
import type { RecordField } from "@viliha/vui-react/record-view";
export type Customer = {
id: string;
name: string;
email: string;
country: string;
plan: "Free" | "Pro" | "Enterprise";
seats: number;
notes: string;
active: boolean;
};
export const CUSTOMER_FIELDS: RecordField<Customer>[] = [
{ key: "name", label: "Name", editable: true, required: true, trim: true,
group: "Identity", sortable: true, hideInTable: true },
{ key: "email", label: "Email", editable: true, required: true, trim: true,
group: "Identity", pattern: "^[^@\s]+@[^@\s]+$",
patternMessage: "That is not an email address" },
{ key: "country", label: "Country", editable: true, group: "Identity",
options: COUNTRIES, filterable: { control: "select", options: COUNTRIES } },
{ key: "plan", label: "Plan", editable: true, required: true, group: "Commercial",
options: PLANS, filterable: { control: "select", options: PLANS } },
{ key: "seats", label: "Seats", editable: true, group: "Commercial", min: 1, max: 500 },
{ key: "active", label: "Active", editable: true, group: "Commercial" },
{ key: "notes", label: "Notes", editable: true, group: "Commercial",
input: "textarea", hideInTable: true },
];app/customers/page.tsx
"use client";
import { RecordView } from "@viliha/vui-react/record-view";
import * as React from "react";
import { CUSTOMER_FIELDS, type Customer } from "./data";
export default function CustomersPage() {
const [rows, setRows] = React.useState<Customer[]>([]);
const [error, setError] = React.useState<string>();
return (
<>
{error && <p role="alert">{error}</p>}
<RecordView<Customer>
title="Customers"
singular="Customer"
fields={CUSTOMER_FIELDS}
getPrimary={(row) => ({
title: row.name,
subtitle: `${row.plan} · ${row.seats} seats`,
initials: row.name.slice(0, 2).toUpperCase(),
})}
data={rows}
onDataChange={async (next) => {
const previous = rows;
setRows(next); // optimistic
try {
await save(next);
} catch {
setRows(previous); // a save that fails must not leave a lie on screen
setError("Could not save. Nothing was changed.");
}
}}
makeEmptyRow={() => ({
id: `cus-${Date.now()}`, name: "", email: "", country: "United States",
plan: "Free", seats: 1, notes: "", active: true,
})}
/>
</>
);
}What that gave you
- A table with search, per-column filters on country and plan, sorting, pagination, selection and a column menu.
- A create form in a slide-over, split into Identity and Commercial because the fields carry
group. - An edit form, the same one, filled in.
- A read-only view, which is where
notesshows even though it is hidden from the table.
⚠️ The rollback is the part worth copying
Setting state and then awaiting a save that fails leaves the screen showing a row nobody stored. Keeping the previous rows and restoring them is four lines, and it is the difference between an optimistic update and a lie.