ssignal
v1.9.1
Published
Lightweight zero-dependency reactive signal built on the native EventTarget API. Supports any value type, reactive Map mutations, updater functions, and AbortSignal integration.
Maintainers
Readme
SSignal
A lightweight, zero-dependency reactive signal built on top of the native EventTarget API. SSignal lets you observe value changes on any data type — including deep mutations on Map instances — without a framework, build plugin, or compiler transform.
Features
- Simple API —
value,subscribe, and an unsubscribe function. That's it. - Framework-agnostic — works in the browser, Node.js ≥ 18.7, and any runtime that supports
EventTarget. - Reactive
Mapsupport — mutations viaset(),delete(), andclear()automatically dispatch change events. - Updater functions —
signal.value = (prev) => prev + 1for safe derived updates. - In-place mutations —
signal.mutate((list) => list.push(item))for arrays and objects, with a single change event. - Immediate mode —
{ immediate: true }fires the callback with the current value on subscribe. - One-time subscriptions —
once()listens for the next change only, then unsubscribes itself. - Computed signals — derive read-only signals from one or more sources with
computed(). - Effects —
effect()runs side effects now and on every change, with cleanup and a single dispose. - Custom equality —
{ equals }decides when an assigned value counts as a change. - Batched updates —
batch()groups several changes into one notification per signal. - Disposable —
dispose()removes every subscription at once, and signals work withusing. - AbortSignal integration — cancel subscriptions with a standard
AbortController. - TypeScript-first — fully typed, zero
anyin the public API. - Tree-shakeable —
sideEffects: false, ships ESM + CJS + UMD.
Installation
npm install ssignalCDN (browser)
<script src="https://unpkg.com/ssignal@latest/lib/ssignal.umd.js"></script>API
| Member | Description |
| :----- | :---------- |
| new SSignal(value: T, options?) | Creates a signal. Map values are automatically wrapped in a reactive proxy. Options: SSignalOptions<T>. |
| signal.value | Gets the current value. |
| signal.value = newValue \| (prev: T) => T | Sets a new value. Accepts a direct value or an updater function. No event is fired when the value does not change. |
| signal.mutate(mutator) | Mutates the value in place (arrays, objects…) and fires one change event afterwards. Return false from the mutator to skip the event. Throws on computed signals. |
| signal.subscribe(callback, options?) | Registers a listener called on every change. Returns an Unsubscribe function. Options: SubscribeOptions. |
| signal.once(callback, options?) | Registers a listener called only on the next change, then unsubscribes automatically. Returns an Unsubscribe function. Options: OnceOptions. With immediate, it runs right away with the current value instead, and that is its only call. |
| computed(source, fn, options?) | Creates a read-only ComputedSignal derived from one source. Accepts the same equals option. |
| computed([...sources], fn, options?) | Creates a read-only ComputedSignal derived from multiple sources. Accepts the same equals option. |
| signal.dispose() | Removes every subscribe()/once() subscription at once. The signal stays usable. Also available as [Symbol.dispose]() for using. |
| computed.dispose() | Removes all source subscriptions and its own subscribers. Call when the signal is no longer needed. |
| effect(source, fn, options?) | Runs fn with the current value, then after every change. fn may return a cleanup, called before the next run and on dispose. Returns a dispose function. Options: EffectOptions. |
| effect([...sources], fn, options?) | Same, with the values of several sources as a tuple. Sources changed together in batch() cause a single run. |
| batch(fn) | Runs fn and defers change events until it returns, so each changed signal notifies once with its final value. Returns what fn returns. |
Types
All types are exported from the package entry:
import type {
EffectCleanup,
EffectOptions,
OnceOptions,
SSignalOptions,
SubscribeOptions,
Unsubscribe,
} from 'ssignal';| Type | Definition |
| :--- | :--------- |
| Unsubscribe | () => void |
| SubscribeOptions | { signal?: AbortSignal; immediate?: boolean } |
| OnceOptions | { signal?: AbortSignal; immediate?: boolean } |
| EffectOptions | { signal?: AbortSignal } |
| EffectCleanup | () => void |
| SSignalOptions<T> | { equals?: (prev: T, next: T) => boolean } |
Events
| Event | Type | Description |
| :---- | :--- | :---------- |
| change | CustomEvent<T> | Fired when the value changes. The new value is available as event.detail. |
Architecture
flowchart TD
subgraph writes["Ways to change a signal"]
set["signal.value = next<br/>or (prev) => next"]
mutate["signal.mutate(fn)"]
coll["Map / Set proxy<br/>set · add · delete · clear"]
end
set --> equals{"equals(prev, next)?<br/>default Object.is"}
equals -- "equal" --> skip(["ignored, no event"])
equals -- "changed" --> notify
mutate -- "unless fn returns false" --> notify
coll -- "only if it really changed" --> notify
notify["#notify()"] --> inBatch{"inside batch()?"}
inBatch -- "yes" --> queue[("batch queue<br/>one entry per signal")]
queue -- "outermost batch ends" --> notify
inBatch -- "no" --> dispatching{"already dispatching?"}
dispatching -- "yes" --> pending["mark pending<br/>(new round after the current one)"]
dispatching -- "no" --> dispatch["EventTarget.dispatchEvent<br/>CustomEvent('change')"]
pending -. "once every listener has run" .-> dispatch
dispatch --> subscribe["subscribe() listeners"]
dispatch --> once["once() listeners<br/>removed after first call"]
dispatch --> sourcesChanged
dispatch --> changed
subgraph computed["ComputedSignal (read-only)"]
sourcesChanged{"new source values or<br/>in-place mutation?"}
sourcesChanged -- "no, e.g. second source<br/>of the same batch" --> noRecompute(["skipped"])
sourcesChanged -- "yes" --> recompute["fn(...source values)"]
recompute -- "source mutated in place,<br/>same object returned" --> notify2["#notify() of the computed"]
recompute -- "otherwise" --> equals2["equals check of the computed"]
end
subgraph effects["effect()"]
changed{"new values or<br/>in-place mutation?"}
changed -- "yes" --> rerun["previous cleanup,<br/>then fn(values)"]
changed -- "no, e.g. second source<br/>of the same batch" --> noRun(["skipped"])
end
subgraph teardown["Removing subscriptions"]
disposeAll["signal.dispose()<br/>or end of a using block"]
single["unsubscribe()<br/>or AbortSignal abort"]
end
disposeAll -. "removes all" .-> subscribe
disposeAll -. "removes all" .-> once
single -. "removes one" .-> subscribeSSignalextends the nativeEventTarget. Every change ends in a single private#notify(), which dispatches achangeevent whosedetailis the current value.- Writes come from three places: assignments (filtered by
equals),mutate(), and the Map/Set proxy, which only notifies when the collection actually changed. Insidemutate(), Map/Set changes are folded into its single event. #notify()queues the signal while abatch()is running, so it notifies once when the batch ends. If a listener changes the signal during a dispatch, the change is delivered in a follow-up round rather than a nested one, so every listener ends on the latest value.- Consumers all listen to that event:
subscribe()andonce()callbacks,ComputedSignal, andeffect(). ComputedSignalre-runsfnwhen its sources bring new values or were mutated in place, then goes through its ownequalscheck, or notifies directly after an in-place mutation. It skips notifications that bring nothing new, so several sources changed in onebatch(), or both paths of a diamond, cause a single run. Its sources reference it only through aWeakRef, but it stays alive while it has listeners.dispose()removes its source subscriptions and its own subscribers.effect()runs the previous cleanup and thenfnwhenever its sources bring new values or were mutated in place, and skips notifications that bring nothing new, so several sources changed in onebatch()cause a single run. Its dispose function stops it and runs the last cleanup.- Teardown:
unsubscribe()or anAbortSignalremoves one subscription, whiledispose()(also called at the end of ausingblock) removes everysubscribe()/once()subscription of a signal at once.
Usage examples
Plain function / vanilla JS
import SSignal from 'ssignal';
const counter = new SSignal(0);
const unsubscribe = counter.subscribe((value) => {
console.log('counter changed:', value);
});
counter.value = 1; // logs: counter changed: 1
counter.value = (n) => n + 1; // logs: counter changed: 2
counter.value = 2; // no log — same value, no event fired
unsubscribe();
counter.value = 99; // no log — already unsubscribedReact component
import { useEffect, useState } from 'react';
import SSignal from 'ssignal';
// Create signals outside the component so they are shared across the app
export const themeSignal = new SSignal<'light' | 'dark'>('light');
export const cartSignal = new SSignal(new Map<string, number>());
// Generic hook to bind any SSignal to local state
function useSignal<T>(signal: SSignal<T>): T {
const [value, setValue] = useState<T>(signal.value);
useEffect(() => {
const controller = new AbortController();
// immediate: true keeps state in sync if the signal changes between
// render and the effect running
signal.subscribe((v) => setValue(v), { signal: controller.signal, immediate: true });
return () => controller.abort();
}, [signal]);
return value;
}
export function ThemeToggle() {
const theme = useSignal(themeSignal);
return (
<button onClick={() => themeSignal.value = theme === 'light' ? 'dark' : 'light'}>
Current theme: {theme}
</button>
);
}
export function Cart() {
const cart = useSignal(cartSignal);
const addItem = (id: string) => {
cartSignal.value.set(id, (cart.get(id) ?? 0) + 1);
};
return (
<div>
<p>Items in cart: {cart.size}</p>
<button onClick={() => addItem('product-1')}>Add product</button>
</div>
);
}Express backend
import express from 'express';
import SSignal from 'ssignal';
const app = express();
// Shared application state
const connectedClients = new SSignal(0);
const featureFlags = new SSignal(new Map<string, boolean>([
['new-checkout', false],
['dark-mode', true],
]));
// Log every time the client count changes
connectedClients.subscribe((count) => {
console.log(`[${new Date().toISOString()}] Connected clients: ${count}`);
});
app.use((req, res, next) => {
connectedClients.value = (n) => n + 1;
res.on('finish', () => {
connectedClients.value = (n) => n - 1;
});
next();
});
app.get('/flags', (req, res) => {
res.json(Object.fromEntries(featureFlags.value));
});
app.patch('/flags/:name', express.json(), (req, res) => {
const { name } = req.params;
featureFlags.value.set(name, req.body.enabled);
res.sendStatus(204);
});
app.listen(3000, () => console.log('Server running on port 3000'));Reactive Map
import SSignal from 'ssignal';
const store = new SSignal(new Map<string, number>());
store.subscribe((map) => {
console.log('store changed, size:', map.size);
});
store.value.set('a', 1); // logs: store changed, size: 1
store.value.set('b', 2); // logs: store changed, size: 2
store.value.delete('a'); // logs: store changed, size: 1
store.value.clear(); // logs: store changed, size: 0Only calls made through signal.value are tracked. The signal wraps the collection in a proxy, so if you keep the original Map/Set you passed in and mutate it directly, no event is fired. Set values work the same way with add(), delete() and clear().
Arrays and objects
Arrays and plain objects are not wrapped, so in-place changes such as push() or obj.x = 1 are not detected on their own. Use mutate(): it runs your changes and fires one change event at the end.
import SSignal from 'ssignal';
const todos = new SSignal<string[]>([]);
todos.subscribe((list) => console.log('todos:', list.length));
todos.mutate((list) => {
list.push('write docs');
list.push('ship it');
}); // logs once: todos: 2
// Return false to skip the event when nothing changed
todos.mutate((list) => {
if (list.includes('ship it')) return false;
list.push('ship it');
}); // no logAn assigned function is always treated as an updater. To store a function as the value, return it from one: signal.value = () => handler.
Immediate mode
import SSignal from 'ssignal';
const user = new SSignal({ name: 'Ivan' });
// Fires immediately with current value, then on every change
user.subscribe((v) => console.log('user:', v.name), { immediate: true });
// logs: user: Ivan ← fired synchronously on subscribe
user.value = { name: 'Junior' };
// logs: user: JuniorOne-time subscription
import SSignal from 'ssignal';
type CheckoutState =
| { status: 'idle' }
| { status: 'processing'; orderId: string }
| { status: 'paid'; orderId: string; receiptUrl: string }
| { status: 'failed'; orderId: string; reason: string };
const checkout = new SSignal<CheckoutState>({ status: 'idle' });
function openCheckout(orderId: string) {
const controller = new AbortController();
checkout.value = { status: 'processing', orderId };
// Registered after 'processing', so it reacts to the next state: the payment result
checkout.once((state) => {
if (state.status === 'paid') {
window.location.assign(state.receiptUrl);
}
}, { signal: controller.signal });
return {
close: () => controller.abort(),
};
}
const modal = openCheckout('order_123');
checkout.value = {
status: 'paid',
orderId: 'order_123',
receiptUrl: '/receipts/order_123',
}; // redirects once
modal.close(); // no effect after the one-time listener has already firedWith { immediate: true }, once() runs the callback right away with the current value instead of waiting for a change. That is its only call, so nothing stays registered:
const config = new SSignal({ theme: 'dark' });
config.once((c) => applyTheme(c.theme), { immediate: true }); // runs now, once
config.value = { theme: 'light' }; // not called againComputed signals
import SSignal, { computed } from 'ssignal';
// Single source
const price = new SSignal(100);
const withTax = computed(price, (p) => p * 1.21);
withTax.subscribe((v) => console.log('price with tax:', v), { immediate: true });
// logs: price with tax: 121
price.value = 200;
// logs: price with tax: 242
// Multiple sources
const qty = new SSignal(3);
const total = computed([price, qty], ([p, q]) => p * q);
total.subscribe((v) => console.log('total:', v), { immediate: true }); // logs: total: 600
qty.value = 5; // logs: total: 1000
// computed signals are read-only
total.value = 0; // throws TypeError
// clean up when no longer needed
total.dispose();Computed signals skip updates when the derived value is unchanged, except after an in-place mutation of a source (mutate() or a Map/Set change). There, a derived object with the same reference still notifies, because it may be what was mutated:
const todos = new SSignal<string[]>([]);
const list = computed(todos, (items) => items);
list.subscribe((items) => console.log(items.length));
todos.mutate((items) => items.push('write docs')); // logs: 1Effects
effect() runs a side effect right away and again whenever its sources change. Dependencies are listed explicitly, like in computed(). Return a function to clean up before the next run and when the effect is disposed:
import SSignal, { effect } from 'ssignal';
const userId = new SSignal(1);
const dispose = effect(userId, (id) => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((res) => res.json())
.then(render);
return () => controller.abort(); // cancels the previous request
});
userId.value = 2; // aborts the request for user 1, fetches user 2
dispose(); // aborts the pending request, stops following userIdWith several sources, fn receives their values as a tuple, and sources changed together inside batch() trigger a single run. Pass { signal } to dispose the effect with an AbortController.
Custom equality
By default an assignment is ignored when Object.is(prev, next) is true. Pass equals to compare by content instead, for example when you always build new objects:
import SSignal, { computed } from 'ssignal';
const samePoint = (a: { x: number; y: number }, b: { x: number; y: number }) =>
a.x === b.x && a.y === b.y;
const point = new SSignal({ x: 0, y: 0 }, { equals: samePoint });
point.subscribe((p) => console.log('moved to', p));
point.value = { x: 0, y: 0 }; // no log, equal by content
point.value = { x: 1, y: 0 }; // logs: moved to { x: 1, y: 0 }
// Also on computed signals
const size = new SSignal({ width: 1920, height: 1080 });
const orientation = computed(size, (s) => ({ landscape: s.width > s.height }), {
equals: (a, b) => a.landscape === b.landscape,
});Return false to notify on every assignment. equals is not consulted by mutate() or Map/Set mutations, since the reference does not change.
Batched updates
import SSignal, { batch, computed } from 'ssignal';
const price = new SSignal(100);
const qty = new SSignal(1);
const total = computed([price, qty], ([p, q]) => p * q);
total.subscribe((v) => console.log('total:', v));
batch(() => {
price.value = 200;
qty.value = 3;
}); // logs once: total: 600total is computed once, with both new values, even when fn returns a new object each time. Values change immediately inside batch(); only the events wait. When a listener changes the signal it is listening to, the new value is delivered in a follow-up round after every listener has seen the current one, so all listeners finish on the latest value.
Disposing subscriptions
dispose() removes every subscription made with subscribe() or once() in one call, without keeping each unsubscribe function around. The signal can still be read, set and subscribed to afterwards.
import SSignal from 'ssignal';
const status = new SSignal('idle');
status.subscribe(render);
status.subscribe(log);
status.dispose(); // both listeners removedSignals also implement Symbol.dispose, so a using declaration cleans them up when the block ends:
{
using status = new SSignal('idle');
status.subscribe(render);
} // status.dispose() runs hereListeners added directly with addEventListener are not tracked by dispose(). using needs TypeScript 5.2+ and a runtime with Symbol.dispose (Node 18.18+, recent browsers).
AbortController
import SSignal from 'ssignal';
const signal = new SSignal(0);
const controller = new AbortController();
signal.subscribe((v) => console.log(v), { signal: controller.signal });
signal.value = 1; // logs: 1
controller.abort();
signal.value = 2; // no logScripts
| Command | Description |
| :------ | :---------- |
| npm run build | Compile and bundle to lib/. |
| npm test | Run unit tests. |
| npm run test:coverage | Run unit tests with coverage report. |
| npm run test:performance | Run performance tests. |
Performance
SSignal handles 200,000 value updates notifying 10 simultaneous subscribers in under 500 ms.

License
MIT — see LICENSE.
Repository: github.com/ElJijuna/ssignal
