QueryState
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/query-state.jsonEvery list writes this ladder, and writes it slightly differently each time. What differs is never deliberate: one screen forgets the empty case, another shows a spinner where the shape was known, a third prints the raw error object. This is that ladder, once.
It takes values, not a query. useQuery hands back isPending and
error, and those are ordinary values — so this asks for them rather than for
the query result, and works the same with TanStack Query, with SWR, with a
reducer, or with two useState calls:
const works = useQuery({ queryKey, queryFn })
<QueryState pending={works.isPending} error={works.error} empty={works.data?.length === 0} errorLabels={{ title: 'Could not load' }} emptyState={<EmptyState title="No works yet" />}> <Works rows={works.data} /></QueryState>Taking the result object would put a library in the registry — and therefore in every product that installs this primitive — to save one line at the call site.
The order of the cases is the component, and it is the part that goes wrong by hand:
- pending, because a refetch that already has data should not blank the
screen it is refreshing — the caller decides that by passing
pendingonly when there is nothing to show; - error, because an error with stale data is still an error, and “nothing found” for a request that failed is a lie;
- empty, which is the only one that cannot be known until something has arrived.
aria-busy is on the region, said once — which is why the
Skeletons inside are aria-hidden.
empty is computed by the caller, because only the caller knows whether
empty means [], null, or a count of zero.
It offers the retry it is handed. A failed load with no way to try again
is a dead end, and kilna had nineteen of them — every one wrote “could not
load” and none called the refetch it already had. Pass onRetry with the
word for it, and the default error screen gets the button; a screen you draw
yourself is handed the retry as its second argument.
<QueryState pending={works.isPending} error={works.error} onRetry={() => works.refetch()} retryLabel={t('retry')} errorLabels={{ title: t('works.loadFailed') }}> …</QueryState>onRetry and retryLabel travel together in the type: a retry button exists
only where there is a word for it, and the word is the product’s. plain draws
the default error screen without its frame, for a list inside a panel.
What it does not do: fetch, retry or cache. Those belong to whatever owns the data, and a component that guessed at them would be wrong for the product that owns them differently. A crash while rendering is ErrorBoundary, not this.
Props
Read from the types in ui/query-state.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
| Prop | Type | Default | |
|---|---|---|---|
onRetrywith retryLabel | () => void | A way out of a failure: puts a retry button on the default error screen and is handed to a custom errorState. Requires retryLabel. | |
retryLabelwith onRetry | string | What the retry button is called. Required with onRetry, because a string invented here could not be translated. | |
pending | boolean | false | Nothing has arrived yet. |
error | string | { message: string; } | null | null | It failed. Anything with a message, which is what every error library agrees on - or a string, for a product that carries its own. |
empty | boolean | false | Something arrived, and it was nothing. Computed by the caller, because only the caller knows whether empty means [], null or a count of zero. |
skeleton | ReactNode | What stands in while pending. A shape, ideally - the default is a list, because most screens are waiting for one, but a screen waiting for a card should say so. | |
emptyState | ReactNode | What is shown when nothing came back. | |
errorState | (message: string, retry?: (() => void) | undefined) => ReactNode | What is shown when it failed. Given the message, so a product can put it where it likes - or ignore it - and the retry, when there is one. | |
errorLabels | { title: string; body?: string | undefined; } | The words on the default error screen. Required only in the sense that the default screen needs them; pass errorState instead and they are never read. | |
plain | boolean | false | Draw the default error screen without its frame, for a list inside a panel or a widget that already has one. The empty screen is the caller's, and says for itself whether it is plain. |
childrenrequired | ReactNode |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 12 rules applied and passed, and none failed. The accessibility report has every item.
History
Each release that changed ui/query-state.tsx - the file a product copies - newest first.