Popser

Promise Toasts

Loading → success → error. The state machine you were too busy to write.

toast.promise() handles the loading → success → error dance. Hand it a promise and three messages. Popser does the rest.

import { toast } from "@vcui/popser";

toast.promise(fetchData(), {
  loading: "Fetching data...",
  success: "Data loaded",
  error: "Failed to fetch",
});

Signature

toast.promise<T>(
  promiseOrFn: Promise<T> | (() => Promise<T>),
  options: PopserPromiseOptions<T>
): Promise<T> & { id: string }

Returns the original promise with an id tacked on. For tracking, if you're into that.

Options

{
  loading: ReactNode;
  success: ReactNode | ((result: T) => ReactNode | PopserPromiseExtendedResult | undefined);
  error: ReactNode | ((error: unknown) => ReactNode | PopserPromiseExtendedResult | undefined);
  description?: ReactNode | ((data: T) => ReactNode) | ((error: unknown) => ReactNode);
  finally?: () => void | Promise<void>;
  id?: string;
  signal?: AbortSignal;
  aborted?: ReactNode | ((reason: unknown) => ReactNode | PopserPromiseExtendedResult | undefined);
  onAbort?: (reason: unknown) => void;
}

Dynamic messages

Pass a function to success or error and compute from the actual result. Groundbreaking, truly.

toast.promise(saveUser(data), {
  loading: "Saving...",
  success: (user) => `Saved ${user.name}`,
  error: (err) => `Failed: ${err.message}`,
});

JSX results

Handlers take ReactNode. Strings, JSX, whatever. Go mental.

toast.promise(saveUser(data), {
  loading: "Saving...",
  success: (user) => <span>Saved <strong>{user.name}</strong></span>,
  error: (err) => <span className="text-red-500">{err.message}</span>,
});

Extended results

Return an object with title to take full control. Description, timeout, icon, actions — the whole menu.

toast.promise(uploadFile(file), {
  loading: "Uploading...",
  success: (result) => ({
    title: "Uploaded",
    description: result.filename,
    timeout: 8000,
    icon: <FileIcon />,
    action: {
      label: "View",
      onClick: () => openFile(result.url),
    },
  }),
  error: (err) => ({
    title: "Upload failed",
    description: err.message,
    timeout: 0, // stays until dismissed
  }),
});

Detected by the presence of title (and not being a React element). Any PopserOptions field works here.

Conditional skip

Return undefined to silently dismiss instead of showing a result. For background operations nobody asked to see.

toast.promise(fetchData(), {
  loading: "Loading...",
  success: (data) => data.silent ? undefined : "Done!",
  error: (err) => err.name === "AbortError" ? undefined : `Error: ${err.message}`,
});

Suppressing abort errors? Chef's kiss.

Per-state descriptions

description can be a function. Gets the resolved data or the error, depending on how things went.

toast.promise(fetchItems(), {
  loading: "Loading items...",
  success: "Done",
  description: (items) => `${items.length} items loaded`,
  error: "Failed",
});

For errors:

toast.promise(save(), {
  loading: "Saving...",
  success: "Saved",
  error: "Failed",
  description: (err) => err instanceof Error ? err.message : "Unknown error",
});

Finally

Runs after the promise settles. Success, error, doesn't matter. Cleanup is cleanup.

toast.promise(uploadFile(file), {
  loading: "Uploading...",
  success: "Done",
  error: "Failed",
  finally: () => {
    cleanup();
  },
});

Lazy promises

Pass a function instead of a promise. Doesn't fire until the toast is created.

toast.promise(
  () => fetchData(),
  {
    loading: "Loading...",
    success: "Done",
    error: "Failed",
  },
);

Handy when you want the loading toast to appear before the request fires. Timing matters.

AbortSignal

Cancel in-flight promise toasts. Pass a signal and optionally aborted content.

const controller = new AbortController();

toast.promise(fetchData(), {
  loading: "Fetching...",
  success: "Done",
  error: "Failed",
  signal: controller.signal,
  aborted: "Cancelled",
});

// later
controller.abort();
// toast updates to "Cancelled" with type "warning"

Abort content options

aborted follows the same pattern as success/error:

// Static message
aborted: "Cancelled"

// Dynamic from abort reason
aborted: (reason) => `Cancelled: ${reason}`

// Extended result with full control
aborted: (reason) => ({
  title: "Aborted",
  description: String(reason),
  timeout: 5000,
  icon: <StopIcon />,
})

// Return undefined to silently dismiss
aborted: (reason) => reason === "user" ? "Cancelled" : undefined

// Omit entirely to auto-dismiss on abort

Default type for aborted state: "warning".

onAbort callback

Fires before the toast updates. Use for cleanup.

toast.promise(uploadFile(file), {
  loading: "Uploading...",
  success: "Uploaded",
  error: "Failed",
  signal: controller.signal,
  aborted: "Upload cancelled",
  onAbort: (reason) => {
    cleanupPartialUpload();
  },
});

If the promise resolves or rejects before the signal fires, the abort is a no-op. If the signal is already aborted when toast.promise() is called, abort handling runs immediately.

Custom ID

Pin a specific ID:

const result = toast.promise(save(), {
  loading: "Saving...",
  success: "Saved",
  error: "Failed",
  id: "save-operation",
});

console.log(result.id); // "save-operation"

On this page