Skip to content

QueryState

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/query-state.json
See it livePending, failed, empty and ready - press through them.→

Every 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:

  1. pending, because a refetch that already has data should not blank the screen it is refreshing — the caller decides that by passing pending only when there is nothing to show;
  2. error, because an error with stale data is still an error, and “nothing found” for a request that failed is a lie;
  3. 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.

PropTypeDefault
onRetrywith retryLabel() => voidA way out of a failure: puts a retry button on the default error screen and is handed to a custom errorState. Requires retryLabel.
retryLabelwith onRetrystringWhat the retry button is called. Required with onRetry, because a string invented here could not be translated.
pendingbooleanfalseNothing has arrived yet.
errorstring | { message: string; } | nullnullIt failed. Anything with a message, which is what every error library agrees on - or a string, for a product that carries its own.
emptybooleanfalseSomething arrived, and it was nothing. Computed by the caller, because only the caller knows whether empty means [], null or a count of zero.
skeletonReactNodeWhat 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.
emptyStateReactNodeWhat is shown when nothing came back.
errorState(message: string, retry?: (() => void) | undefined) => ReactNodeWhat 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.
plainbooleanfalseDraw 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.
childrenrequiredReactNode

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.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.33.0
    • feat plain empty states and a retry for a failed load ce765d4
  3. v0.23.0
    • feat the screens between asking for data and showing it bc3f636