useToast() is how any code in the application raises a toast: a component, a store, a route guard, an HTTP interceptor. It returns the shared queue, created on first call, so nothing has to be passed down and no plugin has to be installed.
The queue is only half of it. NbToaster renders it, once, at the root of the application. Without a host mounted, useToast() still queues records and nothing is ever shown.
import { useToast } from '@nubisco/ui'
const toast = useToast()
toast.success('Draft saved')
toast.error('Could not reach the server', { title: 'Save failed' })Why the queue is a separate thing from the toast
NbToast knows how one message looks. NbToaster knows where the stack sits and how it moves. This file knows when a message leaves, and that has to be one shared decision rather than a timer per item: while the pointer rests on the top toast, the two below it must stop counting down as well, and an item that owns its own timer cannot know that.
Three applications in the 12-app audit hand-rolled this, and each lost a different part of it. One had no auto-dismiss at all, so successes accumulated for the life of the route.
A handle, not just a push
push() and its shorthands return a handle, so the caller can keep talking about the toast it created. This is what turns two toasts for one operation ("Saving...", then "Saved") into one toast that becomes its own result.
const saving = toast.info('Saving changes...', { duration: 0 })
try {
await save()
saving.update({ variant: 'success', message: 'Changes saved' })
} catch (error) {
saving.update({ variant: 'error', message: 'Could not save changes' })
}The toast keeps its slot and its place in the stack, so nothing jumps. The countdown restarts, because the words changed and the reader has not seen the new ones yet. Passing a variant with no duration adopts that variant's default, which is what makes the success clear itself while the error stays.
Knowing when a toast is over
onDismiss fires exactly once per pushed message, with the reason it ended. The reason is the whole mechanism behind an undoable action: one value commits the delete, another cancels it.
let undone = false
toast.push({
message: 'Comment deleted',
cta: {
label: 'Undo',
action: () => {
undone = true
restore(comment)
},
},
onDismiss: () => {
if (!undone) commitDelete(comment)
},
})A toast with an action never auto-dismisses unless you give it a duration: an action that removes itself while it is being reached for is not an action, and the only route to it for a keyboard user is to travel there.
The callback is fixed at push(). TToastPatch is Omit<Partial<IToastOptions>, 'onDismiss'>, so update() rewrites the words of the message it was given and never the commit point: swapping it halfway is how a queue ends up dropping one commit or running another twice.
Bursts, and the same news twice
Give a toast a key and pushing it again takes over the toast on screen instead of adding a second. A double-clicked Save produces one "Saved", not two.
toast.push({ key: 'autosave', variant: 'success', message: 'Draft saved' })The second push replaces the message rather than merging into it: only the id, the slot and the position survive, so a field the new call leaves out goes back to its default instead of lingering from the previous one. And because the displaced message may be holding an optimistic delete, its onDismiss runs first, with reason 'replaced'. Deleting two rows quickly under one key therefore commits row 1 and then row 2, in that order. Pushing the same callback under the same key twice displaces nothing, so an autosave firing twice does not commit twice.
Route changes
The queue outlives the route, which is usually wrong: "Draft saved" two pages later is news about a screen the user has left, and its Undo acts on a record they can no longer see. Nothing here knows about a router, and it must not, so the queue provides the operation and your application says when.
router.afterEach(() => toast.dismissTransient())Mark the exceptions with retain: true: news that is true wherever the user ends up ("Export ready", "Connection restored"). dismissAll() clears those too, and is what sign-out wants.
That includes anything raised during the navigation. afterEach fires after the guard that redirected, so a toast.error('Your session expired') pushed in beforeEach is cleared with reason 'navigated' before it is ever painted. Guard toasts need retain: true. This is the single most common way the recipe above goes wrong, and it is written up at length on the NbToaster page.
Your own queue
createToastQueue(options) builds an isolated queue with its own cap, durations and labels. Use it for anything that is not the application's own stack.
import { createToastQueue } from '@nubisco/ui'
// A Spanish-market product states its strings once, not per call.
export const toast = createToastQueue({
max: 2,
statusLabels: {
success: 'Correcto',
error: 'Error',
warning: 'Aviso',
info: 'Informacion',
},
closeLabel: 'Cerrar',
})<template>
<NbToaster :queue="toast" label="Notificaciones" />
</template>The host has no max prop here on purpose, and that is what makes the cap of two hold: max on NbToaster has no default, so an absent prop writes nothing to the queue. Pass :max only when the host, not the queue, is the thing that knows the right number.
Two hosts rendering the same queue is the mistake to avoid: every record renders and is announced twice. A demo, a dialog or a test that needs its own host gives it its own queue.
Testing
A queue is plain reactive state, so it can be asserted without mounting anything.
import { createToastQueue } from '@nubisco/ui'
it('tells the user the save failed', async () => {
const toast = createToastQueue()
await save({ toast }, brokenPayload)
expect(toast.visible.value).toHaveLength(1)
expect(toast.visible.value[0].variant).toBe('error')
// Errors are persistent by default, so there is nothing to advance.
expect(toast.remaining(toast.visible.value[0].id)).toBe(0)
})If your code reaches for the shared useToast() rather than an injected queue, call resetToastQueue() in afterEach. The shared queue is a module singleton, so one test's toast is otherwise still there in the next.
Server rendering needs the same call for the same reason: a singleton filled during one request would otherwise be handed to the next.