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 Schema

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