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.
⚠️ RecordView is a Pro component
RecordView up to 1.66 is yours forever and nothing in your lockfile changes. If your data is an array you already hold rather than rows on a server, the free DataTable is the component you want, and free and Pro explains the split and names the promise this broke.Import
import { RecordView, type RecordField } from "@viliha/vui-react/record-view";One component, many features
Building a profile page?
ProfileForm from @viliha/vui-react/profile-form and feed it data + fields. For a company profile, spread the organizationProfileFields preset from @viliha/vui-react/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-react/record-view";
import { Badge } from "@viliha/vui-react/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
title: plural page title (e.g. "Organizations").singular: used on the add button and dialogs ("Organization").fields: the column/field definitions (see below).initialData: the rows (any array of objects with anid).makeEmptyRow: returns a blank row for "+ Add".getPrimary(row): returns{ title, subtitle?, initials }for the row's identity (avatar + panel header).icon: optional page icon.formMode:"panel"(default, slide-over) or"page"(full-page form). See form layouts below.formColumns:1(default) or2field-group columns, in page mode.formDescription: intro text for the page-form documentation panel.resizableColumns: on by default (fromNEXT_PUBLIC_RESIZABLE_COLUMNS, unless set to0/false) — users drag a column's right edge to widen it. Setfalseto opt a single table out and auto-size instead.persistKey: a stable key (e.g. the route) that persists the view's filter / sort / page and the add/edit draft tosessionStorage, so work survives leaving and returning via the open-tabs strip.onFilter(values): called from the Filter panel's Search / Clear when any field isfilterable(see Filtering). Receives the per-field values; run your query or client-side filter here.loading: whiletrue, the table body shows shimmering skeleton rows (for an initial fetch or a refetch). The toolbar stays usable.maxCellChars: characters a cell shows before it truncates to one line with an ellipsis + hover tooltip (long text never wraps). Defaults toNEXT_PUBLIC_MAX_CELL_CHARSor 25; per-columnmaxCharsoverrides it.nameLabel: header for the leading identity column. Default"Name"; set e.g."Title"for tables whose identity is a title field (regions, roles, …).nameSortKey: field the identity column sorts by, so its header toggles + shows a caret like other columns. Defaults to the firsthideInTablefield markedsortable(the one drivinggetPrimary).identityColumn: where the identity (Name/Title) column sits:"first"(default),"last","hidden", or a number = field columns before it (e.g.1→ Region, Title, Code). Lets you order reference tables (Country/State/City) freely.fetcher+cacheKey: server-side mode where RecordView owns the fetch, caching, and loading (see Server-side data). Optionalcache(LRU tuning) andonError.manual+rowCount+onQueryChange: the lower-level server mode: RecordView reports the query and you managedata/loadingyourself.- Toolbar toggles: each defaults to
true, so the full toolbar ships unless you opt out:showFilter,showSort,showPagination(the standard set),showImport/showExport(the ones you'll usually turn off per page),showAdd, andshowSelection.showPagination={false}renders all rows (no page slicing) in client mode;showSelection={false}removes the checkbox column, bulk actions, and Clear selection (and drag-to-reorder, which shares that column). - Row Edit (
showEdit): the one toggle that doesn't default totrue. It follows your fields, so a list where nothing is markededitableshows no row pencil and no Edit button on the view panel. Opening that form would only show an empty body and a Save that writes nothing. PassshowEdit={false}to hide Edit on a table that does have editable fields, orshowEditto force it on. UseonEditwhen you want the action to stay but navigate to your own edit route instead. - Trash mode (
showTrash, off by default): adds a Trash toggle left of Filter that flips the same table between live and soft-deleted rows. RecordView is display-only — the host supplies the trashed rows (trashedDatain client mode, or thetrash: trueflag on theServerQueryinmanual/fetchermode) and persists restores viaonRestore(rows). Restore mirrors delete: a per-row Restore icon and bulk "Restore N selected", each with a confirm. It's wired across the demo tables (Branches, Organizations, Users, the System reference tables and more) — client tables get it fromuseClientFilter(which routes deletes into Trash), and the Users server table via thetrashquery flag.
Field options
Each entry in fields is a RecordField:
key/label: the data key and column header.editable: inline-editable cell + shows in the add/edit panel.required: marks the field with*(in the column header, including the primary Name column, and beside the form label) and validates on save.copyable: a copy-to-clipboard button on hover.maxChars: truncate this column's cells at N characters (ellipsis + tooltip); overrides the view'smaxCellChars.0= never truncate this column.hideInTable: keep it in the panel but not as a column.sortable: decouple sorting from column visibility. Defaults to sortable when it's a visible column; settrueto sort a field with no column (e.g. ahideInTablename shown viagetPrimary), orfalseto keep a visible column unsortable.render(row): custom cell content (badges, formatted numbers…).description: help text shown in the page-form documentation panel.options: makes it a choice field and, when the field is alsoeditable, adds a "Set {label}" bulk action (bulk writes follow the same rule as the form). Renders aSelectin the form; addinput: "combobox"for a searchableCombobox(long lists). The table cell shows the option's label (e.g.SYSTEM→ "System"), staying editable, with norenderneeded. A static array, or a function of the draft for dependent/cascading options (see below).input: form control."text"(default),"number","date","checkbox"(boolean → checkbox in the form, Yes/No in cells), or"combobox"(searchable, needsoptions).- Validation — declarative rules that run on blur + before Save, block Save while invalid, and show the message inline under the field:
min/max(character length, or numeric value forinput:"number"),pattern(regex, withpatternMessage),format: "email" | "phone"("phone"also auto-formats as(123) 456-7890),validate(value, draft)(return a message, or nothing when valid), andtrim(strip whitespace on Save). Reference: the Branches Add/Edit form. renderInput: render a custom Add/Edit control (checkbox, radio group, upload, anything); you get{ value, onChange, field, invalid }. Set it alongsiderenderto get a custom view and a custom edit control — the form showsrenderwhile viewing andrenderInputwhile editing (e.g. a logo preview you can replace in Edit).group: form section title. Any string works; the page form renders one section per group, in first-appearance order (e.g. "Organization information", "Brand assets"). Ungrouped fields fall under "General".multiple: multi-select(many-to-many). The field's value becomes astring[]; the form renders a searchable multi-select with removable chips (from staticoptionsor asyncloadOptions), and the read cell shows up tomaxChipsInCell(default 3) labels then "+N". Async fields addresolveOptions(values)(batch companion toresolveOption).required⇒ at least one. Reference: Markets → Post Codes.filterable: expose the field in the Filter panel as a labeled control (see Filtering).true= text input; pass a config to choose the control.icon: column-header icon.width: initial column width (px). Columns auto-size otherwise, and users can drag-resize them (on by default viaresizableColumns).align:"left"/"center"/"right"(see below).
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-react/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-react/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(); },
},
]}
/* … */
/>- A function receives the shipped list so you can add to it; an array replaces it; an empty array hides the menu.
table.importActions/table.exportActionsonVuiProviderset it for every table at once, so an app can route all of its import and export through its API in one place.pickFileopens the file picker and passes the file asctx.file;acceptfilters what can be chosen. Leave it off and the action runs straight away, which is what an API-triggered import wants.visible(ctx)hides an entry per state, for example offering "Export all" only to an admin.
Exporting more than the page you can see
ctx.rows is what is on screen: filtered, sorted, and in fetcher mode 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
cache={false}turns it off for a list where even a moment of last-known data is wrong: stock levels, payment status, anything someone acts on immediately.cache={{ ttlMs, max }}tunes it. PastttlMs(default 60s) a page isn't painted from cache at all and the shimmer shows instead.- It is off entirely when keep-alive tabs are off (
NEXT_PUBLIC_KEEP_ALIVE_TABS=0), because holding a page in memory between visits is that same feature. clearRecordViewCache(cacheKey?)drops cached pages when something outside the table changed the data: a websocket event, a bulk job, an edit on another screen.- Superseded requests are aborted via the
signal, and stale responses are ignored, so no out-of-order flicker to handle yourself. - Edits/adds/deletes update optimistically, invalidate the cache, and refetch the current query in the background.
cache={{ max, ttlMs }}tunes the LRU;onErroris called if a fetch rejects (the last data stays).
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).
onQueryChange(query)fires on page, page-size, sort, and keyword changes (and once on mount for the initial load); per-field filters fire it on the Filter panel's Search / Clear.queryis{ page, pageSize, sort, search, filters }, everything you need to build the request (pageis 1-based).- Feed the response back into
data+rowCount, and toggleloadingaround the fetch.
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:
- Cache responses in a module-scoped
Mapkeyed by the query (or your data layer's cache, like React Query or SWR). A remount then finds the page in memory: serve it synchronously and skip the loading state, so there's no round-trip and no shimmer. - Pass
persistKeyso the current page, sort, and filters survive the remount, andonQueryChangefires with the restored query, hits the cache, and you land back on the exact same view.
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).
loadOptions({ search, signal, values }): fetch on open (empty search) and debounced (250 ms) per keystroke;valuesis the live draft / filter values,signalaborts superseded requests.resolveOption(value): resolve an already-set or defaulted value's label from a single record, so edit/view never loads the whole list just to show one label.dependsOn: [parentKey]for cascades: a parent change clears this field's cached options + value, and the next open re-fetches.resolveOptions(values): resolve a whole set of ids in one call. Every cell of that column painted in the same tick is collected and asked for together, so a 50-row page costs one request instead of 50. With onlyresolveOption, ids already in flight are shared, which still saves the repeats.displayValue(row): skip resolution altogether. When the payload already carries the label beside the id ({ countryId, country }), return it and the read display paints instantly with no request. It only supplies the text, so the cell keeps its alignment, truncation and copy button, and the edit control is unaffected.- What a reader sees while a label resolves: a skeleton, never the stored id.
Region 1tells a user nothing, and a uuid tells them less. Read cells, view panels and picker triggers all shimmer until the label lands, and a value that resolves to nothing reads—, because an unresolvable reference is missing data rather than a value.
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-react/record-form";
// 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-react/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-react/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.