Async Content

One wrapper for anything that loads, which shows a skeleton, the data, an empty state or an error for you.

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

No invoices match these filters
Try another search, or clear the filters.
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

PropWhat it doesDefault
delayMsWaits this long before showing the skeleton200
minDurationMsKeeps the skeleton at least this long once shown400
slowAfterMsSays the load is slow after this long8000

Props

PropTypeDefault
querya query resultnone
skeletonReactNoderequired
childrenReactNode | (data) => ReactNoderequired
emptyReactNodeA plain empty state
isEmptyboolean | (data) => booleanNo data, or an empty list
errorViewReactNode | (error, actions) => ReactNodeAn error state
supportReactNodenone
onRetry() => unknownquery.refetch
loadingSteps{ after?, text }[]none
retryNoteAfternumber2
size"sm" | "md""md"
messagesPartial<AsyncContentMessages>Built-in text
localestringThe app's locale