Agent docs

Toast

The app's transient notification stack, reached only through pushAlert.

The app's transient notification stack, reached only through a frozen pushAlert interface. Downstream consumers depend on that shape, never on the Sonner library it is built from, so the substrate can change without touching a call site.

When to reach for it#

Use a toast for a transient event: an action succeeded, an action failed, a background job settled. Use an inline alert or NoteBanner instead when the message is a standing condition of the current view rather than an event that just happened; canon: "Alert is for in-flow notes. Toast is for transient events" (docs/design-language/10-overlays.md §A8). A form validation error that stays wrong until the user fixes the field belongs inline, not in a toast that auto-expires while the problem persists.

For an async action whose outcome the user is waiting on, use pushAlert.promise rather than a manual pending toast plus a manual success or error toast. It models the whole pending, success, error sequence as one call and one replaced toast id, and every real usage in both apps follows this pattern.

Contract#

Toasts is the stack; mount it once near the app root. position (default "bottom-right") is its only prop.

pushAlert(variant, content, options?) is the base call. variant is "info" | "success" | "warning" | "error". Shorthands pushAlert.info / .success / .warning / .error drop the variant argument. pushAlert.remove(id) dismisses one toast by id. useAlertToast() returns { pushAlert } as a hook, for consumers (query-kit's mutation toasts especially) that want a stable interface they can stub rather than a bare module import.

Options that matter:

  • id lets the same id replace a toast in place instead of stacking a new one. Without an explicit id, string or number content hashes to an id automatically, so pushing the identical message twice de-dupes on its own (toast.tsx:107-111, 127-129). JSX content gets a unique id every call unless you pass one explicitly, since it cannot be hashed meaningfully.
  • duration is the auto-dismiss delay in ms; any negative value (the convention is -1) keeps the toast up until dismissed. Sonner cannot change a live toast's duration after creation (upstream issue #529/#509), so a spinner toast pushed with no explicit duration is treated as persistent until something replaces it by id (toast.tsx:119-125).
  • spinner shows a spinner in place of the variant icon.
  • copy adds a copy button: true copies the rendered content if it is a string, a string value copies that exact text instead. Defaults to true for the error variant and false otherwise (toast.tsx:67-69). Real usage: speedway/app/lib/scrape.server.ts:370 passes an explicit copy string.
  • unremovable hides the dismiss button and blocks swipe-to-dismiss.
  • controls adds extra buttons in the trailing row, either a node or a function of the toast id for dismiss-on-click actions.
  • onExpire fires only on auto-expiry, not on a manual dismiss.
  • ToastOptions extends IconImportProps, so Icon, iconSize, iconProps, and iconClassName are valid per-toast icon overrides; controlsClassName and descriptionClassName style those slots.

pushAlert.promise(p, { pending, success, error }, options?) pushes a persistent spinner toast for pending, then replaces it in place (same id) with success or error once p settles. success and error can each be a plain node or a function of the result or the thrown error. It rethrows. The source resolves the promise, pushes the outcome toast, and then throw err on the error path (toast.tsx:167-171). The call returns the same promise it was given, so a caller that both relies on .promise for the error toast and separately catches and reports the same error will show it twice: once from the toast, once from whatever the catch block does.

The correct pattern, and the one every real call site in both apps uses, is to let .promise own the error toast and swallow the rethrow in the caller:

try {
await pushAlert.promise(doWork(), { pending: "...", success: "...", error: (err) => errorMessage(err) });
onSettled();
} catch {
// the promise toast already showed the error
}

This exact shape (a try/catch whose catch body is only a comment noting the toast already fired) appears at walmart-mvp/frontend/src/pages/ErrorManagement.tsx:653-664, :700-711, and walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:558-569. ImportParts.tsx:222 uses the equivalent .catch(() => null) form directly on the promise chain when the caller needs the resolved value rather than a full try/catch. A related but distinct pattern, Settings.tsx:358-371, sets local inline error state (setError(errorMessage(err))) from the catch rather than leaving it empty; that is still safe, because setError drives a persistent inline error line, not a second toast for the same failure.

Sanctioned combinations#

CombinationWhat it producesWhere usedWhy
pushAlert.promise(p, {...}) awaited inside try, with a catch block that only comments the toast already firedA single async action's full pending, success, error lifecycle in one toastwalmart-mvp/frontend/src/pages/ErrorManagement.tsx:653-664, :700-711; Settings.tsx:358-371; PartPreviewModal.tsx:557-570Avoids the double-toast bug: the promise toast already reported the error, so the catch exists only to stop the rethrow from propagating unhandled.
A manual pushAlert.info(..., { id, spinner: true, duration: -1 }) followed later by pushAlert.error(..., { id, spinner: false }) (or .success) reusing the same idA pending-to-settled toast for a sequence that is not a single promisewalmart-mvp/frontend/src/pages/ErrorManagement.tsx:764 and :786The work being tracked spans multiple steps (a fetch, then a multi-branch apply), so .promise's single-promise model does not fit; the id-replace mechanism it uses internally is exposed directly instead.
pushAlert.success(...) fired from a useEffect reacting to a route action's resultA settle notification for a form submission handled outside the component that renders the toastspeedway/app/routes/account/accounts.tsx:326-351The action result arrives as data, not as a promise the component holds, so .promise does not apply; a direct variant call is correct here.

Banned combinations#

Do not call pushAlert.error (or any variant) a second time in a catch block after a pushAlert.promise call already handled that same error. .promise rethrows by design so the caller can branch on failure (skip a refresh, keep a form open), not so the caller can report the error again. Every real call site swallows the rethrow with a comment instead of re-reporting it; follow that pattern.

Do not give a spinner toast (spinner: true) an explicit finite duration. Sonner cannot change a live toast's duration after creation, so a spinner that is meant to be replaced when work finishes must stay persistent (the default when duration is omitted) and be resolved by a same-id replace, never by racing its own timer against the async work.

Do not rely on content-hash de-duping for JSX toast content. Hashing only applies to string or number content; two calls with equivalent but separately-constructed JSX will not de-dupe and will stack, unless you pass an explicit id.

What this saves you#

A toast outlives whatever pushed it (02 §A6, 10 §A8). The canon states this as a rule because it was once broken: toast state lived inside the component that pushed it, and died when that component unmounted on success.

Here it is unrepresentable. pushAlert is a module-level export (toast.tsx:144), not a hook and not component state, and Toasts mounts once for the app. A toast pushed from a modal therefore survives the modal closing, because the state was never inside it. There is no correct-usage discipline to remember; the wrong version cannot be written.

That is what makes this component the answer to the half of 10 §A5 that ConfirmModal cannot cover. A confirm that starts async work owes the user an outcome, the modal is gone before the work settles, and pushAlert.promise is the thing that is still alive to report it. See modal.md.

One caveat, and it is the reason the Banned combinations entry above exists: being free of the ownership bug does not make .promise free of the double-report bug. It rethrows.

Feedback belongs to the thing that is happening, not the thing that asked (00 model 4). The canon's own generalization was written about a route and its skeleton. pushAlert living at module scope is the same idea one layer up: the outcome belongs to the async action, which outlives whatever fired it.

Classified in docs/app-patterns/12-primitives-and-rules.md.

Before you adopt this#

Five questions to answer before reaching for a toast.

  1. Does the shell, a parent layout, or a global provider already render this? Toasts mounts once near the app root; a second stack duplicates the app's own.
  2. Does this app already ship a local implementation of the same thing? Not applicable, no local toast clone is documented in either app.
  3. Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed pushAlert option is noted in this doc.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable, toasts carry no focus target; dismiss and swipe are the only paths.
  5. Which canon §A rules bind this surface, and which does the composition break? §A8 draws the Alert-versus-Toast line: Alert in-flow, Toast for transient events.

Travels with#

errorMessage() (an app-local helper in both apps, not a kit export) normalizes a thrown error into toast-ready text; every error handler in the sanctioned combinations table above passes it as the function form of error.

ConfirmModal often hands off to pushAlert.promise for the async outcome once the confirm gate closes, since the modal itself carries no loading state for work that outlives it (see modal.md's sanctioned combinations table, ErrorManagement.tsx:1140-1149).

Snippet#

// walmart-mvp/frontend/src/pages/Settings.tsx:358-371
try {
// The confirm modal is gone before the request settles; the promise
// toast is the only affordance covering the gap.
await pushAlert.promise(authApi.removeMember(orgId, userId), {
pending: self ? "Leaving the organization" : `Removing ${label}`,
success: self
? "You left the organization."
: `${label} removed from the organization.`,
error: (err) => errorMessage(err),
});
load();
} catch (err) {
setError(errorMessage(err));
}
@versable-git/ui · reference, canon, and method, read in place