Open pub-trivia.app, or any page under it, and run this:
document.querySelector('[aria-label^="Notifications"]')
// <div role="region" aria-label="Notifications (F8)" tabindex="-1" style="pointer-events:none">
That is a live Radix toast viewport, on a static marketing page that cannot produce a toast. Press F8 and focus moves to the list inside it, which is empty and always will be on that page.
The code behind it is in the bundle too:
curl -s https://pub-trivia.app/ \
| grep -oE '/_next/static/chunks/[^"?]+\.js' | sort -u \
| sed 's|^|https://pub-trivia.app|' | xargs -n1 curl -s \
| grep -oc 'ADD_TOAST'
# 2
The chunk carrying it is 37,211 bytes raw and 13,145 over the wire. It is downloaded by every visitor who lands on the homepage, the pricing page, any of the guides or any of the free tools, none of which ever raise a toast. The first toast in the product's life happens after you sign in.
The reason is one line in the root layout:
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
<Toaster /> sits in the root layout because it has to be above everything that might call toast(), and the dashboard is below the root layout, so the root layout is where it went. The cost is that it is also above 70 pages of content that will not.
The store is a module-level variable
The interesting part is not the placement, it is what toast() actually is. This is the whole state container:
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
No context, no provider, no store library. A let at module scope and an array of setState functions. useToast() subscribes a component by pushing its setState into listeners, and dispatch loops over them.
This is the shadcn pattern and it is deliberate. Because the state is not inside React, toast() can be imported and called from anywhere: a plain async function, a form action handler, a catch block, code that is nowhere near a component. The price is that it is one global per browser tab. Two mounted useToast() calls are two subscribers to the same array, and a route change does not reset it, because a module-level let has no idea that the page it was written for is gone.
The subscription has one thing I would change:
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
The dependency is [state], so every toast tears the subscription down and rebuilds it. setState is referentially stable, so the push and the splice balance out and nothing breaks, but the effect is doing work proportional to state changes in order to express "subscribe once". [] is the correct dependency array and would read as what it means.
One toast at a time, by a slice
const TOAST_LIMIT = 1
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
The new toast goes on the front and the array is cut to one, so a second toast does not stack, it evicts. The previous toast vanishes instantly with no exit animation, because it is not dismissed, it is simply no longer in the array.
For this product that is the right call. A toast here says "Invite sent" or "Table added", and two of those arriving a second apart in a stack is noise in the corner of a screen that may well be projected onto a wall in a pub. But it does mean a burst of three results shows one line of feedback and silently drops the other two. If the second message were an error and the third a success, the user sees the success.
You can watch the whole thing on a free account, which does not need a card: sign up from the pricing page, add two tables quickly in the dashboard, and count the toasts.
A dismissed toast is kept for sixteen minutes and forty seconds
const TOAST_REMOVE_DELAY = 1000000
That is not a typo in the source and it is not a typo here. One million milliseconds. Dismissing a toast does not remove it, it sets open: false and queues the removal:
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({ type: "REMOVE_TOAST", toastId: toastId })
}, TOAST_REMOVE_DELAY)
In the shipped bundle it survives as ,1e6).
So the sequence for a single toast is: Radix closes it after its own duration, the close handler dismisses it, the dismissed toast stays in memoryState.toasts with open: false for sixteen minutes and forty seconds, and a closed Radix toast element stays in the DOM that whole time. A host who triggers one action and then reads their session history for a quarter of an hour is carrying a hidden toast and a pending timer the entire time.
Nothing visible goes wrong, and that is why the number is still there. TOAST_LIMIT = 1 means the next toast evicts the stale one early, so the array never grows. The timer holds a closure over a string. It is a leak with a ceiling, which is the most durable kind of leak there is, because nothing will ever force you to fix it.
The one case where it is not harmless is the one case nobody tests: a user who dismisses a toast by hand and then leaves the tab open. The element is data-state="closed" rather than unmounted, so it is still in the accessibility tree as a child of that notifications region, for sixteen more minutes.
What I would actually change
Not the pattern. The module-level store is the thing that makes toast() callable from a server action's error path, and that is worth more than the tidiness of a context.
What I would change is the scope. <Toaster /> and the 13 KB behind it belong in the dashboard layout, not the root layout, because every page that can raise a toast is under the dashboard and every page that cannot is not. The current arrangement is one line in the right place for the app and the wrong place for the 70 pages that exist to be found by a search engine.
Then TOAST_REMOVE_DELAY becomes something a human could defend out loud, and the dependency array becomes [].
Top comments (0)