alert-store
A capped notice store on the row store's ordered lane. Repeated keys update an existing notice and grow its count.
import { useState } from "react"
import { createAlertStore } from "@/lib/alert-store"
export default function AlertStoreDemo() {
const [result] = useState(() => {
const alerts = createAlertStore()
const first = alerts.push({ key: "md:slow", severity: "warning", title: "Feed slow", message: "1.2 s behind" })
const repeat = alerts.push({ key: "md:slow", severity: "critical", title: "Feed slow", message: "4 s behind" })
return { repeat, size: alerts.size(), sameId: first.id === repeat.id }
})
return (
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<p className="text-muted-foreground">After two pushes with the key md:slow</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt>Rows</dt><dd>{result.size}</dd>
<dt>Same id</dt><dd>{result.sameId ? "Yes" : "No"}</dd>
<dt>Count</dt><dd>{result.repeat.count}</dd>
<dt>Severity</dt><dd>{result.repeat.severity}</dd>
<dt>Message</dt><dd>{result.repeat.message}</dd>
</dl>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/alert-store#v2.0.0
npx shadcn@latest add tradecn/ui/alert-store#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/alert-store#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/alert-store#v2.0.0
-
Copy the files into your project:
lib/alert-store.ts import { createRowStore, type RowId, type RowStore } from "@/lib/row-store" // Notices need a store, not a toast each. At the close a thousand events land in a short window, and // a pop-up per event buries the screen; here they go into a row store on the ordered lane, a notice // with the same key replaces the last one and counts it instead of adding a row, and a cap keeps the // list to a size. Nothing here decides what a notice means: the severity is the consumer's word, the // tone a token name beside it, and the actions ids the server allowed. /** A token name. The severity word carries the meaning; the tone is the hint beside it. */ export type AlertTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export type AlertJson = string | number | boolean | null | AlertJson[] | { [key: string]: AlertJson } export interface Alert { id: string /** Notices with one key coalesce: the newest replaces the last and `count` grows. */ key?: string /** When it happened, ms since the epoch. */ at: number /** Arrival order in this store, counting up from one; two notices in one millisecond still have an order. */ seq: number /** The consumer's or the server's word: "info", "warning", "critical", "fill", whatever it says. Printed as is. */ severity: string tone?: AlertTone title: string message?: string /** How many notices this one stands for. */ count: number /** Ids of the actions that may be offered for it. No list, no actions. */ allowedActions?: string[] meta?: { [key: string]: AlertJson } } /** What `push` takes: an alert without the fields the store fills. */ export type AlertInput = Omit<Alert, "id" | "at" | "count" | "seq"> & { id?: string; at?: number; count?: number } export interface AlertStoreOptions { /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number /** At most this many notices; the oldest without an action go first, then the oldest with one. Default 500. */ max?: number /** Ids for notices pushed without one. */ nextId?: () => string } export interface AlertStore { /** The rows, on the ordered lane, for a grid or a view. */ readonly store: RowStore<Alert> /** Add a notice, or fold it into the one with the same key. Returns the row as stored. */ push(input: AlertInput): Alert dismiss(ids: RowId | readonly RowId[]): void clear(): void /** Every notice, newest first. */ list(): Alert[] /** How many notices there are. */ size(): number } /** Newest first; at one time the later arrival leads, so notices pushed in one millisecond keep their push order and the order is stable. */ export function byNewest(a: Alert, b: Alert): number { return b.at - a.at || b.seq - a.seq } let counter = 0 function defaultId(): string { counter += 1 return `alert-${Date.now().toString(36)}-${counter}` } export function createAlertStore(options: AlertStoreOptions = {}): AlertStore { const now = options.now ?? Date.now const max = options.max ?? 500 const nextId = options.nextId ?? defaultId const store = createRowStore<Alert>({ getRowId: (alert) => alert.id, lane: "ordered", now }) const byKey = new Map<string, RowId>() let seq = 0 const list = () => store.getIds().map((id) => store.getRow(id)!).sort(byNewest) function trim(): RowId[] { // Room for the newcomer: what is here plus one, against the cap. const over = store.getIds().length + 1 - max if (over <= 0) return [] // Oldest first, and a notice with something to do on it outlives one without. const rows = list().reverse() const without = rows.filter((alert) => !alert.allowedActions?.length) const withActions = rows.filter((alert) => alert.allowedActions?.length) return [...without, ...withActions].slice(0, over).map((alert) => alert.id) } function forget(ids: readonly RowId[]) { for (const id of ids) { const row = store.getRow(id) if (row?.key !== undefined && byKey.get(row.key) === id) byKey.delete(row.key) } } return { store, push(input) { const at = input.at ?? now() const existingId = input.key !== undefined ? byKey.get(input.key) : undefined const existing = existingId !== undefined ? store.getRow(existingId) : undefined if (existing) { // The folded row takes the newcomer's place in time and in arrival order. seq += 1 const fields: Partial<Alert> = { at, seq, severity: input.severity, tone: input.tone, title: input.title, message: input.message, count: existing.count + (input.count ?? 1), allowedActions: input.allowedActions, meta: input.meta, } store.applyDeltas({ patch: [{ id: existing.id, fields }] }) return store.getRow(existing.id)! } seq += 1 const alert: Alert = { ...input, id: input.id ?? nextId(), at, seq, count: input.count ?? 1 } if (alert.key !== undefined) byKey.set(alert.key, alert.id) const remove = trim() // The cap makes room before the newcomer lands, and never takes the newcomer itself. forget(remove) store.applyDeltas({ upsert: [alert], remove: remove.length ? remove : undefined }) return alert }, dismiss(ids) { const list = Array.isArray(ids) ? ids : [ids as RowId] const present = list.filter((id) => store.getRow(id) !== undefined) if (!present.length) return forget(present) store.applyDeltas({ remove: present }) }, clear() { byKey.clear() store.clear() }, list, size: () => store.getIds().length, } }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. // setTimeout's longest delay; a longer one fires at once. const MAX_TIMER_MS = 2 ** 31 - 1 // How long a batch waits for a frame before a timer flushes it: a hidden tab runs no frames. const FRAME_FALLBACK_MS = 250 export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments per batch or clear, before any subscriber is notified. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean /** When the current or most recent reorder hold lapses or lapsed, in Date.now() milliseconds; null before any hold. Optional: without it, parked arrival marks age from their arrival, so a hold longer than the highlight window swallows those flashes. */ holdExpiresAt?(): number | null dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } /** A render-safe snapshot with an explicitly owned, reversible connection to its store. */ export interface PreparedRowView<T> extends RowView<T> { /** Follow batches until the returned release runs. Multiple owners release independently. */ connect(): () => void } /** Publish current row, order and metadata snapshots before notifying any subscriber. */ export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void /** Prepare a current snapshot without subscriptions, registration or timers. Safe during render. */ prepareView(opts?: ViewOptions<T>): PreparedRowView<T> /** Follow the store immediately, even without subscribers. The caller must dispose the view. */ createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] // Snapshots never alias the live array: the first batch appends to ids in place. let idsSnapshot: readonly RowId[] = ids.slice() let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged, meta.version) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, prepareView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.add(view), () => views.delete(view)) return view }, createView(opts) { const view = store.prepareView(opts) view.connect() return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = [] meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true, meta.version) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a === b) return true if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements PreparedRowView<T> { private ids: readonly RowId[] = [] private published: readonly RowId[] = [] private version: number private readonly listeners = new Set<Listener>() private holdUntil = 0 private settlePending = false private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private connections = 0 private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly attach: () => void private readonly detach: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, attach: () => void, detach: () => void) { this.store = store this.opts = opts this.attach = attach this.detach = detach this.now = opts.now ?? Date.now this.version = store.getMeta().version this.ids = this.compute() this.published = this.ids } getIds() { if (this.disposed) return this.ids const version = this.store.getMeta().version const expired = !this.connections && this.settlePending && !this.isHeld() if (version !== this.version || expired) { this.cache(this.isHeld() ? this.computeHeld() : this.compute()) this.version = version if (expired) this.settlePending = false } return this.ids } subscribe(cb: Listener) { if (this.disposed) return () => {} this.listeners.add(cb) return () => { this.listeners.delete(cb) } } connect() { if (this.disposed) return () => {} let released = false const release = () => { if (released || this.disposed) return released = true if (--this.connections === 0) { this.detach() this.clearTimer() } } if (this.connections === 0) { // Refresh while disconnected, including a hold that expired while effects were hidden. this.getIds() this.attach() } this.connections++ try { this.publish() this.armTimer() } catch (error) { release() throw error } return release } isHeld() { return this.now() < this.holdUntil } holdExpiresAt() { // The hold is kept in the view's own clock; the reported deadline is wall // time, so a grid can compare it with Date.now whatever clock was injected. return this.holdUntil > 0 ? Date.now() + (this.holdUntil - this.now()) : null } touch() { if (this.disposed) return // A hold that isn't a positive number holds nothing, and one longer than a timer can wait, Infinity included, holds // as long as one can, about 24.8 days: past that a timer fires at once. const asked = this.opts.reorderHoldMs ?? 0 const hold = asked > 0 ? Math.min(asked, MAX_TIMER_MS) : 0 if (hold <= 0) return if (!this.connections) this.getIds() this.holdUntil = this.now() + hold this.settlePending = true this.clearTimer() this.armTimer() } private armTimer() { if (this.disposed || !this.connections || !this.settlePending || this.timer !== null) return // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.settlePending = false this.cache(this.compute()) this.publish() }, Math.max(0, this.holdUntil - this.now())) } private clearTimer() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } dispose() { if (this.disposed) return this.disposed = true this.clearTimer() this.connections = 0 this.listeners.clear() this.detach() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean, version: number) { // Another view's listener may already have read our current snapshot. It cannot publish it. if (this.version === version) { this.publish() return } this.version = version if (!touched.size && !orderChanged) return if (this.isHeld()) { this.cache(this.computeHeld(removed)) this.publish() return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) { this.cache(this.compute()) this.publish() } } private computeHeld(removed?: Set<RowId>): readonly RowId[] { // Frozen order: drop removals and rows outside the filter, then append sorted newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed?.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) return kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private cache(next: readonly RowId[]) { if (!sameOrder(this.ids, next)) this.ids = next } private publish() { if (sameOrder(this.published, this.ids)) return this.published = this.ids for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame, or after 250 ms when no frame comes. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null // A hidden document runs no frames, so a timer stands in: whichever comes first flushes, and the other is cancelled. let timer: ReturnType<typeof setTimeout> | null = null // The frame scheduled now. A frame the flush cancelled can still run where it can't be cancelled, as with a `raf` // passed without its `caf`, and it finds another frame scheduled, or none, and flushes nothing. let scheduled: object | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { scheduled = null if (handle !== null) { caf(handle) handle = null } if (timer !== null) { clearTimeout(timer) timer = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { // Omitted fields keep their previous values all the way through: a frame that // never mentions gap must not write one, or a replay's recorded gap is // silenced mid-flight. Mentions apply in order, so a gap that opens and // closes inside one frame ends closed, exactly as two frames would. const gapMentioned = delta.meta.gap !== undefined || meta?.gap !== undefined meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), ...(gapMentioned ? { gap: delta.meta.gap ?? meta?.gap } : {}), } if (!gapMentioned) delete (meta as { gap?: boolean }).gap } if (handle === null && timer === null) { const frame = {} scheduled = frame const requested = raf(() => { if (scheduled !== frame) return handle = null flush() }) // A frame that ran as it was requested has flushed already, and leaves nothing to hold. if (scheduled === frame) { handle = requested timer = setTimeout(() => { timer = null flush() }, FRAME_FALLBACK_MS) } } }, flush, cancel() { scheduled = null if (handle !== null) caf(handle) if (timer !== null) clearTimeout(timer) handle = null timer = null reset() }, pending: () => handle !== null || timer !== null, } }
Usage
import { createAlertStore } from "@/lib/alert-store"
const alerts = createAlertStore()
const first = alerts.push({ key: "md:slow", severity: "warning", title: "Feed slow", message: "1.2 s behind" })
const repeat = alerts.push({ key: "md:slow", severity: "critical", title: "Feed slow", message: "4 s behind" })
console.log(first.id === repeat.id) // true
console.log(alerts.size(), repeat.count) // 1 row, count 2
console.log(repeat.severity, repeat.message) // critical, 4 s behind
console.log(alerts.list()) // current notices, newest first
alerts.dismiss(repeat.id)
A repeated key keeps the row's identity and adds to its count. The latest push supplies the other fields; see Coalescing for how omitted fields behave.
Capped notices
Add a notice to exceed the three-row cap. The slow-feed notice starts oldest, but its allowed action gives it priority over the plain notices. Repeats update its count without adding a row. Clear removes everything, including tracked keys; Restore reseeds the original three rows.
This React example subscribes to the underlying store's metadata so repeats redraw immediately. Order subscriptions alone only catch rows being added or removed. The table prints allowed action ids; implementing those actions is the application's job.
import { useRef, useState, useSyncExternalStore } from "react"
import { createAlertStore, type AlertInput, type AlertStore } from "@/lib/alert-store"
const slow: AlertInput = { key: "md:slow", severity: "warning", title: "Feed slow", allowedActions: ["reconnect"] }
function restore(alerts: AlertStore) {
alerts.clear()
alerts.push(slow)
alerts.push({ severity: "info", title: "Notice 1" })
alerts.push({ severity: "info", title: "Notice 2" })
}
export default function AlertStoreCapDemo() {
const [alerts] = useState(() => {
const store = createAlertStore({ max: 3 })
restore(store)
return store
})
const nextNotice = useRef(2)
useSyncExternalStore(alerts.store.subscribeMeta, alerts.store.getMeta, alerts.store.getMeta)
const rows = alerts.list()
const count = rows.reduce((total, alert) => total + alert.count, 0)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push(slow)}>Repeat slow feed</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push({ severity: "info", title: `Notice ${++nextNotice.current}` })}>Add notice</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.clear()}>Clear</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => { nextNotice.current = 2; restore(alerts) }}>Restore</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<p role="status" className="text-muted-foreground">{rows.length} of 3 rows · {count} {count === 1 ? "notice" : "notices"}</p>
<div role="region" aria-label="Capped notices" tabIndex={0} className="max-w-full overflow-x-auto rounded border border-border">
<table className="border-collapse text-left whitespace-nowrap">
<caption className="p-2 text-left text-muted-foreground">Newest first</caption>
<thead><tr className="border-b border-border">
<th scope="col" className="px-2 py-1 font-medium">Title</th>
<th scope="col" className="px-2 py-1 text-right font-medium">Count</th>
<th scope="col" className="px-2 py-1 font-medium">Allowed actions</th>
</tr></thead>
<tbody>{rows.map((alert) => (
<tr key={alert.id} className="border-b border-border last:border-0">
<th scope="row" className="px-2 py-1 font-normal">{alert.title}</th>
<td className="px-2 py-1 text-right">{alert.count}</td>
<td className="px-2 py-1">{alert.allowedActions?.join(", ") || "None"}</td>
</tr>
))}</tbody>
</table>
</div>
</div>
</>
)
}
API Reference
A notice
push(input: AlertInput) accepts the fields below and returns an Alert. Only severity and title are required inputs. Stored alerts also require id, at, seq, and count; the store supplies them as described here.
| Field | Type | Push default | Purpose |
|---|---|---|---|
id |
string |
nextId() for a new row |
Row identity. A coalescing push keeps the existing id, ignoring this input. |
key |
string |
Omitted | Groups repeats; the empty string is also a key. |
at |
number |
now() |
Event time in milliseconds since the epoch. |
seq |
number |
Generated; not an input | Arrival order, incremented on every push from one, including repeats. |
severity |
string |
Required | Your word or the server's, such as info, warning, or fill; the UI prints it unchanged. |
tone |
AlertTone |
Omitted | Optional color token beside the severity. |
title |
string |
Required | Notice heading. |
message |
string |
Omitted | Supporting text. |
count |
number |
1 |
Initial count for a new row, or the amount added to a repeat. |
allowedActions |
string[] |
Omitted | Action ids that may be offered. Omission or an empty list allows none. |
meta |
{ [key: string]: AlertJson } |
Omitted | Your metadata object. |
AlertTone is "up", "down", "flat", "stale", "expiring", "primary", or "destructive". AlertJson is a recursive JSON value: a string, number, boolean, null, array of these values, or object with these values. The store does not validate timestamps, counts, or metadata at runtime.
byNewest(a: Alert, b: Alert): number sorts by descending at, then descending seq. At the same timestamp, the later push leads. An explicit older at can put a new notice or repeat below earlier arrivals. Dismissal and clearing do not reset the sequence.
Options
createAlertStore(options?: AlertStoreOptions): AlertStore accepts:
| Option | Type | Default | Purpose |
|---|---|---|---|
now |
() => number |
Date.now |
Clock in epoch milliseconds, used for omitted at and row-store batch timestamps. |
max |
number |
500 |
Row cap. Supply a positive integer; the store does not validate it. |
nextId |
() => string |
Generated id | Called when a new row has no supplied id. Return a unique id. |
The default id is alert-<time>-<counter>, using base-36 Date.now() and a counter shared across stores in the module. Injecting now does not change this id clock; supply nextId for deterministic ids.
Coalescing
A push whose key matches a tracked row keeps that row's id and key. It replaces at, seq, severity, title, tone, message, allowedActions, and meta, and adds the incoming count (default 1) to the existing count. Omitted optional fields become undefined; a repeat is not a partial update, and metadata is replaced rather than merged.
Dismissal, cap eviction, and clearing forget the removed rows' keys. A later push with one of those keys starts a fresh count using its input or the default 1. A push without a key does not coalesce.
Give distinct live notices distinct ids, whether supplied directly or by nextId. Reusing an id without a matching key replaces that row without accumulating its count and can leave an old key pointing to the replacement. At the cap, the same id may also be selected for removal: the batch upserts before it removes, so the returned notice can be absent from the store.
The cap
When adding a row would exceed max, the store removes existing rows to make room. Oldest notices with missing or empty allowedActions go first, then the oldest with a nonempty list. Oldest means the reverse of byNewest: earlier at, then earlier seq. Any allowed id earns this preference, even if no UI action matches it.
With unique ids, the newcomer is retained even when its timestamp is older than every existing row. Coalescing does not run eviction. Zero and negative caps still retain one newcomer; they do not disable storage.
The rows
| Member | Type | Behavior |
|---|---|---|
store |
RowStore<Alert> |
Readonly reference to the underlying ordered-lane store. |
push |
(input: AlertInput) => Alert |
Adds a notice or folds a repeat into its keyed row. |
dismiss |
(ids: RowId | readonly RowId[]) => void |
Removes one or more ids in a single batch. Unknown ids are ignored; no batch runs when none are present. |
clear |
() => void |
Removes all rows and forgets all keys. Notifies order and metadata listeners even when already empty. |
list |
() => Alert[] |
Returns a new array of all notices, newest first. |
size |
() => number |
Current number of rows, not the sum of their counts. |
RowId is a string. data-grid, views, and useRowIds can read store like any other row-store. The underlying ids stay in insertion order; use store.createView({ comparator: byNewest }) for a newest-first view and dispose it when finished. list() sorts its own array but does not clone the notice objects.
Use the alert store's methods for writes. Direct row-store mutations or edits to returned notices bypass key tracking, sequence assignment, and cap handling. Patching non-key fields through store.applyDeltas — revoking allowedActions on a live notice, say — is safe and keeps the row in place; only id, the key, at, count, and seq belong to the store's own bookkeeping.
Each push uses one synchronous applyDeltas batch, including any evictions. Metadata listeners run once per batch; each touched row's listeners run once, so an eviction can notify both the removed row and the newcomer. Order listeners run only when ids are added or removed; coalescing alone does not notify them. A sorted view can notify when a repeat changes its order. Keep subscriber callbacks nonthrowing: an exception interrupts the remaining notifications.
What it does not do
The store has no timers or toast integration. Your application decides what severity means, when a notice expires, and what each action does. alerts provides composable notice parts, subscription and expiry hooks, an optional history grid, and a toast bridge over this store.
This page is docs/alert-store.md at v2.0.0.