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:
idlets 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.durationis 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 explicitdurationis treated as persistent until something replaces it by id (toast.tsx:119-125).spinnershows a spinner in place of the variant icon.copyadds a copy button:truecopies the rendered content if it is a string, a string value copies that exact text instead. Defaults totruefor theerrorvariant andfalseotherwise (toast.tsx:67-69). Real usage:speedway/app/lib/scrape.server.ts:370passes an explicit copy string.unremovablehides the dismiss button and blocks swipe-to-dismiss.controlsadds extra buttons in the trailing row, either a node or a function of the toast id for dismiss-on-click actions.onExpirefires only on auto-expiry, not on a manual dismiss.ToastOptionsextendsIconImportProps, soIcon,iconSize,iconProps, andiconClassNameare valid per-toast icon overrides;controlsClassNameanddescriptionClassNamestyle 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#
| Combination | What it produces | Where used | Why |
|---|---|---|---|
pushAlert.promise(p, {...}) awaited inside try, with a catch block that only comments the toast already fired | A single async action's full pending, success, error lifecycle in one toast | walmart-mvp/frontend/src/pages/ErrorManagement.tsx:653-664, :700-711; Settings.tsx:358-371; PartPreviewModal.tsx:557-570 | Avoids 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 id | A pending-to-settled toast for a sequence that is not a single promise | walmart-mvp/frontend/src/pages/ErrorManagement.tsx:764 and :786 | The 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 result | A settle notification for a form submission handled outside the component that renders the toast | speedway/app/routes/account/accounts.tsx:326-351 | The 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.
- Does the shell, a parent layout, or a global provider already render this?
Toastsmounts once near the app root; a second stack duplicates the app's own. - Does this app already ship a local implementation of the same thing? Not applicable, no local toast clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed
pushAlertoption is noted in this doc. - 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.
- 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-371try { // 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));}