Zero-dependency, glitch-free reactive signals for TypeScript and JavaScript. Built with a highly optimized Push/Pull architecture (inspired by Angular) that guarantees zero wasted computations.
Perfectly suited for both Browser and Node.js environments. Use it to drive UI frameworks, build reactive CLI tools, or manage complex server-side state machines.
Package: https://www.npmjs.com/package/@demchenko.di/signals
Demo: https://dmytrodemchenko.github.io/Signals/demo/
npm install @demchenko.di/signals- Glitch-free Push/Pull engine: Guarantees effects only run when values actually change.
- Node.js Ready: Extremely lightweight, fast, and completely decoupled from the DOM.
- Small core API:
signal,computed,effect,batch,untracked - Extra primitives:
linkedSignal,resource,optimistic - Dual build: Unminified for development, minified for production (
@demchenko.di/signals/min). - No runtime dependencies
- Typed public API with generated
.d.tsfiles
Performance Highlights:
- ✅ 1.5x faster than RxJS on diamond dependency graphs (glitch-free)
- ✅ 1.4x faster than RxJS for NestJS WebSocket simulation (1000 concurrent effects)
- ✅ Faster than @preact/signals-core on heavy graph updates
Measured on Node.js v24.15.0 · Apple Silicon · April 2026
Source:
benchmarks/— runcd benchmarks && npm startto reproduce.
| Library | ops/sec |
|---|---|
RxJS BehaviorSubject + getValue() |
16,662,329 |
@demchenko.di/signals signal() + set() |
4,715,875 |
| Library | ops/sec |
|---|---|
RxJS BehaviorSubject + combineLatest |
871,323 |
@demchenko.di/signals signal + computed |
1,228,103 ✅ 1.4× faster |
The main benchmark: create a diamond-shaped dependency graph (a → b, c → d) and batch 100 updates through it.
| Library | ops/sec | vs us |
|---|---|---|
@preact/signals-core |
1,677,734 | 1.26× slower |
alien-signals |
2,503,989 | 1.19× faster |
| @demchenko.di/signals | 2,105,284 | — |
Simulates 1000 concurrent WebSocket connections reacting to a single signal update — a realistic server-side workload.
| Library | ops/sec |
|---|---|
| RxJS (1000 Subscriptions) | 39,527 |
| @demchenko.di/signals (1000 Effects) | 47,944 ✅ 1.2× faster |
The engine uses a doubly-linked list of Link nodes for dependency tracking instead of Set/Map. Each Link exists in two lists simultaneously (the producer's subscriber list and the consumer's dependency list), enabling O(1) subscribe/unsubscribe with near-zero GC pressure. During re-evaluation, existing link nodes are reused when the dependency graph is stable — making steady-state updates allocation-free.
import { signal, computed, effect, batch } from "@demchenko.di/signals";
const count = signal(0);
const doubled = computed(() => count() * 2);
const readonlyCount = count.asReadonly();
const stop = effect(() => {
console.log("count:", readonlyCount(), "doubled:", doubled());
});
batch(() => {
count.set(1);
count.update((value) => value + 1);
});
stop();import { signal } from "@demchenko.di/signals";
const user = signal({ id: 1, name: "Ada" }, {
equal: (a, b) => a.id === b.id,
});
user.set({ id: 1, name: "Ada Lovelace" }); // skipped
user.set({ id: 2, name: "Grace" }); // notifiesimport { signal, linkedSignal } from "@demchenko.di/signals";
const items = signal(["a", "b", "c"]);
const selection = linkedSignal<string[], string>({
source: () => items(),
computation: (nextItems, previous) => {
if (previous && nextItems.includes(previous.value)) {
return previous.value;
}
return nextItems[0];
},
});import { signal, resource } from "@demchenko.di/signals";
const userId = signal(1);
const user = resource({
request: () => userId(),
loader: async ({ request, abortSignal }) => {
const response = await fetch(`https://example.com/users/${request}`, {
signal: abortSignal,
});
return response.json();
},
});
effect(() => {
if (user.isLoading() && !user.hasValue()) {
console.log("Loading initial user...");
return;
}
if (user.isRefreshing()) {
console.log("Refreshing user while keeping stale data visible");
}
});import { optimistic, signal } from "@demchenko.di/signals";
const serverLikes = signal(10);
const optimisticLikes = optimistic(serverLikes);
const tx = optimisticLikes.apply((value) => value + 1);
try {
await api.like();
tx.commit((value) => value + 1);
} catch {
tx.rollback();
}Use optimistic() for async writes that should feel immediate in the UI without mutating committed base state too early. It is especially useful for reactions, toggles, reordering, inline edits, and other mutation-heavy flows where rollback matters.
hasPending() and pendingCount() are regular read signals, so they can be used directly inside computed() and effect() for UI state:
import { computed, optimistic, signal } from "@demchenko.di/signals";
const serverLikes = signal(10);
const optimisticLikes = optimistic(serverLikes);
const canLike = computed(() => !optimisticLikes.hasPending());
const pendingLabel = computed(() =>
optimisticLikes.hasPending()
? `Saving (${optimisticLikes.pendingCount()})...`
: "Like",
);import { signal } from "@demchenko.di/signals";
const count = signal(0);
const readonlyCount = count.asReadonly();
readonlyCount(); // 0
count.set(1);
readonlyCount(); // 1While signals are typically associated with frontend frameworks, they are incredibly powerful for backend state management, particularly in Node.js and frameworks like NestJS.
In a backend context, you often need to manage state at a specific point in time (e.g., "What is the current game score?", "Is maintenance mode active right now?"). While RxJS BehaviorSubject chains can handle this, they can become complex and prone to memory leaks if not unsubscribed carefully. Signals offer a much simpler, synchronous, and glitch-free alternative.
If you are building a WebSocket gateway in NestJS, you need to manage complex, rapidly changing room or game state. Signals ensure that derived state is calculated efficiently and side-effects (like broadcasting to players) only happen when the underlying data actually changes.
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { signal, computed, effect } from '@demchenko.di/signals';
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
@Injectable()
@WebSocketGateway()
export class GameRoomService implements OnModuleDestroy {
@WebSocketServer() server: Server;
private players = signal<Record<string, { name: string; score: number }>>({});
private timeRemaining = signal(60);
public isGameOver = computed(() => this.timeRemaining() <= 0);
public leader = computed(() => {
const p = this.players();
return Object.values(p).sort((a, b) => b.score - a.score)[0]?.name;
});
private stopEffect: () => void;
private timer: NodeJS.Timeout;
constructor() {
this.stopEffect = effect(() => {
if (this.isGameOver()) {
this.server.emit('game_over', {
winner: this.leader(),
finalScores: this.players()
});
}
});
this.timer = setInterval(() => {
this.timeRemaining.update(time => Math.max(0, time - 1));
}, 1000);
}
onModuleDestroy() {
this.stopEffect();
clearInterval(this.timer);
}
addScore(playerId: string, points: number) {
this.players.update(p => ({
...p,
[playerId]: {
...p[playerId],
score: (p[playerId]?.score || 0) + points
}
}));
}
}Instead of writing complex polling loops, you can combine signals with MongoDB Change Streams. When a document updates in the database, the signal updates, dependent computations re-run lazily, and side-effects trigger automatically. Other NestJS services simply read the computed signals synchronously and always get the freshest value.
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { signal, computed, effect } from '@demchenko.di/signals';
import { Config, ConfigDocument } from './config.schema';
@Injectable()
export class ConfigService implements OnModuleDestroy {
private readonly logger = new Logger(ConfigService.name);
private rawConfig = signal({ maintenanceMode: false, rateLimit: 100 });
public isMaintenanceMode = computed(() => this.rawConfig().maintenanceMode);
public currentRateLimit = computed(() => this.rawConfig().rateLimit);
private stopEffect: () => void;
constructor(
@InjectModel(Config.name) private configModel: Model<ConfigDocument>
) {
this.stopEffect = effect(() => {
if (this.isMaintenanceMode()) {
this.logger.warn('⚠️ SYSTEM ENTERED MAINTENANCE MODE ⚠️');
} else {
this.logger.log('✅ System operating normally.');
}
});
this.watchDatabaseChanges();
}
onModuleDestroy() {
this.stopEffect();
}
private async watchDatabaseChanges() {
const initialConfig = await this.configModel.findOne().lean();
if (initialConfig) {
this.rawConfig.set({
maintenanceMode: initialConfig.maintenanceMode,
rateLimit: initialConfig.rateLimit
});
}
this.configModel.watch().on('change', async (change) => {
if (change.operationType === 'update' || change.operationType === 'replace') {
const updatedConfig = await this.configModel.findOne().lean();
if (updatedConfig) {
this.rawConfig.set({
maintenanceMode: updatedConfig.maintenanceMode,
rateLimit: updatedConfig.rateLimit
});
}
}
});
}
}You can use signals to memoize expensive operations across HTTP requests in standard Node.js applications.
import express from 'express';
import { signal, computed } from '@demchenko.di/signals';
const app = express();
const databaseRecords = signal([{ id: 1, value: 100 }, { id: 2, value: 250 }]);
const expensiveTotal = computed(() => {
console.log("Running expensive calculation...");
return databaseRecords().reduce((sum, record) => sum + record.value, 0);
});
app.get('/api/total', (req, res) => {
res.json({ total: expensiveTotal() });
});The package exports:
signalcomputedeffectEffectOptionsbatchuntrackedisSignallinkedSignaloptimisticresource
Important: batch() is strictly synchronous. It works by temporarily pausing effect execution and flushing them once the provided function completes.
You should never use await inside a batch() block.
❌ Incorrect:
batch(async () => {
state.set('loading');
await fetch('/api/data'); // ⚠️ The batch ends immediately here!
state.set('success'); // This executes outside the batch.
});✅ Correct:
state.set('loading');
await fetch('/api/data');
// Batch only the synchronous mutations
batch(() => {
state.set('success');
data.set(newData);
});Note: The library will now throw a console.warn if you accidentally return a Promise from inside a batch().
optimistic(source)creates a projected signal layered on top of a writable base signal.apply(patch)adds a pending optimistic layer and returns a transaction withcommit()androllback().hasPending()istruewhile one or more optimistic layers are active.pendingCount()is the number of active optimistic layers.clear()removes all optimistic layers.commit(nextBase?)removes the layer and optionally writes a final value or updater into the base signal.rollback()removes the layer without touching the base signal.
Because hasPending() and pendingCount() are read signals, they compose naturally with computed() for disabled buttons, loading labels, and mutation-aware UI state.
Optimistic layers always rebase on top of the latest base signal value. If the server updates the underlying state while a mutation is pending, the optimistic projection recalculates from the new base value automatically.
hasValue()istrueafter the resource has produced or been assigned a value, even if that value isundefined.isLoading()istruewhenever a request is in flight.isRefreshing()istruewhen a request is in flight and the resource is still holding a previous value.
import { effect } from "@demchenko.di/signals";
const stop = effect(() => {
// ...
}, {
allowSignalWrites: true,
manualCleanup: true,
scheduler: (run) => queueMicrotask(run),
});allowSignalWritesdefaults totrue. Set it tofalseto throw on signal writes from the effect or its cleanup.manualCleanupis accepted for compatibility with owner-scoped effect APIs. In this library, effects are always disposed manually via the function returned fromeffect().schedulercustomizes reruns after invalidation. The first effect run still happens synchronously.
npm install
npm run check
npm run build