Data table (RecordView)
RecordView is the component shadcn/ui leaves you to build yourself: a complete, themed admin data table driven by a single fields array. Point it at your data and editing, sorting, filtering, pagination, row actions, a buffered add/edit panel, bulk actions, and import/export all come for free.
Import
import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";One component, many features
Building a profile page?
ProfileForm from @viliha/vui-ui/profile-form and feed it data + fields. For a company profile, spread the organizationProfileFields preset from @viliha/vui-ui/organization-profile. The /organization/profile page is the reference consumer.Logo and favicon fields upload through you
BrandAsset control never touches your storage. Hand it onPick(file), save the file wherever your assets live, and return the URL to display: orgProfileFields({ logo: { onPick } }). The control shows its own uploading state, enforces maxBytes before it calls you, and prints a details line from meta. Its inline option stores the image as a base64 data URI and is only for demos with no backend, which is what the prebuilt organizationProfileFields uses.Full example
Describe your columns in a fieldsarray, pass your data, and wire up two small callbacks. That's the entire integration.
"use client";
import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
import { Badge } from "@viliha/vui-ui/badge";
type Org = {
id: number;
name: string;
domain: string;
country: string;
employees: number;
status: "active" | "trial" | "suspended";
};
const fields: RecordField<Org>[] = [
// hideInTable: shown only in the add/edit panel, used as the row title
{ key: "name", label: "Name", editable: true, required: true, hideInTable: true },
{ key: "domain", label: "Domain", editable: true, copyable: true },
{ key: "country", label: "Country", editable: true },
// number → auto-centers
{ key: "employees", label: "Employees", editable: true },
// options → "Set status" bulk action + a choice field
{
key: "status",
label: "Status",
options: [
{ value: "active", label: "Active" },
{ value: "trial", label: "Trial" },
{ value: "suspended", label: "Suspended" },
],
render: (row) => <Badge>{row.status}</Badge>,
},
];
export function OrganizationsTable({ data }: { data: Org[] }) {
return (
<RecordView
title="Organizations"
singular="Organization"
fields={fields}
initialData={data}
makeEmptyRow={() => ({
id: Date.now(), name: "", domain: "", country: "",
employees: 0, status: "trial",
})}
getPrimary={(row) => ({
title: row.name,
subtitle: row.domain,
initials: row.name.slice(0, 2).toUpperCase(),
})}
/>
);
}Props
Field options
Each entry in fields is a RecordField:
Auto-aligned columns
Leave align off and columns align themselves from the data: numeric columns and short codes (all values ≤ 4 characters, e.g. "USD", "EN") center; everything else stays left. Set align explicitly to override.
{ key: "employees", label: "Employees" } // number → centered
{ key: "code", label: "Code" } // "USD","EUR" → centered
{ key: "name", label: "Name" } // long text → left
{ key: "total", label: "Total", align: "right" } // explicit overrideHow do I enable sorting?
Click a column header or use the Sort dropdown. Every sortable column shows a caret indicator: a muted up/down caret by default, then a solid caret for the active direction (up = ascending, down = descending). By default a field is sortable when it's a visible column; sortable decouples the two, so your sort set can differ from your column set (a non-sortable column shows no caret). The identity column (Name/Title) sorts too: mark its hideInTable field sortable (or set nameSortKey) and its header toggles with the same caret.
{ key: "name", hideInTable: true, sortable: true } // sorted, but no column
{ key: "code", label: "Code" } // column + sortable (default)
{ key: "notes", label: "Notes", sortable: false } // column, but not sortableHow do I add filters?
By default the toolbar's Filter panel is a single keyword box that matches across every field (built in, nothing to wire). For a labeled control per field, mark fields filterable. When any field is filterable the panel switches to a control per field plus Search and Clear. Every row uses the theme's enforced filter layout, two columns: label │ control, one row per field, with labels aligned across rows.
The control is dynamic, so the front end composes a different filter form per screen: filterable: true is a text input, or pass a config to pick the control.
const fields: RecordField<Region>[] = [
{ key: "name", label: "Name", filterable: true }, // text input
{ key: "code", label: "Code", filterable: { control: "text",
placeholder: "e.g. APAC" } },
{ key: "status", label: "Status", options: STATUS, // reuses options
filterable: { control: "select" } },
{ key: "tags", label: "Tags",
filterable: { control: "checkbox", options: TAGS } }, // → string[]
];control is one of "text" | "number" | "date" | "select" | "combobox" | "checkbox" (unknown or omitted → text). combobox is a searchable single-select (type-to-filter) for long option lists; select is the plain dropdown. options is a static array or a function of the current filter values(cascading, see Cascading options), and falls back to the field's own options. The exported types are FilterControl, FieldFilter, and FilterValues<T>.
Custom filter rows
Need a control the built-in kinds don't cover? Add your own row with filterExtras, composed with FilterField from @viliha/vui-ui/filter-field so it inherits the same two-column layout. Never hand-roll the row. It renders below the filterablefields in the same grid; its state and matching are yours (RecordView's Search / Clear drive the filterable values only).
import { FilterField } from "@viliha/vui-ui/filter-field";
<RecordView
fields={fields}
filterExtras={
<FilterField label="Created after">
<Input type="date" value={after} onChange={(e) => setAfter(e.target.value)} />
</FilterField>
}
onFilter={runQuery}
/>The two-column layout (FilterGrid + FilterField) is exported, so a filter panel you build outside RecordView follows the same design.
⚠️ Per-field mode doesn't match rows for you
onFilter(values) to run a query or your own client-side filter (Search and Clear both call it). The built-in keyword matching only applies when no field is filterable.<RecordView
fields={fields}
data={rows}
onDataChange={setRows}
// values = { name: "asia", code: "AP", tags: ["eu","us"] }
onFilter={(values) => refetch(values)} // or filter client-side
/>Live example: system/regions filters Name and Code client-side. To add a control kind not listed, extend the FilterControl union in the component.
How do I show a loading state?
When rows come from a server, set loading while the request is in flight and the table body shows skeleton rows (matched to your columns) with a highlight shimmering left to right, instead of an empty “No records” flash. The toolbar stays usable. Clear it when the data arrives. The Markets demo simulates this on first load; the Data Table demo shows it on every server fetch.
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/markets")
.then((r) => r.json())
.then(setRows)
.finally(() => setLoading(false));
}, []);
<RecordView loading={loading} data={rows} onDataChange={setRows} /* … */ />How do I load data from a server?
By default RecordView does filtering, sorting, and pagination in the browser over the data you pass. For large tables that live on a server, there are two ways to go manual.
Let RecordView own the fetch (recommended)
Pass a fetcher and RecordView owns the whole read path: it calls your endpoint on every query change, manages data / rowCount / loading, and caches responses in memory under cacheKey (a module-scoped map that survives remounts), so returning to a tab paints instantly. Add persistKey and the page/sort/filters restore on remount too. No data/onQueryChange/loading wiring.
<RecordView
fetcher={(query, signal) => fetch(url(query), { signal }).then((r) => r.json())}
// → resolves { rows, total }
cacheKey="members" // caches responses per query, survives tab switches
persistKey="/members" // restores page/sort/filters on return
fields={fields}
initialData={[]}
/* … */
/>How do I make Import and Export use my API?
Both menus are placeholders with a working default. The CSV, Excel, JSON and PDF entries ship and do the work in the browser, and they are built from the same IoAction type you use, so replacing them or adding to them is the same API rather than a different one.
<RecordView
exportActions={(defaults) => [
...defaults, // keep CSV / Excel / JSON / PDF
{
id: "server",
label: "Everything matching (server)",
// The browser only holds the page on screen. ctx.query has the rest:
// page, sort, search, filters, trash.
onAct: async (ctx) => window.open(await api.exportUrl(ctx.query)),
},
]}
importActions={[ // an array replaces the menu entirely
{
id: "upload",
label: "Upload to server",
pickFile: true, // open the picker, hand it to ctx.file
accept: ".csv,.xlsx",
onAct: async (ctx) => { await api.import(ctx.file!); ctx.refetch(); },
},
]}
/* … */
/>Exporting more than the page you can see
ctx.rows is what is on screen: filtered, sorted, and in fetchermode only the current page. Writing those out and calling it "export all" is the usual mistake. For everything that matches, use ctx.query and ask your API, which is what the example above does. The default entries write ctx.rows, which is right for a small client-side list and wrong the moment the data outgrows a page.A wide table says so
vui-scroll in theme.css: put it on any scroll region you add.The cache paints, the server decides
Page size (env-configurable)
A table with a million rows never ships them all to the browser: it pulls one page at a time. The initial page size and the ceiling are set by env, so every table inherits one setting:
NEXT_PUBLIC_DEFAULT_PAGE_SIZE=50 # initial rows per page
NEXT_PUBLIC_MAX_PAGE_SIZE=100 # ceiling, the selector never offers more
NEXT_PUBLIC_RESIZABLE_COLUMNS=1 # drag column edges to resize; 0/false to disableOverride per table with the defaultPageSize / maxPageSize props. The important rule: maxPageSize is a UI ceiling only. Your data layer must clamp the returned page to the same max, because a client can request any size. The users demo does exactly this (Math.min(query.pageSize, MAX_PAGE_SIZE) before it slices the page).
fetcher-backed list, omit initialData and makeEmptyRow, the "+ New" button and CSV/JSON import are hidden when there's nothing to create.Or manage it yourself (onQueryChange)
For full control, set manual instead: RecordView stops processing data (renders it as the current page verbatim) and reports the query via onQueryChange so your backend does the work. Pair it with rowCount (for the totals and page count) and loading (for the shimmer).
const [data, setData] = useState([]);
const [rowCount, setRowCount] = useState(0);
const [loading, setLoading] = useState(true);
const onQueryChange = useCallback((q) => { // { page, pageSize, sort, search, filters }
setLoading(true);
fetchPage(q) // your API call
.then(({ rows, total }) => { setData(rows); setRowCount(total); })
.finally(() => setLoading(false));
}, []);
<RecordView
manual
rowCount={rowCount}
loading={loading}
onQueryChange={onQueryChange}
data={data}
onDataChange={setData}
fields={fields}
/* … */
/>Configure the form footer instead of rebuilding the form
RecordForm ships Cancel + Save (Close + Edit while viewing) and they are ordinary actions, so you change them with the same API that builds them. Give formActions a function and you get the shipped list to work from; give it an array and it replaces them.<RecordView
formActions={(defaults) => [
...defaults,
{
id: "archive",
label: "Archive",
align: "start", // pinned left, where destructive actions belong
variant: "destructive",
confirm: { title: "Archive this record?" },
visible: (ctx) => ctx.mode === "edit",
onAct: async (ctx) => { await api.archive(ctx.row.id); },
},
]}
/* … */
/>Each action takes label, variant, icon, align, confirm, and visible / disabled predicates that read the live form. The context carries mode, row (the draft), dirty, valid, errors, close, reset and edit.
One rule governs what happens next: an action closes the form when it finishes unless it returns false, and an action that validates commits the draft through onSave on the way out. Validation is on for variant: "primary" and off otherwise, which is why Save validates and Cancel doesn't; set requiresValid to override either way. A destructive action therefore closes without saving, and a custom primary action saves exactly like Save. renderFooter(ctx)replaces the footer wholesale for the rare case the array can't express.
after picks what happens once a saving action succeeds: "close" (the default), "stay" on the record just saved, or "new" for a blank one. That last is all Save & New takes: { id: "save-new", label: "Save & New", variant: "primary", after: "new" }. It needs makeEmptyRow on the table, and falls back to "stay" without one.
Form layout: cards across, two columns inside
sectionColumns (1, 2 or 3) is how many cards sit across; they wrap onto as many rows as they need. sections declares the cards, so one can span the row with span: "full". Every card is the same two columns, one field per row: [i] Label * then the control. Full write-up on the Form layout page.<RecordView
sectionColumns={2}
sections={[
{ group: "Customer" },
{ group: "Delivery" },
{ group: "Items", span: "full" },
]}
fields={[
{ key: "customer", label: "Customer", group: "Customer", required: true,
description: "Who the invoice goes to." }, // → tooltip on the label
{ key: "orderedAt", label: "Order date", group: "Customer", input: "date" },
{ key: "notes", label: "Notes", group: "Items" },
]}
/* … */
/>You never align anything. The label column widens to the longest label in that card and never wraps, so every control in the card starts at the same x, and hairlines between the columns and rows make the grid legible without drawing the eye. Multi-column card grids collapse to one column on small screens.
Field help is a tooltip on the label
description shows an info icon before its label, wherever it renders. That text used to appear only in the Info panel on full-page forms, so anyone filling in a slide-over never saw it. Write it as an instruction rather than a definition, because it is read with the cursor already in the box.Put your own content between the fields
formSlots renders a callout, a preview or a pair of custom controls as a full-width row inside a section, so it inherits the card, the separators and the padding instead of floating beside them. afterplaces a slot under that field, in that field's own section; group names a section and puts it at the end; neither means the end of the default section. The render callback gets the live draft, so a slot can react to what is typed.<RecordView
fields={[
{ key: "vatNumber", label: "VAT number", editable: true },
{ key: "notes", label: "Notes", editable: true },
]}
formSlots={[
{ id: "vat-hint", after: "vatNumber", render: () => <Alert>We validate this with HMRC.</Alert> },
]}
/* … */
/>Slots are a prop on the form rather than entries in fields, and that is deliberate: fields is the data contract that drives the table, the filter panel and import/export as well as the form, so arbitrary markup in it would leak layout into all four.
Behaviour is config too
behaviour holds what a table does, as opposed to how it looks, and every key replaces something that used to be hard-coded: rowClick ("view" | "edit" | "none", what clicking a record's name does), closeOnSave, flashMs (the saved-row highlight; 0 turns it off), confirmDelete and confirmDiscardWhenDirty. Set it per table with behaviour={{ rowClick: "edit" }}, or app-wide on the provider.Let the people using the app change some of it
userConfigurable to VuiProvider and those keys become theirs, saved per browser and merged over your config: userConfigurable={{ behaviour: ["rowClick", "flashMs"] }}. Nothing is user-editable unless it is listed, which is the point: a person preferring no row highlight is a feature, a person moving the Save button is chaos. Build the UI from useVuiPreferences() — preferences, setPreference and reset. The Settings page's Data tables section is the working example.One config, four layers
vuiPreset is a plain value the package applies by default, built from the same API you would use, so nothing is locked and nothing is all-or-nothing. Values resolve per-instance prop → VuiProvider config → vuiPreset → package default, and each layer overrides only the keys it mentions. Set <VuiProvider config={{ form: { actions } }}> to change every form in the app; a per-screen formActions still wins. Colors, radius and spacing stay in theme.css on purpose.Return a promise from onDataChange when you save to a server
onDataChange fires on every add, edit, delete and restore, in manual and fetcher mode as well as controlled mode. Return the promise for your write (onDataChange={async (rows) => { await api.save(rows); }}) and RecordView waits for it before reloading, so the reload sees the change. Return nothing and the reload fires immediately, racing your POST and repainting the rows the server had before it — which is what makes a save look like it was ignored, most visibly on Add, where the new row appears and then disappears. In manual mode a returned promise also re-emits the current query through onQueryChange once the write settles, so the page reloads itself.Guard against out-of-order responses
useRefcounter) and ignore a response whose id isn't the latest, so a slow earlier page doesn't overwrite a newer one. Debounce the fetch if keyword changes are chatty. The live Data Table demo (shadcn/ui section) does both against a simulated backend.Persisting across tab switches (no reload on return)
Keep-alive keeps a page mounted, but under the App Router an async page can still remount when you switch tabs, which would re-run the fetch and flash the shimmer. Make returning to the tab feel instant with two things:
const cache = new Map(); // module scope: survives remounts
const onQueryChange = useCallback((q) => {
const hit = cache.get(key(q));
if (hit) { setData(hit.rows); setRowCount(hit.total); setLoading(false); return; }
setLoading(true);
fetchPage(q).then((res) => { cache.set(key(q), res); /* setData… */ });
}, []);
<RecordView manual persistKey="/data-table" onQueryChange={onQueryChange} /* … */ />How do the add and edit forms work?
Every RecordView comes with a buffered add/edit form: edits stay in a draft and commit only when you hit Save. The form is designed from your fields array. Each row aligns the label, icon, required *, and control on one baseline, and every bit of spacing and color comes from theme tokens, so you never style a field by hand. It renders in one of two layouts.
Form controls
Each field picks its control from the same fields array: options → a Select, plus input: "combobox" for a searchable Combobox, input: "number" | "date" for native inputs, else an auto-growing text area.
Need something the built-ins don't cover, like a checkbox, a radio group, a slider, a date-range, your own widget? Use renderInput to drop in any component. It overrides the default control; the field still owns the label, required mark, and Save validation. The Organizations form uses it to render Status as a radio group.
{
key: "status",
label: "Status",
options: STATUS, // still used for View + bulk actions
renderInput: ({ value, onChange, field }) => (
<div role="radiogroup" aria-label={field.label} className="flex gap-4">
{(field.options ?? []).map((o) => (
<label key={o.value} className="flex items-center gap-1.5">
<input type="radio" name={field.key}
checked={value === o.value}
onChange={() => onChange(o.value)} />
{o.label}
</label>
))}
</div>
),
}Cascading (dependent) options
Make options a functionand one field's choices depend on another. In the form it receives the live draft; in the filter it receives the current filter values. When the parent changes and the child's value is no longer valid, RecordView clears the child automatically, both in the Add/Edit form and the Filter. The Cities demo derives State from the selected Country in both.
const statesFor = (country) =>
[...new Set(cities.filter((c) => c.country === country).map((c) => c.state))]
.map((s) => ({ value: s, label: s }));
const fields = [
{ key: "country", input: "combobox", options: COUNTRIES,
filterable: { control: "select", options: COUNTRIES } },
{ key: "state", input: "combobox",
options: (draft) => statesFor(draft.country), // form: from draft
filterable: { control: "combobox",
options: (values) => statesFor(values.country) } }, // filter: from values
];renderInput + function options
options and renderInput, guard with Array.isArray(field.options) before mapping, inside renderInputyou only get the field, not the draft, so a function isn't resolved for you there.Lazy-loading option data (loadOptions)
Remote reference lists (FK pickers like regions, countries, states, 181 timezones …) should never be eager-loaded into a static options array on mount. Give the field a loadOptions source instead and it fetches only when the picker opens, so browsing the table triggers zero option requests. This works in both the form and the filter (unlike onFormOpen, which only covers the form).
const fields = [
{ key: "region", label: "Region", input: "combobox",
loadOptions: ({ search, signal }) => api.regions(search, signal),
resolveOption: (id) => api.region(id) }, // one record for the label
{ key: "country", label: "Country", input: "combobox",
dependsOn: ["region"], // clears when region changes
loadOptions: ({ search, signal, values }) =>
api.countries(values.region, search, signal),
resolveOption: (id) => api.country(id),
// filter uses the same async source:
filterable: { control: "combobox", dependsOn: ["region"],
loadOptions: ({ search, signal, values }) =>
api.countries(values.region, search, signal) } },
];Any picker, not just tables
Combobox / Select: pass source={{ loadOptions, resolveOption }} (+ resetKey for cascades) instead of options, for FK pickers in custom forms or the /settings dropdowns. Loading / empty / error (retry) states render inside the dropdown for you.Slide-over panel (default)
By default the form slides in from the right, over the table. There is nothing to configure.
Full-page form
Set formMode="page" to render the form as a full page instead, with a breadcrumb bar, a padded bordered card, and a fixed Save/Cancel action bar. Use formColumns to arrange the field groups in one or two columns.
<RecordView formMode="page" formColumns={1} /* … */ />Field documentation panel
In page mode, add a formDescription intro and a description on any field, and the form gains an AWS-style help column alongside it.
const fields = [
{ key: "email", label: "Email", editable: true,
description: "Used for billing and account notices." },
];
<RecordView
formMode="page"
formDescription="Organizations are the top-level tenants in the system…"
fields={fields}
/* … */
/>Dedicated routes
To give the form its own URL (say /organizations/new), render the exported RecordForm on that route and have the table navigate to it. Pass data and onDataChange so the table and form share a single data source, and use onCreate / onView / onEdit to navigate rather than open the built-in overlay.
import { RecordForm } from "@viliha/vui-ui/record-view";
// table row: navigate instead of opening the overlay
<RecordView
data={rows}
onDataChange={setRows}
onCreate={() => router.push("/organizations/new")}
onEdit={(id) => router.push(`/organizations/edit?id=${id}`)}
/* … */
/>
// /organizations/new/page.tsx
<RecordForm
isNew
fields={fields}
row={emptyRow}
singular="Organization"
getPrimary={getPrimary}
onSave={(row) => { addRow(row); router.push("/organizations"); }}
onCancel={() => router.push("/organizations")}
/>The page form's breadcrumb defaults to Home › {title} › Create/Update {singular}. Pass a crumbs array to configure it fully, to add a parent, rename the last crumb, whatever the route needs (each crumb is { label, onClick? }; the last is the current page):
import type { Crumb } from "@viliha/vui-ui/breadcrumbs";
<RecordForm
crumbs={[
{ label: "Home", onClick: () => router.push("/") },
{ label: "Access", onClick: () => router.push("/access") },
{ label: "Roles", onClick: () => router.push("/access/roles") },
{ label: "New Role" }, // current page
]}
/* … */
/>Consistent breadcrumbs
@viliha/vui-ui/breadcrumbs component, so the trail, chevron, and back button look identical everywhere.Bulk actions
Check a few rows and an Actionsmenu appears in the toolbar, next to Filter. From there you can set any choice field across the selection ("Set status → …") or delete the whole selection behind a confirm dialog. Any field with optionsjoins the "Set …" list automatically.
Import & export
The toolbar includes Import (CSV / JSON) and Export (CSV / JSON / Excel / PDF) out of the box. Both read the same fields, so there is nothing to configure.
Layout
<RecordView /> straight into a page. It renders its own action header (with breadcrumbs), toolbar, and padded, bordered card, following the page layout conventions without any extra work.