Async Boundary

PreviousNext

One wrapper that takes a useQuery result and resolves its four states — pending, error, empty, success — on a single motion system.

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/async-boundary.json

Usage

import { AsyncBoundary } from '@/components/matos-ui/async-boundary'
const query = useQuery({ queryKey: ['tasks'], queryFn: fetchTasks })

<AsyncBoundary query={query}>
  {(tasks) => <TaskList tasks={tasks} />}
</AsyncBoundary>

Pass the whole useQuery result. The boundary reads status, data and error and resolves to one of four states. children is a node, or a function given the data — already guaranteed non-empty.

The four states

StateWhenWhat shows
pendingstatus === 'pending'A generic shimmer (or your pending).
errorstatus === 'error'An error card with "Try again".
emptysuccess and isEmpty(data)The empty state (default: data null or an empty array).
successsuccess and non-emptychildren.

Surface: error lifts a rung

The error state is Elevated offset={1} — a surface above the content. Error interrupts, and the elevation says so without red: the icon is text-muted-foreground, the rung carries the weight.

The skeleton and the content sit at the same offset (flush). That is what keeps the swap from jumping — no surface appears or disappears, only the content inside it.

Motion

  • The container animates its own height with layout, so the box grows and shrinks between states instead of jumping.
  • States swap through AnimatePresence mode="wait" — one at a time. Out on an ease.accelerate tween (get out of the way), in on spring.moderate.
  • Exception: the content enters on spring.fast. It is a response — the data arrived, show it now.
  • The skeleton pulses on an ambient, receding beat (three duration.slower, with a per-row delay that reads as a wave). That ambient-vs-responsive contrast is the tier system's whole point.
  • prefers-reduced-motion keeps the crossfade and drops the height animation, the travel and the pulse.

Customising each state

<AsyncBoundary
  query={query}
  isEmpty={(data) => data.items.length === 0}
  pending={<TaskListSkeleton />}
  empty={{
    title: 'No tasks',
    description: 'Anything you create shows up here.',
    action: <Button onClick={create}>New task</Button>,
  }}
  errorFallback={(error, retry) => <MyError error={error} onRetry={retry} />}
>
  {(data) => <TaskList tasks={data.items} />}
</AsyncBoundary>

A disabled query (enabled: false) sits in pending — it shows the skeleton until enabled.

Reference

AsyncBoundary Props

PropTypeDefaultDescription
query{ status, data, error, refetch?, isFetching?, fetchStatus? }The useQuery result (typed structurally).
childrenReactNode | ((data: T) => ReactNode)Rendered on success.
isEmpty(data: T) => booleannull / []Decides the empty state.
pendingReactNodeshimmerThe loading state.
emptyReactNode | { icon, title, description, action }default stateThe empty state.
errorFallbackReactNode | ((error, retry) => ReactNode)default cardThe error state.
onRetry() => voidquery.refetchHandler for "Try again".
fetchingIndicatorbooleantrueA thin indeterminate bar while a success query refetches.

QueryLike<T> and EmptyStateConfig are exported — useful for typing a mock or an adapter for another data-fetching library.