Toast state ends up reimplemented per framework — a useState array here, a
ref there, a store somewhere else, each with its own timer bookkeeping. notifywrite is a
headless notification engine: a small typed core with add, update,
dismiss, and clear, exposed through a snapshot-and-subscribe API
that six framework adapters wrap natively — and that your server can drive over SSE,
WebSocket, or polling.
add(message, options), update(id, patch), dismiss(id), clear(). Four notification types out of the box: info, success, warning, error.
duration and the engine owns the timer. pause(id) freezes it with its remaining time — hover-to-pause is two event handlers.
connectSource() bridges SSE, WebSocket, or polling into the engine — the server adds, updates, and dismisses toasts by its own stable key.
Every framework ends up with its own copy of the same logic — an array of notifications, a timer per entry, an id counter, add/dismiss helpers — duplicated wherever a toast needs to appear.
function useToasts() {
const [toasts, setToasts] = useState([]);
function addToast(message, type, duration) {
const id = crypto.randomUUID();
setToasts(t => [...t, { id, message, type }]);
// ...and now re-implement this in Vue, Svelte, Angular
if (duration) {
setTimeout(() => dismissToast(id), duration);
}
}
// dismissToast, clearToasts, timer cleanup on unmount...
}
Not shared across frameworks. Not testable without rendering a component. Timers leak if you forget cleanup on unmount.
import { NotificationEngine } from '@daltonr/notifywrite';
export const notifications = new NotificationEngine();
notifications.add('File saved', {
type: 'success',
duration: 3000,
});
// engine owns id generation, timers, and cleanup
// every adapter subscribes to the same instance
One engine instance, one implementation. Every framework adapter subscribes to it with a native hook, signal, or store — no logic duplicated, no timers to manually track.
Create one engine instance. Wire it into your framework with the matching adapter. Add, dismiss, and clear from anywhere in your app.
A single NotificationEngine holds your app's notification state. No provider is required to use it — pass it wherever you need it.
import { NotificationEngine } from '@daltonr/notifywrite';
export const notifications =
new NotificationEngine();
Each adapter subscribes to the engine and returns state shaped for its framework — a React hook, a Vue composable, a Svelte store, an Angular signal.
import { useNotifications }
from '@daltonr/notifywrite-react';
function ToastHost() {
const { notifications } =
useNotifications(engine);
// re-renders on add/dismiss/clear
}
Call the engine from anywhere — a form handler, an error boundary, a job callback, or a server stream. Every subscriber updates immediately.
const id = engine.add('Uploading…');
engine.update(id, {
message: 'Upload complete',
type: 'success',
duration: 3000,
});
engine.pause(id); // e.g. on hover
engine.resume(id);
From a single toast to a multi-framework app shell — notifywrite scales with you.
add, update, dismiss, clear, pause, resume, snapshot, subscribe — a surface you can hold in your head.
Pass a duration and the engine owns the setTimeout handle — dismissed notifications clear their own timers automatically.
pause(id) freezes an auto-dismiss timer keeping its remaining time; resume(id) restarts it. remaining(id) tells you what's left — toast progress bars come free.
update(id, patch) changes message, type, or duration on a live notification, preserving its id and position — one toast can walk through queued, processing, complete.
The /remote module bridges any transport into the engine — sseSource, webSocketSource, and pollingSource ship built in, still zero dependencies.
A max option bounds the stack with drop-oldest semantics, and onDismiss tells you why anything left: user, timeout, clear, or overflow.
React, Vue, Angular, Svelte, SolidJS, and React Native — each idiomatic to its framework's reactivity model.
Every adapter re-reads snapshot() on change. The pattern is simple enough to bind to any future framework in an afternoon.
info, success, warning, error ship by default — easy to theme, easy to filter, easy to test against.
The core engine has no runtime dependencies. Nothing to audit, nothing to update, nothing that breaks.
A plain class with no DOM and no framework. Assert on snapshot().notifications after calling add() — no rendering required.
Notification, NotificationSnapshot, NotificationType, and AddOptions are all exported and fully typed.
The same engine, wired into different frameworks — and driven from the server.
import { NotificationEngine } from '@daltonr/notifywrite';
// max bounds the stack — oldest are dropped with reason 'overflow'
export const notifications = new NotificationEngine({ max: 5 });
// Add a notification — id and createdAt are generated for you
const id = notifications.add('Changes saved', {
type: 'success',
duration: 3000, // auto-dismisses after 3s
});
// Patch a live notification in place — id and createdAt survive
notifications.update(id, { message: 'Synced to server' });
// Freeze the timer with its remaining time, restart it later
notifications.pause(id);
notifications.resume(id);
// Subscribe to every change; onDismiss also tells you why
const unsubscribe = notifications.subscribe(() => {
render(notifications.snapshot());
});
notifications.onDismiss((n, reason) => {
// 'user' | 'timeout' | 'clear' | 'overflow'
});
notifications.dismiss(id);
notifications.clear();
import { NotificationProvider, useNotificationsContext } from '@daltonr/notifywrite-react';
import { notifications } from './notifications';
function App() {
return (
<NotificationProvider engine={notifications}>
<ToastHost />
</NotificationProvider>
);
}
function ToastHost() {
const { notifications, dismiss } = useNotificationsContext();
return (
<div className="toast-stack">
{notifications.map(n => (
<div key={n.id} className={`toast toast-${n.type}`}>
{n.message}
<button onClick={() => dismiss(n.id)}>×</button>
</div>
))}
</div>
);
}
<script setup>
import { injectNotifications } from '@daltonr/notifywrite-vue';
// provideNotifications(engine) is called once, in a parent component
const { notifications, dismiss } = injectNotifications();
</script>
<template>
<div class="toast-stack">
<div
v-for="n in notifications"
:key="n.id"
:class=`toast toast-${n.type}`
>
{{ n.message }}
<button @click="dismiss(n.id)">×</button>
</div>
</div>
</template>
<script>
import { useNotificationsContext } from '@daltonr/notifywrite-svelte';
// provideNotifications(engine) is called once, in a parent component
const { notifications, dismiss } = useNotificationsContext();
</script>
<div class="toast-stack">
{#each $notifications as n (n.id)}
<div class="toast toast-{n.type}">
{n.message}
<button on:click={() => dismiss(n.id)}>×</button>
</div>
{/each}
</div>
import { NotificationEngine } from '@daltonr/notifywrite';
import { connectSource, sseSource } from '@daltonr/notifywrite/remote';
const engine = new NotificationEngine();
// Bridge a server stream into the engine. Also built in:
// webSocketSource(url | socket) and pollingSource(fetcher, ms) —
// or write your own: any (emit) => teardown function plugs in.
const disconnect = connectSource(engine, sseSource('/api/notifications'));
// The server addresses toasts by its own stable key —
// one toast walks through a whole job lifecycle:
// { kind: 'add', key: 'job-42', message: 'Export queued…' }
// { kind: 'update', key: 'job-42', patch: { message: 'Processing…' } }
// { kind: 'update', key: 'job-42', patch: { message: 'Export ready',
// type: 'success', duration: 5000 } }
// { kind: 'dismiss', key: 'job-42' }
notifywrite fits anywhere your app needs to surface transient, dismissible feedback.
Confirm async actions with a short-lived notification, auto-dismissed after a few seconds or dismissible by hand.
Surface validation errors as transient warning notifications alongside inline field errors, without extra local state per form.
An SSE or WebSocket stream walks one toast through queued → processing → complete with key-based updates — connectSource keeps the server's key mapped to the toast it created.
Freeze a toast's auto-dismiss while the pointer is over it and let it finish afterwards — the engine keeps the remaining time, so no timer maths in your component.
One engine instance drives notification UI across independently-rendered framework islands — a React shell and a Vue widget stay in sync.
Call add() and dismiss() directly, then assert on snapshot().notifications — no component tree required.
No other approach gives you one engine, one behaviour, and native adapters across six frameworks — with zero runtime dependencies.
| notifywrite | Raw useState / refs | Framework toast libs | Hand-rolled event bus | |
|---|---|---|---|---|
| Identical behaviour across frameworks | ✓ | ✗ | ✗ | Depends |
| Six native framework adapters | ✓ | ✗ | One framework | ✗ |
| Built-in auto-dismiss timers | ✓ | If you build it | ✓ | If you build it |
| Update / pause / resume live toasts | ✓ | If you build it | Varies | If you build it |
| Server-driven (SSE / WebSocket / polling) | ✓ | ✗ | ✗ | If you build it |
| Typed notification model | ✓ | If you build it | Varies | If you build it |
| Snapshot + subscribe API | ✓ | ✗ | ✗ | Depends |
| Testable without rendering | ✓ | ✗ | ✗ | Depends |
| Zero dependencies | ✓ | ✓ | ✗ | ✓ |
| Full TypeScript type safety | ✓ | Varies | Varies | Varies |
The whole engine is a couple hundred lines. Reading the source is often the fastest reference.
Install, create an engine, wire in your framework's adapter. Up and running in a few minutes.
📐The entire notification engine — add, update, dismiss, pause/resume, overflow, and timer handling — in one small file.
📡The /remote module: connectSource plus SSE, WebSocket, and polling transports — with a full-stack Express + React demo.
React, Vue, Angular, Svelte, SolidJS, and React Native — each adapter's source and API in one place.
🗂️Runnable demo apps for every supported framework, including a dependency-free vanilla JS playground.
One headless notification engine. Six native adapters. Server-driven over SSE, WebSocket, or polling. Zero runtime dependencies.