Notification Engine · TypeScript

One notification engine.
Every framework's own reactivity.

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.

🔔
A tiny, typed API add(message, options), update(id, patch), dismiss(id), clear(). Four notification types out of the box: info, success, warning, error.
⏱️
Auto-dismiss you can pause Pass a duration and the engine owns the timer. pause(id) freezes it with its remaining time — hover-to-pause is two event handlers.
📡
Server-driven, transport-agnostic connectSource() bridges SSE, WebSocket, or polling into the engine — the server adds, updates, and dismisses toasts by its own stable key.
🔌
Native to six frameworks React, Vue, Angular, Svelte, SolidJS, and React Native adapters wrap the same core engine with idiomatic hooks, signals, and stores.
$ npm install @daltonr/notifywrite
Live notification stack runs in-browser
6
Framework adapters
0
Dependencies
3
Server transports
100%
Type safe

The cost of reinventing toast state

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.

✗   The status quo
useToasts.ts
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.

✓   With notifywrite
notifications.ts
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.

Three steps. Any framework.

Create one engine instance. Wire it into your framework with the matching adapter. Add, dismiss, and clear from anywhere in your app.

Step 1 — Create

One engine instance

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();
Step 2 — Wire in

Native to your framework

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
}
Step 3 — Drive it

Add, update, dismiss

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);

Everything a notification engine should do

From a single toast to a multi-framework app shell — notifywrite scales with you.

🔔

Tiny, typed API

add, update, dismiss, clear, pause, resume, snapshot, subscribe — a surface you can hold in your head.

⏱️

Self-cleaning timers

Pass a duration and the engine owns the setTimeout handle — dismissed notifications clear their own timers automatically.

⏯️

Pause & resume

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 in place

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.

📡

Server-driven notifications

The /remote module bridges any transport into the engine — sseSource, webSocketSource, and pollingSource ship built in, still zero dependencies.

🚦

Overflow & dismiss reasons

A max option bounds the stack with drop-oldest semantics, and onDismiss tells you why anything left: user, timeout, clear, or overflow.

🔌

Six framework adapters

React, Vue, Angular, Svelte, SolidJS, and React Native — each idiomatic to its framework's reactivity model.

📸

Snapshot + subscribe

Every adapter re-reads snapshot() on change. The pattern is simple enough to bind to any future framework in an afternoon.

🏷️

Four notification types

info, success, warning, error ship by default — easy to theme, easy to filter, easy to test against.

📭

Zero dependencies

The core engine has no runtime dependencies. Nothing to audit, nothing to update, nothing that breaks.

🧪

Designed for testing

A plain class with no DOM and no framework. Assert on snapshot().notifications after calling add() — no rendering required.

🔷

Full type safety

Notification, NotificationSnapshot, NotificationType, and AddOptions are all exported and fully typed.

See it in action

The same engine, wired into different frameworks — and driven from the server.

notifications.ts
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();
ToastHost.tsx
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>
  );
}
ToastHost.vue
<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>
ToastHost.svelte
<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>
server-notifications.ts
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' }

Real-world applications

notifywrite fits anywhere your app needs to surface transient, dismissible feedback.

UI · Toasts

Success / error toasts

Confirm async actions with a short-lived notification, auto-dismissed after a few seconds or dismissible by hand.

on save = engine.add('Saved', { type: 'success', duration: 3000 })
on failure = engine.add('Save failed', { type: 'error' })
Forms · Validation

Field-level feedback

Surface validation errors as transient warning notifications alongside inline field errors, without extra local state per form.

policy = engine.add('Check the highlighted fields', { type: 'warning' })
Server · Jobs

Job progress from the server

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.

on event = { kind: 'update', key: jobId, patch: { message, type } }
UX · Timers

Pause on hover

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.

onMouseEnter = engine.pause(n.id)
onMouseLeave = engine.resume(n.id)
Architecture · Multi-framework

Shared app shells

One engine instance drives notification UI across independently-rendered framework islands — a React shell and a Vue widget stay in sync.

shell = new NotificationEngine() shared via module scope
Testing

Assertions without rendering

Call add() and dismiss() directly, then assert on snapshot().notifications — no component tree required.

expect(engine.snapshot().notifications).toHaveLength(1)

How notifywrite compares

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

Documentation

The whole engine is a couple hundred lines. Reading the source is often the fastest reference.

Stop reimplementing toast state per framework.

One headless notification engine. Six native adapters. Server-driven over SSE, WebSocket, or polling. Zero runtime dependencies.

npm install @daltonr/notifywrite
Read the quickstart View on GitHub