Forms

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 notes shows 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.