Ballmac UI home

Handle loading, empty and error states

Drive a data block or a form block from your own fetching with the state prop, and wrap your own views in StateView.

On this page

Show a data block while it loads

Lists, boards, dashboards, calendars and chats take state, onRetry, emptyState and errorState. useResource turns a fetch into the state to pass:

app/inbox.tsx
import { Mail1 } from "@/components/ballmac/blocks/mail-1/mail-1"
import { useResource } from "@/lib/ballmac/use-resource"

export function Inbox() {
  const mail = useResource((signal) => fetch("/api/mail", { signal }).then((r) => r.json()))
  return (
    <Mail1
      state={mail.state} // "loading" | "ready" | "empty" | "error"
      messages={mail.data}
      onRetry={mail.retry}
      emptyState={{ title: "Inbox zero", description: "Nothing new. Enjoy it." }}
      errorState={{ description: "Mail is taking a break. Your messages are safe." }}
    />
  )
}

useResource aborts a request that is replaced or unmounted, never lets a slow old answer overwrite a newer one, and goes round again on retry().

Use TanStack Query or SWR instead

If you already use TanStack Query or SWR, map their result:

import { stateOf } from "@/lib/ballmac/use-resource"

const query = useQuery({ queryKey: ["mail"], queryFn: fetchMail })
<Mail1 state={stateOf(query)} messages={query.data} onRetry={query.refetch} />

Drive a form block

Sign-in, sign-up, contact, newsletter, settings and the rest already run their own submit flow. state lets you drive it from your own async code, or show one stage on its own: submitting locks the fields, success shows the confirmation, error shows the failure. errorState.description sets the message.

const [phase, setPhase] = useState<"ready" | "submitting" | "success" | "error">("ready")

<Signup1
  state={phase}
  errorState={{ description: "That email is already registered." }}
  onSubmit={async (values) => {
    setPhase("submitting")
    try { await createAccount(values); setPhase("success") } catch { setPhase("error") }
  }}
/>

Leave state out and the form decides by itself, as before. Several forms also take defaultValues or defaultEmail to prefill.

Wrap your own view

StateView is the part the blocks are built on. Wrap anything that loads:

<StateView
  state={invoices.state}
  loading={<InvoiceSkeleton />} // the same shape as the real list
  empty={{ title: "No invoices", action: <Button>Create one</Button> }}
  error={{ title: "Invoices are unavailable", details: String(invoices.error) }}
  onRetry={invoices.retry}
>
  <InvoiceList items={invoices.data} />
</StateView>

The props of StateView and useResource are in the building blocks reference.