npm

Reference

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

terminal
import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";

One component, many features

Editable cells · resizable columns (on by default) · sticky header · sort · filter · column show/hide · pagination · row actions (view / edit / delete) · required-field markers · buffered add/edit form (slide-over or full-page) · bulk actions (set field / delete) · CSV / JSON / Excel / PDF import & export · auto-aligned columns.

Building a profile page?

For a single record shown read-only with an Edit → Cancel + Save flow, don't hand-wire it — import the pre-designed 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

The 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.

organizations-table.tsx
"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

  • 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 an id).
  • 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) or 2 field-group columns, in page mode.
  • formDescription: intro text for the page-form documentation panel.
  • resizableColumns: on by default (from NEXT_PUBLIC_RESIZABLE_COLUMNS, unless set to 0/false) — users drag a column's right edge to widen it. Set false to 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 to sessionStorage, so work survives leaving and returning via the open-tabs strip.
  • onFilter(values): called from the Filter panel's Search / Clear when any field is filterable (see Filtering). Receives the per-field values; run your query or client-side filter here.
  • loading: while true, 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 to NEXT_PUBLIC_MAX_CELL_CHARS or 25; per-column maxChars overrides 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 first hideInTable field marked sortable (the one driving getPrimary).
  • 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). Optional cache (LRU tuning) and onError.
  • manual + rowCount + onQueryChange: the lower-level server mode: RecordView reports the query and you manage data/loading yourself.
  • 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, and showSelection. 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 to true. It follows your fields, so a list where nothing is marked editable shows 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. Pass showEdit={false} to hide Edit on a table that does have editable fields, or showEdit to force it on. Use onEdit when 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 (trashedData in client mode, or the trash: true flag on the ServerQuery in manual/fetcher mode) and persists restores via onRestore(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 from useClientFilter (which routes deletes into Trash), and the Users server table via the trash query 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's maxCellChars. 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; set true to sort a field with no column (e.g. a hideInTable name shown via getPrimary), or false to 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 also editable, adds a "Set {label}" bulk action (bulk writes follow the same rule as the form). Renders a Select in the form; add input: "combobox" for a searchable Combobox (long lists). The table cell shows the option's label (e.g. SYSTEM → "System"), staying editable, with no render needed. 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, needs options).
  • 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 for input:"number"), pattern (regex, with patternMessage), format: "email" | "phone" ("phone" also auto-formats as (123) 456-7890), validate(value, draft) (return a message, or nothing when valid), and trim (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 alongside render to get a custom view and a custom edit control — the form shows render while viewing and renderInput while 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 a string[]; the form renders a searchable multi-select with removable chips (from static options or async loadOptions), and the read cell shows up to maxChipsInCell (default 3) labels then "+N". Async fields add resolveOptions(values) (batch companion to resolveOption). 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 via resizableColumns).
  • 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.

alignment
{ 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 override

How 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.

sortable, independent of columns
{ 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 sortable

How 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.

per-field filters
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).

filterExtras
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

The panel collects values; it does not filter the table in per-field mode. Handle 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.
wire onFilter (server or client)
<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.

loading around a 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.

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.

fetcher + cacheKey
<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.

server-side export, server-side import
<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.exportActions on VuiProvider set it for every table at once, so an app can route all of its import and export through its API in one place.
  • pickFile opens the file picker and passes the file as ctx.file; accept filters 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 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

Both scrollbars stay visible whenever there is somewhere to scroll, rather than appearing only once you move. macOS hides overlay scrollbars by default, which makes a table wider than its container look like it just ends at the last column. The space is reserved too, so nothing shifts sideways when a bar appears. The utility behind it is vui-scroll in theme.css: put it on any scroll region you add.

The cache paints, the server decides

A cached page is only ever used to paint immediately. The request still goes out on every query, and the server's answer replaces what was shown, so the cache can make a list appear instantly but can never be the final answer. Painting from cache skips the shimmer and refreshes quietly underneath, rather than flashing over rows someone is reading.
  • 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. Past ttlMs(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; onError is 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:

.env
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 disable

Override 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).

For a read-only or 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.
  • query is { page, pageSize, sort, search, filters }, everything you need to build the request (page is 1-based).
  • Feed the response back into data + rowCount, and toggle loading around the fetch.
server-side table
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.
adding an action
<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

A form is a grid of section cards. 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.
a two-column Add Order form
<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

A field with a 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.
a slot and a full-width field
<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

Pass 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

The preconfigured theme is itself a config. 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

A fast user can fire several queries before earlier ones resolve. Track a request id (a 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 Map keyed 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 persistKey so the current page, sort, and filters survive the remount, and onQueryChange fires with the restored query, hits the cache, and you land back on the exact same view.
cache + persistKey → instant return
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.

custom control via renderInput
{
  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.

Country → State cascade
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

If a field uses both a function 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; values is the live draft / filter values, signal aborts 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 only resolveOption, 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 1 tells 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.
async FK picker with a Region → Country cascade
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

The same engine powers standalone 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.

page form
<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.

documentation panel
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.

routed create form
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):

custom breadcrumb
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

Both layouts and the rest of the app pages share a single @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

Drop <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.