๐ Live Demo
Try EventSignal in an interactive React 19 demo application โ Open Demo โ
EventSignal โ API Reference โ
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.
Why EventSignal? โ
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 Overview โ
| 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 |
Quick Start โ
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$} />
}Reactive Composition โ
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"Bridging External Events โ
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 automaticallyOverview โ
EventSignal 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 โ
import { EventSignal, isEventSignal } from '@termi/eventemitterx/modules/EventEmitterEx/EventSignal';Constructor โ
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)Type Parameters โ
| 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) |
Computation Function โ
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.
NewOptions โ
| 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 |
NewOptionsWithSource (extends NewOptions) โ
| 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 |
Creating Signals โ
Simple signal (store) โ
const counter$ = new EventSignal(0, {
description: 'counter',
});
counter$.set(1);
console.log(counter$.get()); // 1Computed signal โ
const 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"Static factory โ
const signal$ = EventSignal.createSignal(0);
const computed$ = EventSignal.createSignal(0, (prev, source, self) => {
return someOther$.get() * 2;
});Value Access โ
get() โ
Get the current value. Triggers computation if needed. Registers automatic dependency if called inside another computation.
const value = signal$.get();value (getter) โ
Alias for getSync().
const value = signal$.value;getSync() โ
Get the current value synchronously. If the value is a Promise (async computation), returns the last resolved value.
getSafe() โ
Like get(), but catches errors and returns the last value on failure.
getSyncSafe() โ
Like getSync() + getSafe(). Returns the last sync value, ignoring errors and async pending state.
getLast() โ
Returns the internal _value directly without triggering any computation.
tryGet() โ
Returns a TryResult<T> object:
type TryResult<T> = {
ok: boolean;
error: unknown | null;
result: T; // Current value or last value if error
};getSourceValue() โ
Get the current source value (set via set() or sourceEmitter).
Value Modification โ
set(newSourceValue) โ
Set a new source value. Triggers recomputation.
counter$.set(42);set(setter) โ
Set using a function. Receives (prevValue, sourceValue, data).
counter$.set(prev => prev + 1);
counter$.set((prev, source, data) => prev + data.step);mutate(props) โ
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 detectionmarkNextValueAsForced() โ
Force the next value update even if shallow-equal to the current value.
Computed Signals โ Real-World Examples โ
Counter with string representation (from demo) โ
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--',
});Sum of two signals โ
const countersSum$ = new EventSignal(0, () => {
return counter1$.get() + counter2$.get();
}, {
description: 'countersSum',
});Async computed signal (API fetch, from demo) โ
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(),
},
});Subscriptions โ
on(callback) / addListener(callback) โ
Subscribe to value changes. Returns a Subscription object.
const sub = signal$.on((newValue) => {
console.log('New value:', newValue);
});
// Later
sub.unsubscribe();once(callback) โ
Subscribe for one value change only.
signal$.once((newValue) => {
console.log('First change:', newValue);
});subscribe(callback) โ
Alternative subscription API. Returns an unsubscribe function (compatible with useSyncExternalStore).
const unsubscribe = signal$.subscribe(() => {
console.log('Changed!');
});Subscription object โ
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;
}EventEmitter-compatible API โ
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 โ
Triggers allow a signal to recompute based on external events.
Clock trigger โ
const clock$ = new EventSignal(0, (prev) => prev + 1, {
trigger: {
type: 'clock',
ms: 1000, // every second
},
});Emitter trigger โ
const signal$ = new EventSignal(null, (prev) => /* ... */, {
trigger: {
type: 'emitter',
emitter: someEventTarget,
event: 'resize',
filter: (eventName, event) => event.target.innerWidth > 768,
},
});EventSignal trigger โ
const signal$ = new EventSignal('', (prev) => /* ... */, {
trigger: {
type: 'eventSignal',
eventSignal: otherSignal$,
},
});Throttle โ
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
},
});Source Emitters โ
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',
});Actions (createMethod) โ
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() === 4Derived Signals (map) โ
Create a read-only derived signal:
const doubled$ = counter$.map(value => value * 2);
console.log(doubled$.get()); // counter$.get() * 2Promise API โ
toPromise() โ
Get a Promise that resolves on next value change:
const nextValue = await signal$.toPromise();Async Iteration โ
for await (const value of signal$) {
console.log('New value:', value);
if (value > 100) break;
}React Integration โ
Initialization โ
Call once at app startup:
import * as React from 'react';
import { EventSignal } from '@termi/eventemitterx/modules/EventEmitterEx/EventSignal';
EventSignal.initReact(React);use() โ React Hook โ
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>;
}useListener() โ React Effect Hook โ
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>;
}Direct JSX Rendering โ
EventSignal instances are valid React elements โ render directly in JSX:
const greeting$ = new EventSignal('Hello, World!');
function App() {
return <div>{greeting$}</div>;
}Component Type System โ
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);Lifecycle โ
destructor() / [Symbol.dispose]() โ
Destroy the signal. Cleans up subscriptions, resolves finale values, rejects pending promises.
signal$.destructor();
signal$.destroyed; // truedestroyed (getter) โ
Check if signal is destroyed.
getDispose() โ
Get the dispose function (useful for passing as a callback).
clearDeps() โ
Remove all dependency subscriptions without destroying the signal.
Properties โ
| 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 |
State Flags โ
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 |
Helper Function โ
isEventSignal(value, inThisRealm?) โ
Type guard to check if a value is an EventSignal instance.
if (isEventSignal(maybeSignal)) {
console.log(maybeSignal.get());
}Edge Cases โ
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.
๐บ๏ธ Roadmap โ Coming Soon โ
EventSignal is actively developed. Here are the planned improvements and new features on the horizon.
โ๏ธ Enhanced React Support โ
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:
tsx// 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.
๐ญ Signal Factory Helpers โ
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
);๐ฆ Standalone @termi/eventsignal Package โ
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.
โฑ๏ธ Advanced Throttle & Debounce โ
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")
๐พ External Sync API โ
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),
},
});And much moreโฆ โ
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