One wrapper that takes a useQuery result and resolves its four states — pending, error, empty, success — on a single motion system.
pnpm dlx shadcn@latest add https://matos-ui.com/r/async-boundary.jsonimport { 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.
| State | When | What shows |
|---|---|---|
pending | status === 'pending' | A generic shimmer (or your pending). |
error | status === 'error' | An error card with "Try again". |
empty | success and isEmpty(data) | The empty state (default: data null or an empty array). |
success | success and non-empty | children. |
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.
layout, so the box grows and
shrinks between states instead of jumping.AnimatePresence mode="wait" — one at a time. Out on an
ease.accelerate tween (get out of the way), in on spring.moderate.spring.fast. It is a response — the
data arrived, show it now.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.<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.
| Prop | Type | Default | Description |
|---|---|---|---|
query | { status, data, error, refetch?, isFetching?, fetchStatus? } | — | The useQuery result (typed structurally). |
children | ReactNode | ((data: T) => ReactNode) | — | Rendered on success. |
isEmpty | (data: T) => boolean | null / [] | Decides the empty state. |
pending | ReactNode | shimmer | The loading state. |
empty | ReactNode | { icon, title, description, action } | default state | The empty state. |
errorFallback | ReactNode | ((error, retry) => ReactNode) | default card | The error state. |
onRetry | () => void | query.refetch | Handler for "Try again". |
fetchingIndicator | boolean | true | A 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.
Install Matos UI
Choose a package manager and copy one command for every component.