Skip to content
KaUI is under active development. If you run into a bug, please open an issue.

Async Scope

A coordination layer for async UI , decouple what fires an action from what reacts to it.

pnpm dlx shadcn@latest add @kaui/async-scope
import {
AsyncScope,
AsyncContent,
AsyncTrigger,
useAsyncScope,
} from "@/components/ui/async-scope";
<AsyncScope action={syncData} onSuccess={() => toast.success("Synced")}>
<AsyncTrigger>Sync</AsyncTrigger>
<AsyncContent>
<YourCard />
</AsyncContent>
</AsyncScope>

useAsyncScope gives child components direct access to the full async state — including isError, reset, and execute. This example uses all three to build a self-contained retry flow inside the card itself. The trigger is disabled during the retry so the user can’t fire duplicate requests.

Any component inside AsyncScope can subscribe to state — not just AsyncContent. Here a status badge lives outside the card entirely, reacting to isIdle → isLoading → isSuccess | isError without being part of the loading area.

useAsyncScope is the escape hatch. Use it when AsyncTrigger and AsyncContent aren’t expressive enough.

function RetryButton() {
const { isError, execute, reset } = useAsyncScope();
if (!isError) return null;
return (
<Button
variant="destructive"
onClick={() => {
reset();
execute();
}}
>
Retry
</Button>
);
}
function SuccessBanner() {
const { isSuccess, data } = useAsyncScope<{ syncedAt: string }>();
if (!isSuccess) return null;
return (
<p className="text-xs text-emerald-500">
Last synced {new Date(data.syncedAt).toLocaleTimeString()}
</p>
);
}

Most apps have three distinct concerns when handling an async action. AsyncScope owns exactly one of them.

┌──────────────────────────────────────────────────────┐
│ Data layer — TanStack Query, SWR, tRPC, fetch │
│ Owns: caching, deduplication, background refetch │
├──────────────────────────────────────────────────────┤
│ State machine — useAsync │
│ Owns: loading state, race protection, callbacks │
├──────────────────────────────────────────────────────┤
│ Coordination layer — AsyncScope ← this │
│ Owns: broadcasting state to the component tree │
├──────────────────────────────────────────────────────┤
│ UI primitives — AsyncTrigger, AsyncContent │
│ Owns: rendering each state (idle/loading/error) │
└──────────────────────────────────────────────────────┘

AsyncScope is not a replacement for your data library. It is a UI coordination layer — its only job is to take the result of an action and make the async state available to any component in its subtree. No caching, no deduplication, no background refetch.

That means they compose. If you already use TanStack Query, pass mutation.mutateAsync as the action. TanStack Query owns the data layer; AsyncScope owns the coordination:

const mutation = useMutation({ mutationFn: syncAccount });
<AsyncScope action={mutation.mutateAsync}>
<AsyncTrigger>Sync</AsyncTrigger>
<AsyncContent>
<AccountCard />
</AsyncContent>
</AsyncScope>;

If you don’t have a data library, useAsync (the built-in state machine) handles the execution. Either way, AsyncScope’s API doesn’t change.

ComponentUse when
AsyncButton

The button itself is the loading indicator — self-contained, no outside coordination needed

AsyncScope

The trigger and the loading area are different elements, or multiple areas update from one action

useAsyncYou need the raw state machine with no opinion on rendering
PropTypeDefaultDescription
action() => Promise<TData> | TDataThe async function to execute when triggered
onSuccess(data: TData) => voidCalled after action resolves
onError(error: TError) => voidCalled if action throws
onSettled(data, error) => voidCalled after either outcome

All Button props except onClick. Automatically disabled while the scope is loading.

PropTypeDefaultDescription
loadingFallbackReactNode

<Loader2 />

Shown in place of children while loading
classNamestringApplied to the visible content wrapper
const {
// status flags
isIdle,
isLoading,
isSuccess,
isError,
// data
data,
error,
// actions
execute,
reset,
} = useAsyncScope<TData, TError>();

Returns the full async state from the nearest AsyncScope. Pass generics to type data and error. Throws if called outside an AsyncScope.