Async Scope
A coordination layer for async UI , decouple what fires an action from what reacts to it.
$12,345
1,234
5,678
99.9%
import { AsyncScope, AsyncContent, AsyncTrigger, useAsyncScope,} from "@/components/ui/async-scope"";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { RefreshCwIcon } from "lucide-react";import { toast } from "sonner";
type AccountData = { syncedAt: string };
const syncDashboard = async (): Promise<AccountData> => { await new Promise((r) => setTimeout(r, 2000)); return { syncedAt: new Date().toISOString() };};
function SyncStatus() { const { isIdle, isLoading, isSuccess, isError, data } = useAsyncScope<AccountData>();
if (isIdle) return null;
if (isLoading) return ( <span className="animate-pulse text-xs text-muted-foreground"> Syncing… </span> );
if (isError) return <span className="text-xs text-destructive">Sync failed</span>;
if (isSuccess) return ( <span className="text-xs text-emerald-500"> Synced at {new Date(data.syncedAt).toLocaleTimeString()} </span> );
return null;}
const stats = [ { label: "Revenue", value: "$12,345" }, { label: "Orders", value: "1,234" }, { label: "Users", value: "5,678" }, { label: "Uptime", value: "99.9%" },];
export function MultiContentAsyncScope() { return ( <AsyncScope action={syncDashboard} onSuccess={() => toast.success("Dashboard synced")} onError={() => toast.error("Sync failed")} > <div className="flex w-full max-w-xs flex-col gap-4"> <AsyncTrigger variant="outline" className="w-fit gap-2"> <RefreshCwIcon className="size-4" /> Sync Dashboard </AsyncTrigger> <SyncStatus />
<div className="grid grid-cols-2 gap-3"> {stats.map(({ label, value }) => ( <Card key={label}> <AsyncContent> <CardHeader className="pb-1"> <CardTitle className="text-xs font-medium text-muted-foreground"> {label} </CardTitle> </CardHeader> <CardContent> <p className="text-2xl font-bold">{value}</p> </CardContent> </AsyncContent> </Card> ))} </div> </div> </AsyncScope> );}Installation
Section titled “Installation”Install the following dependencies
Copy and paste the following code into your project.
components/ui/async-scope.tsx
import { createContext, useContext, type ComponentProps, type PropsWithChildren, type ReactNode,} from "react";import { useAsync, type UseAsyncOptions, type UseAsyncReturn,} from "@/hooks/use-async";import { Button } from "@/components/ui/button";import { Loader2 } from "lucide-react";import { cn } from "@/lib/utils";
const AsyncScopeContext = createContext<UseAsyncReturn< unknown, unknown, []> | null>(null);
/** * Read the async state from the nearest `AsyncScope`. * * ⚠️ The `TData` / `TError` generics are an **assertion, not a guarantee**. * Context can't infer them across the provider boundary, so you must pass the * same types the enclosing `<AsyncScope action={...}>` actually produces. * Mismatched generics compile cleanly but are wrong at runtime. */export function useAsyncScope< TData = unknown, TError = unknown,>(): UseAsyncReturn<TData, TError, []> { const ctx = useContext(AsyncScopeContext); if (!ctx) throw new Error("useAsyncScope must be used within AsyncScope"); return ctx as UseAsyncReturn<TData, TError, []>;}
export type AsyncScopeProps< TData = unknown, TError = unknown,> = PropsWithChildren & UseAsyncOptions<TData, TError, []>;
/** * A coordination layer of async UI , decouples what fires an action from what reacts to it * * required : `action` */export function AsyncScope<TData = unknown, TError = unknown>({ children, action, onSuccess, onError, onSettled,}: AsyncScopeProps<TData, TError>) { const state = useAsync<TData, TError, []>({ action, onSuccess, onError, onSettled, });
return ( <AsyncScopeContext.Provider value={state as UseAsyncReturn<unknown, unknown, []>} > {children} </AsyncScopeContext.Provider> );}
export type AsyncTriggerProps = Omit<ComponentProps<typeof Button>, "onClick">;
export function AsyncTrigger({ children, disabled, ...props}: AsyncTriggerProps) { const { isLoading, execute } = useAsyncScope();
return ( <Button {...props} disabled={disabled || isLoading} onClick={() => execute()} > {children} </Button> );}
export type AsyncContentProps = PropsWithChildren & { loadingFallback?: ReactNode; className?: string;};
export function AsyncContent({ children, loadingFallback, className,}: AsyncContentProps) { const { isLoading } = useAsyncScope();
return ( <div className="grid grid-cols-1 items-center justify-items-center"> <div className={cn( "col-start-1 col-end-2 row-start-1 row-end-2 w-full", isLoading ? "invisible" : "visible", className, )} > {children} </div> <div className={cn( "col-start-1 col-end-2 row-start-1 row-end-2", isLoading ? "visible" : "invisible", )} > {loadingFallback ?? <Loader2 className="size-6 animate-spin" />} </div> </div> );}Update the import paths to match your project setup.
import { AsyncScope, AsyncContent, AsyncTrigger, useAsyncScope,} from "@/components/ui/async-scope";<AsyncScope action={syncData} onSuccess={() => toast.success("Synced")}> <AsyncTrigger>Sync</AsyncTrigger> <AsyncContent> <YourCard /> </AsyncContent></AsyncScope>Examples
Section titled “Examples”Single area
Section titled “Single area”Revenue: $12,345
Orders: 42
Users: 1,234
import { AsyncScope, AsyncContent, AsyncTrigger,} from "@/components/ui/async-scope"";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { toast } from "sonner";
const refreshData = async () => { await new Promise((resolve) => setTimeout(resolve, 1500));};
export function BasicAsyncScope() { return ( <div className="flex w-72 flex-col gap-4"> <AsyncScope action={refreshData} onSuccess={() => toast.success("Data refreshed")} onError={() => toast.error("Failed to refresh")} > <AsyncTrigger variant="outline" className="w-fit"> Refresh Data </AsyncTrigger> <Card> <AsyncContent> <CardHeader> <CardTitle>Dashboard Stats</CardTitle> </CardHeader> <CardContent className="space-y-1"> <p className="text-sm text-muted-foreground">Revenue: $12,345</p> <p className="text-sm text-muted-foreground">Orders: 42</p> <p className="text-sm text-muted-foreground">Users: 1,234</p> </CardContent> </AsyncContent> </Card> </AsyncScope> </div> );}Error & Retry
Section titled “Error & Retry”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.
$—
No data yet
import { AsyncScope, AsyncContent, AsyncTrigger, useAsyncScope,} from "@/components/ui/async-scope"";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { Button } from "@/components/ui/button";import { AlertCircleIcon } from "lucide-react";import { toast } from "sonner";
type Stats = { revenue: string; users: number; growth: string };
const fetchStats = async (): Promise<Stats> => { await new Promise((r) => setTimeout(r, 1500)); if (Math.random() > 0.5) throw new Error("Network timeout"); return { revenue: "$48,293", users: 2847, growth: "+12%" };};
function StatsCard() { const { isError, isSuccess, data, execute, reset } = useAsyncScope<Stats>();
if (isError) { return ( <Card className="border-destructive/50"> <CardContent className="flex flex-col items-center gap-3 py-8"> <AlertCircleIcon className="size-8 text-destructive" /> <p className="text-sm text-muted-foreground">Failed to load stats</p> <Button variant="outline" size="sm" onClick={() => { reset(); execute(); }} > Try again </Button> </CardContent> </Card> ); }
return ( <AsyncContent> <Card> <CardHeader className="pb-2"> <CardTitle className="flex items-center justify-between text-sm font-medium"> Overview {isSuccess && ( <span className="text-xs font-normal text-emerald-500"> {data.growth} this month </span> )} </CardTitle> </CardHeader> <CardContent className="space-y-1"> <p className="text-2xl font-bold"> {isSuccess ? data.revenue : "$—"} </p> <p className="text-sm text-muted-foreground"> {isSuccess ? `${data.users.toLocaleString()} users` : "No data yet"} </p> </CardContent> </Card> </AsyncContent> );}
export function ErrorRetryAsyncScope() { return ( <div className="flex w-72 flex-col gap-4"> <AsyncScope action={fetchStats} onSuccess={() => toast.success("Stats loaded")} onError={() => toast.error("Load failed — try again")} > <AsyncTrigger variant="outline" className="w-fit"> Load Stats </AsyncTrigger> <StatsCard /> </AsyncScope> </div> );}Status indicator
Section titled “Status indicator”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.
Profile
Billing
Security
import { AsyncScope, AsyncContent, AsyncTrigger, useAsyncScope,} from "@/components/ui/async-scope"";import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";import { toast } from "sonner";
type AccountData = { syncedAt: string };
const syncAccount = async (): Promise<AccountData> => { await new Promise((r) => setTimeout(r, 2000)); return { syncedAt: new Date().toISOString() };};
function SyncStatus() { const { isIdle, isLoading, isSuccess, isError, data } = useAsyncScope<AccountData>();
if (isIdle) return null;
if (isLoading) return ( <span className="animate-pulse text-xs text-muted-foreground"> Syncing… </span> );
if (isError) return <span className="text-xs text-destructive">Sync failed</span>;
if (isSuccess) return ( <span className="text-xs text-emerald-500"> Synced at {new Date(data.syncedAt).toLocaleTimeString()} </span> );
return null;}
export function WithStatusAsyncScope() { return ( <div className="flex w-80 flex-col gap-4"> <AsyncScope action={syncAccount} onSuccess={() => toast.success("Account synced")} onError={() => toast.error("Sync failed")} > <div className="flex items-center justify-between"> <AsyncTrigger variant="outline" size="sm"> Sync Account </AsyncTrigger> <SyncStatus /> </div> <AsyncContent> <Card> <CardHeader> <CardTitle>Account</CardTitle> </CardHeader> <CardContent className="space-y-1"> <p className="text-sm text-muted-foreground">Profile</p> <p className="text-sm text-muted-foreground">Billing</p> <p className="text-sm text-muted-foreground">Security</p> </CardContent> </Card> </AsyncContent> </AsyncScope> </div> );}Building custom consumers
Section titled “Building custom consumers”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> );}How it fits in your stack
Section titled “How it fits in your stack”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.
When to use
Section titled “When to use”| Component | Use 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 |
useAsync | You need the raw state machine with no opinion on rendering |
AsyncScope
Section titled “AsyncScope”| Prop | Type | Default | Description |
|---|---|---|---|
action | () => Promise<TData> | TData | — | The async function to execute when triggered |
onSuccess | (data: TData) => void | — | Called after action resolves |
onError | (error: TError) => void | — | Called if action throws |
onSettled | (data, error) => void | — | Called after either outcome |
AsyncTrigger
Section titled “AsyncTrigger”All Button props except onClick. Automatically disabled while the scope is loading.
AsyncContent
Section titled “AsyncContent”| Prop | Type | Default | Description |
|---|---|---|---|
loadingFallback | ReactNode |
| Shown in place of children while loading |
className | string | — | Applied to the visible content wrapper |
useAsyncScope
Section titled “useAsyncScope”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.