EventSignal is not just another reactive primitive. It's a full-featured, battle-tested signals system designed to bridge event-driven code and modern React UIs — with zero glue code and automatic dependency tracking.
Most signal libraries are built in isolation — they operate within their own ecosystem and require adaptation to work with existing event-based infrastructure. EventSignal is different:
- It natively integrates with any
EventEmitterorEventTargetas a reactive data source - It renders directly in JSX without wrapper components or adapters
- It tracks dependencies automatically — no manual subscriptions, no selector boilerplate
- It handles both sync and async computations with built-in
pending/errorstatus - It ships with React hooks (
use(),useListener()) and a full component type system - It manages its own lifecycle cleanly — destructors,
Symbol.dispose, AbortSignal
| Feature | Description |
|---|---|
| ⚡ Auto-tracking | Dependencies are tracked automatically on .get() calls inside a computation |
| ⚛️ React-native | use() hook, direct JSX rendering, polymorphic component system — no adapters |
| 🔀 Async-ready | First-class async computations with status, lastError, and deduplication |
| 📡 Event bridge | Subscribe to any EventEmitter / EventTarget via sourceEmitter |
| ⏰ Triggers | Clock, emitter, or signal-based recomputation with throttle support |
| 🔗 Derived signals | map(), createMethod(), computed chains — compose complex state from simple pieces |
| 🔮 Promise & async | toPromise(), for await...of async iteration support |
| 🏷️ TypeScript-native | Full generics: EventSignal<T, S, D, R> — typed value, source, data, and return |
| ♻️ Safe lifecycle | destructor(), Symbol.dispose, finaleValue — no memory leaks |
import { EventSignal } from '@termi/eventemitterx/modules/EventEmitterEx/EventSignal';
// Simple writable store
const count$ = new EventSignal(0);
// Computed — automatically tracks count$, recomputes on change
const doubled$ = new EventSignal(0, () => count$.get() * 2);
count$.set(5);
console.log(doubled$.get()); // 10
// Async computed with built-in status tracking
const user$ = new EventSignal(null, async (prev, userId) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
});
// user$.status === 'pending' while fetching, 'error' on failure
// React integration
EventSignal.initReact(React);
function Counter() {
const n = count$.use(); // subscribes & triggers re-render on change
return <button onClick={() => count$.set(n + 1)}>{n}</button>;
}
// Render a signal directly in JSX — no component wrapper needed
const label$ = new EventSignal('Hello', { componentType: 'my-label' });
EventSignal.registerReactComponentForComponentType('my-label', MyLabelComponent);
function App() {
return <div>{label$}</div>; // renders as <MyLabelComponent current$={label$} />
}EventSignal excels at building complex state from simple pieces:
const a$ = new EventSignal(2);
const b$ = new EventSignal(3);
// Computed chain — automatically stays in sync
const sum$ = new EventSignal(0, () => a$.get() + b$.get());
const product$ = new EventSignal(0, () => a$.get() * b$.get());
const label$ = new EventSignal('', () => `${a$.get()} + ${b$.get()} = ${sum$.get()}`);
a$.set(10);
console.log(label$.get()); // "10 + 3 = 13"Connect any EventEmitter or EventTarget to reactive state:
const windowWidth$ = new EventSignal(window.innerWidth, (prev, event) => {
return (event?.target as Window)?.innerWidth ?? prev;
}, {
sourceEmitter: window,
sourceEvent: 'resize',
});
// Now windowWidth$ stays in sync with window resize events automaticallyEventSignal is a reactive signals system compatible with EventEmitter/EventTarget and deeply integrated with React. Signals hold reactive values that automatically track dependencies, support computed values (sync and async), and can be rendered directly in JSX.
import { EventSignal, isEventSignal } from '@termi/eventemitterx/modules/EventEmitterEx/EventSignal';new EventSignal<T, S, D, R>(initialValue: T)
new EventSignal<T, S, D, R>(initialValue: T, options: NewOptions)
new EventSignal<T, S, D, R>(initialValue: T, computation: ComputationFn)
new EventSignal<T, S, D, R>(initialValue: T, computation: ComputationFn, options: NewOptions)| Param | Description |
|---|---|
T |
Value type |
S |
Source value type (defaults to T) |
D |
Data payload type (defaults to undefined) |
R |
Return type from get() (defaults to T) |
type ComputationWithSource<T, S, D, R> = (
prevValue: Awaited<T>,
sourceValue: S | undefined,
eventSignal: EventSignal<T, S, D, R>
) => R | undefined;Returning undefined from a computation means "no update" — the current value is kept.
| Option | Type | Description |
|---|---|---|
description |
string |
Human-readable name (used in Symbol description, React DevTools) |
deps |
{ eventName: symbol }[] |
Explicit dependencies (signal symbols) |
data |
D |
Arbitrary payload attached to the signal |
signal |
AbortSignal |
Abort signal for lifecycle management |
finaleValue |
Awaited<R> |
Value set when signal is destroyed |
finaleSourceValue |
S |
Source value set when signal is destroyed |
componentType |
string | symbol | number |
React component type identifier |
reactFC |
ReactFC |
Direct React function component for rendering |
trigger |
TriggerDescription |
External trigger (clock, emitter, or eventSignal) |
throttle |
TriggerDescription |
Throttle trigger for rate-limiting |
onDestroy |
() => void |
Callback when signal is destroyed |
| Option | Type | Description |
|---|---|---|
sourceEmitter |
EventEmitter | EventTarget |
External event source |
sourceEvent |
EventName | EventName[] |
Event name(s) to listen to |
sourceMap |
(eventName, ...args) => S |
Map event args to source value |
sourceFilter |
(eventName, ...args) => boolean |
Filter events |
initialSourceValue |
S |
Initial source value |
const counter$ = new EventSignal(0, {
description: 'counter',
});
counter$.set(1);
console.log(counter$.get()); // 1const firstName$ = new EventSignal('John');
const lastName$ = new EventSignal('Doe');
const fullName$ = new EventSignal('', () => {
return `${firstName$.get()} ${lastName$.get()}`;
}, {
description: 'fullName',
});
console.log(fullName$.get()); // "John Doe"
firstName$.set('Jane');
// fullName$ automatically recomputes on next access
console.log(fullName$.get()); // "Jane Doe"const signal$ = EventSignal.createSignal(0);
const computed$ = EventSignal.createSignal(0, (prev, source, self) => {
return someOther$.get() * 2;
});Get the current value. Triggers computation if needed. Registers automatic dependency if called inside another computation.
const value = signal$.get();Alias for getSync().
const value = signal$.value;Get the current value synchronously. If the value is a Promise (async computation), returns the last resolved value.
Like get(), but catches errors and returns the last value on failure.
Like getSync() + getSafe(). Returns the last sync value, ignoring errors and async pending state.
Returns the internal _value directly without triggering any computation.
Returns a TryResult<T> object:
type TryResult<T> = {
ok: boolean;
error: unknown | null;
result: T; // Current value or last value if error
};Get the current source value (set via set() or sourceEmitter).
Set a new source value. Triggers recomputation.
counter$.set(42);Set using a function. Receives (prevValue, sourceValue, data).
counter$.set(prev => prev + 1);
counter$.set((prev, source, data) => prev + data.step);Partially update an object value. Only triggers if changes are detected.
const user$ = new EventSignal({ name: 'John', age: 30 });
user$.mutate({ age: 31 });
// Equivalent to: user$.set(prev => ({ ...prev, age: 31 }))
// But more efficient — modifies in place with change detectionForce the next value update even if shallow-equal to the current value.
const counter1$ = new EventSignal(0, { description: 'counter1$' });
const computed1$ = new EventSignal('', (_prev, sourceValue, self) => {
// When set() is called directly on computed1$, propagate to counter1$
if ((self.getStateFlags() & EventSignal.StateFlags.wasSourceSetting) !== 0) {
counter1$.set(sourceValue);
}
return `Value = ${counter1$.get()}`;
}, {
initialSourceValue: counter1$.get(),
description: 'computed1$',
finaleValue: 'Counter is destroyed',
componentType: '--counter--',
});const countersSum$ = new EventSignal(0, () => {
return counter1$.get() + counter2$.get();
}, {
description: 'countersSum',
});const userSignal$ = new EventSignal(1, async (prevUserId, sourceUserId, self) => {
const newUserId = sourceUserId ?? prevUserId;
self.data.abortController.abort();
const abortController = new AbortController();
self.data.abortController = abortController;
const response = await fetch(`https://api.example.com/users/${newUserId}`, {
signal: abortController.signal,
});
const user = await response.json();
self.data.userDTO = user;
return newUserId;
}, {
description: 'user',
componentType: 'userCard',
initialSourceValue: undefined,
data: {
userDTO: null,
abortController: new AbortController(),
},
});Subscribe to value changes. Returns a Subscription object.
const sub = signal$.on((newValue) => {
console.log('New value:', newValue);
});
// Later
sub.unsubscribe();Subscribe for one value change only.
signal$.once((newValue) => {
console.log('First change:', newValue);
});Alternative subscription API. Returns an unsubscribe function (compatible with useSyncExternalStore).
const unsubscribe = signal$.subscribe(() => {
console.log('Changed!');
});interface Subscription {
unsubscribe(): void;
suspend(): boolean; // Pause — returns true if wasn't suspended
resume(): boolean; // Unpause — returns true if was suspended
suspended: boolean;
closed: boolean;
}EventSignal also supports an event-name-based API for compatibility, though the event name is ignored:
signal$.on('change', callback); // 'change' is ignored
signal$.removeListener('data', callback);Valid ignored event names: '', 'change', 'changed', 'data', 'error'. Any other value throws TypeError.
Triggers allow a signal to recompute based on external events.
const clock$ = new EventSignal(0, (prev) => prev + 1, {
trigger: {
type: 'clock',
ms: 1000, // every second
},
});const signal$ = new EventSignal(null, (prev) => /* ... */, {
trigger: {
type: 'emitter',
emitter: someEventTarget,
event: 'resize',
filter: (eventName, event) => event.target.innerWidth > 768,
},
});const signal$ = new EventSignal('', (prev) => /* ... */, {
trigger: {
type: 'eventSignal',
eventSignal: otherSignal$,
},
});Limit computation frequency with a separate trigger:
const throttled$ = new EventSignal(0, () => {
return fastChanging$.get();
}, {
throttle: {
type: 'clock',
ms: 200, // compute at most every 200ms
},
});Subscribe to external event sources:
const signal$ = new EventSignal(null, (prev, sourceValue) => {
return processData(sourceValue);
}, {
sourceEmitter: webSocket,
sourceEvent: 'message',
sourceMap: (eventName, event) => event.data,
sourceFilter: (eventName, event) => event.type === 'update',
});Create typed action functions bound to a signal:
const counter$ = new EventSignal(0);
const increment = counter$.createMethod<number | void>((prevValue, arg = 1) => {
return prevValue + arg;
});
const decrement = counter$.createMethod<number | void>((prevValue, arg = 1) => {
return prevValue - arg;
});
increment(); // counter$.get() === 1
increment(5); // counter$.get() === 6
decrement(2); // counter$.get() === 4Create a read-only derived signal:
const doubled$ = counter$.map(value => value * 2);
console.log(doubled$.get()); // counter$.get() * 2Get a Promise that resolves on next value change:
const nextValue = await signal$.toPromise();for await (const value of signal$) {
console.log('New value:', value);
if (value > 100) break;
}Call once at app startup:
import * as React from 'react';
import { EventSignal } from '@termi/eventemitterx/modules/EventEmitterEx/EventSignal';
EventSignal.initReact(React);Use a signal's value in a React component. Triggers re-render on changes.
function Counter() {
const count = counter$.use();
return <div>{count}</div>;
}With a reducer (selector):
function IsEven() {
const isEven = counter$.use(value => value % 2 === 0);
return <div>{isEven ? 'Even' : 'Odd'}</div>;
}Subscribe to changes without triggering re-renders:
function Logger() {
const lastValue = counter$.useListener((newValue) => {
console.log('Counter changed to:', newValue);
});
return <div>Last: {lastValue}</div>;
}EventSignal instances are valid React elements — render directly in JSX:
const greeting$ = new EventSignal('Hello, World!');
function App() {
return <div>{greeting$}</div>;
}Register React components for signal rendering:
// Register a component for 'user-card' type
EventSignal.registerReactComponentForComponentType('user-card', UserCardComponent);
// Register status-specific components
EventSignal.registerReactComponentForComponentType('user-card', Spinner, 'pending');
EventSignal.registerReactComponentForComponentType('user-card', ErrorView, 'error');
EventSignal.registerReactComponentForComponentType('user-card', ErrorBoundary, 'error-boundary');
// Create a signal with that component type
const user$ = new EventSignal(userData, {
componentType: 'user-card',
});
// Renders as <UserCardComponent current$={user$} />
function App() {
return <div>{user$}</div>;
}Dynamic component switching at runtime:
// Switch component at runtime
EventSignal.registerReactComponentForComponentType('counter', SignalAsString1);
// ...later
EventSignal.registerReactComponentForComponentType('counter', SignalAsString2);Destroy the signal. Cleans up subscriptions, resolves finale values, rejects pending promises.
signal$.destructor();
signal$.destroyed; // trueCheck if signal is destroyed.
Get the dispose function (useful for passing as a callback).
Remove all dependency subscriptions without destroying the signal.
| Property | Type | Description |
|---|---|---|
id |
number |
Auto-incrementing unique ID |
key |
string |
String key (base-36 of id), usable as React key |
isEventSignal |
true |
Type guard marker |
data |
D |
Arbitrary payload |
status |
string? |
Current status: 'default', 'pending', 'error' |
lastError |
unknown? |
Last computation error |
componentType |
string? |
React component type identifier |
version |
number |
Increments on each value change |
computationsCount |
number |
Total computations count |
eventName |
symbol |
Internal signal symbol |
Access via signal$.getStateFlags(). Use with EventSignal.StateFlags enum:
| Flag | Description |
|---|---|
wasDepsUpdate |
A dependency was updated |
wasSourceSetting |
Source value was set (via set() or source emitter) |
wasSourceSettingFromEvent |
Source value came from a source emitter event |
wasThrottleTrigger |
Throttle trigger fired |
wasForceUpdateTrigger |
Force update trigger fired |
isNeedToCalculateNewValue |
Computation is pending |
hasSourceEmitter |
Has a source emitter configured |
hasComputation |
Has a computation function |
hasDepsFromProps |
Has explicit deps from constructor |
hasThrottle |
Has throttle configured |
isDestroyed |
Signal is destroyed |
Type guard to check if a value is an EventSignal instance.
if (isEventSignal(maybeSignal)) {
console.log(maybeSignal.get());
}-
Circular dependencies — Detected at runtime. Throws
EventSignalError('Depends on own value')if a signal reads itself during computation, orEventSignalError('Now in computing state (cycle deps?)')for indirect cycles. -
Undefined from computation — Returning
undefinedmeans "no update". The current value is preserved. -
Object equality — Object values use shallow equality by default. Use
markNextValueAsForced()to bypass. -
Async computation — Experimental. Sets status to
'pending'during async computation. Concurrent async computations are deduplicated — only the last one's result is used. -
Destroyed signal reads —
get()returns the last value (orfinaleValueif set).set()is a no-op. -
React StrictMode — Compatible. Double-invocations from StrictMode are handled correctly.
EventSignal is actively developed. Here are the planned improvements and new features on the horizon.
-
Visibility-aware rendering — Signals will leverage
IntersectionObserverto automatically skip re-rendering components that are currently off-screen. This dramatically reduces wasted renders in long lists, virtualized layouts, and off-viewport panels — with zero configuration required. -
HTML signal bindings — First-class JSX wrappers for native HTML elements with automatic two-way binding: DOM events update the signal, signal changes update the DOM:
// Two-way binding out of the box <EventSignal.$.input value={text$} /> <EventSignal.$.textarea value={bio$} /> <EventSignal.$.select value={country$} /> <EventSignal.$.input type="checkbox" checked={isDark$} />
No
onChangehandlers, novalue={x}+onChange={() => setX(...)}boilerplate.
Ergonomic factory functions as the primary API — replacing new EventSignal(...) with intent-revealing helpers:
import { createSignal, createComputedSignal, createReadonlySignal,
createAsyncSignal, createSourceSignal } from '@termi/eventsignal';
const count$ = createSignal(0); // writable store
const doubled$ = createComputedSignal(() => count$.get() * 2);// auto-tracked computed
const readonly$ = createReadonlySignal(count$); // read-only view
const user$ = createAsyncSignal(async () => // async computed
fetchUser(id$.get())
);
const resize$ = createSourceSignal(window, 'resize', // EventTarget source
(e) => e.target.innerWidth
);EventSignal will be extracted as a fully independent npm package — zero dependency on EventEmitterX. If you only need reactive signals and don't use the event system, you'll be able to install just:
npm install @termi/eventsignalSame API, same TypeScript types, smaller bundle.
ThrottleDescriptionDebounce — full control over how and when subscriber notifications are fired:
// Debounce mode: notify 300ms after the *last* update
const search$ = new EventSignal('', async (prev, query) => fetchResults(query), {
throttle: {
type: 'debounce',
ms: 300,
},
});
// Throttle mode: notify no more often than every 200ms
const scroll$ = new EventSignal(0, () => window.scrollY, {
throttle: {
type: 'throttle',
ms: 200,
},
});Two configurable modes:
- Throttle — fire notifications no more often than every N ms ("leading edge")
- Debounce — fire notification only after N ms of inactivity since the last update ("trailing edge")
New sync option for persisting signal values to external storage — signals that survive page reloads, share state across tabs, or sync with a server:
// Persist to localStorage
const theme$ = new EventSignal('light', {
sync: {
load: () => localStorage.getItem('theme') ?? 'light',
save: (value) => localStorage.setItem('theme', value),
},
});
// Async sync with custom API
const settings$ = new EventSignal(defaultSettings, {
sync: {
load: () => api.getSettings(),
save: (value) => api.saveSettings(value),
},
});batch()— group multiple signal updates into a single subscriber notificationpeek()— read a signal's value inside a computation without registering a dependency- Improved React DevTools integration with signal names and dependency graphs
- Performance improvements and bundle size reduction