A customer screen, end to end
One route, one type, one field list, one component. By the end you have a searchable, sortable, filterable customer list that adds, edits and deletes, and you have written no table markup.
Before you start
The React edition on Tailwind CSS, Pro tier. RecordView is a Pro family, so this recipe does not work on the free edition, which is the one thing worth knowing before the first command rather than at the import line.
1 · Describe a customer
A plain type. Everything below is checked against it, so this is the only place the shape is written down.
export type Customer = {
id: string;
name: string;
email: string;
country: string;
spend: number;
status: "Active" | "Trial" | "Churned";
};2 · Say what each field is
The field list is the whole configuration. It decides the table columns, the form inputs, the filters and the validation, so there is no second list to keep in step with this one.
import type { RecordField } from "@viliha/vui-react/record-view";
const COUNTRIES = ["United States", "Japan", "Nigeria", "United Kingdom"];
const STATUSES = ["Active", "Trial", "Churned"];
export const CUSTOMER_FIELDS: RecordField<Customer>[] = [
{
key: "name",
label: "Name",
editable: true,
required: true,
group: "General",
sortable: true,
// The identity column already prints the name. See "the name appears twice" below.
hideInTable: true,
},
{ key: "email", label: "Email", editable: true, required: true, group: "General" },
{
key: "country",
label: "Country",
editable: true,
group: "General",
filterable: { control: "select", options: COUNTRIES.map((c) => ({ value: c, label: c })) },
},
{ key: "spend", label: "Lifetime spend", editable: true, group: "Commercial" },
{
key: "status",
label: "Status",
editable: true,
required: true,
group: "Commercial",
filterable: { control: "select", options: STATUSES.map((s) => ({ value: s, label: s })) },
},
];group is what splits the add and edit form into sections. Two groups here, so the form has two headed blocks rather than one column of nine inputs.
3 · Draw the screen
"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[]>([]);
return (
<RecordView<Customer>
title="Customers"
singular="Customer"
fields={CUSTOMER_FIELDS}
getPrimary={(row) => ({
title: row.name,
subtitle: `${row.country} · ${row.email}`,
initials: row.name.slice(0, 2).toUpperCase(),
})}
data={rows}
onDataChange={(next) => setRows(next)}
makeEmptyRow={() => ({
id: `cus-${Date.now()}`,
name: "",
email: "",
country: "United States",
spend: 0,
status: "Trial",
})}
/>
);
}Check it worked
- The table is there and empty, with its own empty state rather than a bare frame. You passed no rows, so this is correct.
- “New Customer” opens a slide-over with two headed sections, General and Commercial, matching the
groupvalues. - Saving adds a row, and the first column shows the name with its initials rather than a plain cell.
- The toolbar filters by country and status, because those two fields are
filterableand the others are not.
4 · Connect it to your data
Nothing above talks to a server, and the two ways to change that are a prop apart.
Let the component hold the rows
Pass initialData and let it manage its own state. This is the shortest thing that works, and it is enough for a screen whose data is loaded once.
Hold the rows yourself
Pass data and onDataChange, as above. onDataChange may return a promise, so it is where a POST or PATCH goes. Use this one as soon as anything outside the screen needs to know a row changed.
Pick one, not both
data and initialData answer the same question. Passing both means the component is told twice who owns the rows, and the answer stops being obvious from reading the call.5 · Choose how the form opens
formMode is "panel" by default: a slide-over beside the table, which keeps the list visible behind it. Set "page" for a full-page form, which suits a record with many fields, and pair it with formColumns={2} when one column would scroll.
Your download has both, side by side and deliberately identical otherwise: /departments is the default and /organizations is the same screen with formMode="page". Open them together and the only difference you can see is the one you are choosing between.
When it did not work
The name appears twice
Cause: getPrimary already draws the name in the identity column, and a name field draws it again under its own heading. Fix: hideInTable: true on that field. It stays in the form, where you do need it. Verify: one name column.
TypeScript rejects your fields against a row that only has an id
Cause: the generic was left off. With data alone, inference lands on the constraint ({ id: RowId }) rather than on your type, so every key past id looks invalid. Fix: write it out, <RecordView<Customer>. Verify: npx tsc --noEmit is clean.
There is no “New” button
Cause: makeEmptyRow is missing, and the component hides the button rather than offering one that cannot produce a row. That is the intended way to build a read-only list. Fix: add it, if you wanted the button. Verify: the button is back.
Rows disappear when you add one
Cause: data is passed without onDataChange, so the component asks you to store the new list and nobody does. Fix: add the handler, or move to initialData and let the component keep them. Verify: the row stays.
Next
Add it to the sidebar so people can reach it, and read RecordView for the rest of its props, including import and export, selection and the toolbar toggles.