import * as React from "react"
import { AsyncContent } from "@hel/ui/async-content"When to use it#
Use AsyncContent around anything that comes from a request: a table, a list, a card, a form that loads a record. Without it, every screen has the same four branches to write, and each team writes them a little differently: still loading, failed, loaded but empty, and loaded. This wrapper decides which one to show, and does the details well.
- A skeleton shows only when loading takes longer than a moment, so fast loads do not flash. Once shown, it stays long enough not to blink.
- A slow load says so. You can give your own messages for it.
- A failure shows an error with "Try again", and after two tries a place for "Contact support".
- Data that was already loaded stays on screen when a refresh fails or the connection drops, with a banner that says so, instead of being thrown away.
- An empty result shows an empty state.
Import#
import { AsyncContent } from "@hel/ui/async-content"Usage#
Give it the state of your request. A result from TanStack Query fits as it is, and so does one from SWR or a hand-made object with the same fields. The library does not depend on any data library.
Put what to show for the first load in skeleton. The library has ready-made skeletons, such as TableSkeleton, ListSkeleton, CardSkeleton, FormSkeleton and StatSkeleton. The children are the loaded content, as a function that gets the data, typed and never undefined.
const invoices = useQuery({ queryKey: ["invoices"], queryFn: loadInvoices })
<AsyncContent query={invoices} skeleton={<TableSkeleton columns={3} rows={5} />}>
{(rows) => <InvoiceTable rows={rows} />}
</AsyncContent>Instead of query, you can pass the fields one by one: data, error, isPending, isError, isFetching, isPaused, failureCount and onRetry. A field you pass wins over the same field in query, which lets you combine several requests.
Try the buttons above. Each one changes what the made-up server does, and the same wrapper handles all four cases.
Examples#
Your own messages and empty state#
loadingSteps puts messages under the skeleton as the wait goes on, like a Timed Message: one at once, another after a few seconds. empty replaces the plain empty state with your own, for example one that offers to clear the filters.
Loading your invoices...
import { FilterX, SearchX } from "lucide-react"
import { AsyncContent } from "@hel/ui/async-content"Errors#
By default, a failure shows an Error State worded by the kind of error: no access, not found, a server problem, or no connection. Pass errorView to draw your own. As a function, it gets the error and { retry, retrying, tries }. support is shown next to "Try again" once it has failed twice. Automatic retries keep the skeleton on screen, and a short note says it is still trying after retryNoteAfter failed attempts, 2 by default.
Timing#
| Prop | What it does | Default |
|---|---|---|
delayMs | Waits this long before showing the skeleton | 200 |
minDurationMs | Keeps the skeleton at least this long once shown | 400 |
slowAfterMs | Says the load is slow after this long | 8000 |
Props#
| Prop | Type | Default |
|---|---|---|
query | a query result | none |
skeleton | ReactNode | required |
children | ReactNode | (data) => ReactNode | required |
empty | ReactNode | A plain empty state |
isEmpty | boolean | (data) => boolean | No data, or an empty list |
errorView | ReactNode | (error, actions) => ReactNode | An error state |
support | ReactNode | none |
onRetry | () => unknown | query.refetch |
loadingSteps | { after?, text }[] | none |
retryNoteAfter | number | 2 |
size | "sm" | "md" | "md" |
messages | Partial<AsyncContentMessages> | Built-in text |
locale | string | The app's locale |