useFormSkeleton & useTableSkeleton
Loading placeholders shaped like the real data — field and column descriptors from the same Schema the real UI uses.
A generic spinner tells the user nothing and reserves no space, so the layout
jumps the moment data arrives. The usual fix is to hand-write a placeholder with
"about four grey bars" — which then silently drifts from the form it is standing
in for. These two hooks remove that guesswork: the descriptor's Schema already
knows exactly how many fields the form will render and which columns the table
will have, before any data is fetched. Same source as <AutoForm> and
<DataTable>, so the placeholder and the real UI cannot disagree.
import { useFormSkeleton, useTableSkeleton } from '@voltro/client'
const fields = useFormSkeleton('app', 'todos.create') // mutation INPUT Schema
const columns = useTableSkeleton('app', 'todos.list') // query OUTPUT SchemaBoth take (apiName, tag) and return a ReadonlyArray<FieldDescriptor>. That
is the whole signature — there are no options.
Reach for the component first
Most of the time you do not need these hooks at all — @voltro/ui ships
<FormSkeleton> and <TableSkeleton>, which call them for you:
import { FormSkeleton, TableSkeleton } from '@voltro/ui'
{loading ? <FormSkeleton api="app" mutation="todos.create" /> : <AutoForm api="app" mutation="todos.create" />}
{loading ? <TableSkeleton api="app" query="todos.list" rows={5} /> : <DataTable api="app" query="todos.list" />}Both accept a fallback count for the moment the descriptor is not resolvable yet
(fallbackFields, default 3; fallbackColumns, default 4), and both mark
themselves aria-busy + aria-hidden so a screen reader never announces the
placeholder bars.
The hooks are the headless layer underneath. Reach for them when the shipped markup does not fit your design system and you want to render the placeholder yourself:
{loading
? fields.map((f) => <div key={f.name} className="h-10 animate-pulse rounded bg-muted" />)
: <AutoForm api="app" mutation="todos.create" />}Each FieldDescriptor carries name, label (the humanised property name),
widget ('text' | 'textarea' | 'number' | 'checkbox' | 'switch' | 'select' | 'radio' | 'async-select' | 'multi-select' | 'date' | 'datetime' | 'daterange' | …), required, nullable, an optional options array for closed value sets,
and the resolved jsonSchema node. widget is what makes the placeholder
faithful — a textarea deserves a taller bar than a checkbox.
Three things that will surprise you
They know nothing about loading. Neither hook subscribes to anything or
tracks a request; they read a Schema. The loading flag in the example above is
yours — from the useSubscription / useMutation whose data you are waiting
on. They are cheap and synchronous, and they work during SSR, which is why the
shape is correct on the very first paint.
An unknown tag returns [], silently. A typo in the api name or the tag
produces an empty array, not an error — so a skeleton that renders nothing is
almost always a misspelled tag, not a Schema the hooks failed to read.
Neither hook checks the descriptor's kind. useFormSkeleton reads the
descriptor's input; useTableSkeleton reads its output. Pass a query tag to
useFormSkeleton and you get that query's input fields — useful for a filter
panel's skeleton, but it means nothing stops you from pairing the wrong hook
with the wrong tag. useTableSkeleton additionally returns [] unless the
output Schema is an array of objects.
The labels are humanised property names, not the Schema title annotation —
built-in refinements carry a type-name title (nonEmptyString) that would leak
into the UI. That matches what <AutoForm> renders, so the placeholder's label
widths line up with the real ones.