Toast queue
Per-item effectScope owns each toast's auto-dismiss timer. Closing the toast disposes the scope, cancelling the timer cleanly.
Toasts auto-dismiss after a delay, but the user can also close them early. The natural shape: each toast owns a scoped lifetime. Disposing the scope cancels its timer — no manual clearTimeout, no leaked callbacks.
import { signal, effectScope } from "elements-kit/signals";
import { createTimeout } from "elements-kit/utilities/timeout";
export type Toast = {
id: number;
message: string;
tone: "info" | "success" | "warn";
dispose: () => void;
};
export const toasts = signal<Toast[]>([]);
let nextId = 1;
export function dismiss(id: number) {
const t = toasts().find((x) => x.id === id);
t?.dispose();
toasts(toasts().filter((x) => x.id !== id));
}
export function show(message: string, tone: Toast["tone"] = "info", ms = 3000) {
const id = nextId++;
const stop = effectScope(() => {
createTimeout(() => dismiss(id), ms);
});
toasts([...toasts(), { id, message, tone, dispose: stop }]);
}
The recipe
import { signal, effectScope } from "elements-kit/signals";
import { createTimeout } from "elements-kit/utilities/timeout";
type Toast = { id: number; message: string; dispose: () => void };
const toasts = signal<Toast[]>([]);
let nextId = 1;
function dismiss(id: number) {
const t = toasts().find((x) => x.id === id);
t?.dispose(); // cancels its timer
toasts(toasts().filter((x) => x.id !== id));
}
function show(message: string, ms = 3000) {
const id = nextId++;
const stop = effectScope(() => {
createTimeout(() => dismiss(id), ms);
});
toasts([...toasts(), { id, message, dispose: stop }]);
}effectScope returns a stop function. createTimeout registers onCleanup against the current scope, so stop() clears the timer. The toast object carries dispose — the close button calls it directly.
How each piece contributes
| Primitive | Role |
|---|---|
signal<Toast[]> | The queue. Replaced ([…toasts(), new]) on push so subscribers re-render. |
effectScope(fn) | Per-toast lifetime. Owns any onCleanup registered inside fn. |
createTimeout(cb, ms) | The auto-dismiss. Registers onCleanup(() => clearTimeout(id)) on the surrounding scope — the very thing effectScope gives us. |
For each={toasts} by={(t) => t.id} | Keyed reconciliation so closing the third toast doesn't recreate the first two. |
Variations
Pause on hover — read createHover inside the timer scope. Reset the timeout when the pointer leaves:
const stop = effectScope(() => {
const hovered = createHover(host);
const t = createTimeout(() => dismiss(id), ms);
effect(() => { if (hovered()) t.stop(); else t.start(); });
});Cap the queue — drop the oldest when over the limit:
function show(message: string) {
// …create toast…
const next = [...toasts(), toast];
if (next.length > 5) next.shift()!.dispose();
toasts(next);
}Position variants — bind a signal<"top-right" | "bottom-center"> and switch the container's CSS class.
Gotchas
Without effectScope, all timers leak together
createTimeout registers cleanup on the current scope. Outside an explicit scope, that's whatever effect or page-level scope happens to be running — disposing the page wipes every timer at once, which is not what you want for individual dismissal. The per-toast effectScope is what makes early-close work.
Replace the array, don't mutate it
toasts().push(toast) won't notify subscribers. Always assign a new array via toasts([...]) — same rule as the Infinite scroll list.
Call dispose before removing from the list
Filter-then-dispose works too, but disposing first means the timer can't fire mid-removal and trigger a redundant dismiss(id).
See also
- Timing and DOM events —
createTimeout,createHover. - Scopes —
effectScopelifetime semantics. - Lists —
Forkeyed reconciliation.
Context propagation
Share a current-user signal with deeply-nested components via setContext + getContext, deferring the lookup with <dom-lifecycle> so it resolves after mount.
Event delegation
Replace N per-row listeners with one parent listener for large lists. Look up the row from a data source via the element's data-id.