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.

toasts.tsmain.tsxToastStack.tsx
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

PrimitiveRole
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 — effectScope lifetime semantics.
  • Lists — For keyed reconciliation.