shadimage

Async Scope

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

src/registry/base/async-scope/components/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>
  );
}

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · light

icons
lucide-react