npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@esmj/signals

v0.4.0

Published

Tiny reactive signals.

Readme

@esmj/signals

A tiny, fine-grained reactive signals library for JavaScript. Built as a lightweight wrapper around the TC39 Signals proposal, providing a ready-to-use API today that aligns with the future standard.

Installation

npm install @esmj/signals

Quick Start

import { state, computed, effect } from '@esmj/signals';

const count = state(0);
const doubled = computed(() => count.get() * 2);

effect(() => {
  console.log(`Count: ${count.get()}, Doubled: ${doubled.get()}`);
});
// logs: "Count: 0, Doubled: 0"

count.set(5);
// logs: "Count: 5, Doubled: 10"

Motivation

The TC39 Signals proposal aims to bring reactive primitives to the JavaScript language. This library provides a lightweight implementation of the same concepts so you can start using signals today with minimal overhead. When the proposal lands natively, migration should be straightforward.

API

state(value, options?)

Creates a reactive signal (also exported as createSignal).

import { state } from '@esmj/signals';

const name = state('Alice');

// Read the value
name.get(); // 'Alice'

// Write a new value
name.set('Bob');
name.get(); // 'Bob'

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | equals | (a, b) => boolean | Object.is | Custom equality function. Notifications are skipped when equals returns true. |

// Signal that always notifies on set, even with the same value
const counter = state(0, { equals: () => false });

// Signal with deep equality (e.g. using a library)
const data = state({ a: 1 }, { equals: deepEqual });

computed(callback, options?)

Creates a lazy, memoized derived signal. The callback is not executed until .get() is first called. Recomputation only occurs when a dependency changes.

import { state, computed } from '@esmj/signals';

const firstName = state('John');
const lastName = state('Doe');

const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);

fullName.get(); // 'John Doe'

firstName.set('Jane');
fullName.get(); // 'Jane Doe'

Chained computeds

Computed signals can depend on other computed signals:

const a = state(1);
const b = computed(() => a.get() * 2);
const c = computed(() => b.get() + 10);

c.get(); // 12

a.set(5);
c.get(); // 20

Options

Same as state options (equals).

effect(callback, options?)

Creates a side effect that automatically re-runs whenever its dependencies change. Returns a dispose function to stop the effect.

import { state, effect } from '@esmj/signals';

const count = state(0);

const dispose = effect(() => {
  console.log('Count is:', count.get());
});
// logs: "Count is: 0"

count.set(1);
// logs: "Count is: 1"

// Stop the effect
dispose();
count.set(2);
// (nothing logged)

Explicit Resource Management (using)

The dispose function supports Symbol.dispose, enabling automatic cleanup with the using keyword:

{
  using dispose = effect(() => {
    console.log('Count is:', count.get());
  });

  count.set(1);
  // effect is active
}
// ← effect automatically disposed when block exits

Cleanup / Destructor

If the effect callback returns a function, it will be called before each re-execution and on disposal:

const visible = state(true);

const dispose = effect(() => {
  if (visible.get()) {
    const handler = () => console.log('clicked');
    document.addEventListener('click', handler);

    // Cleanup: runs before next effect execution or on dispose
    return () => {
      document.removeEventListener('click', handler);
    };
  }
});

visible.set(false); // cleanup runs, listener removed
dispose();

batch(callback)

Batches multiple signal updates into a single notification. Computed signals and effects are only notified once after the batch completes, preventing intermediate (glitchy) states.

import { state, computed, batch } from '@esmj/signals';

const a = state(1);
const b = state(2);
let computeCount = 0;

const sum = computed(() => {
  computeCount++;
  return a.get() + b.get();
});

sum.get(); // 3, computeCount === 1

batch(() => {
  a.set(10);
  b.set(20);
  // No recomputation happens here
});

sum.get(); // 30, computeCount === 2 (only one recomputation!)

Nested batches

Inner batches do not flush until the outermost batch completes:

batch(() => {
  a.set(10);
  batch(() => {
    b.set(20);
    c.set(30);
  });
  // Still batched — nothing flushed yet
});
// Now all three updates are flushed at once

Efficient Updates (Pull-based Validation)

The library uses pull-based validation with revision tracking to avoid redundant recomputations in diamond dependency graphs:

      state A
      /     \
computed B  computed C
      \     /
     computed D

When A changes, both B and C are marked dirty, which also marks D dirty. However, when D.get() is called, it first validates its sources by pulling their current values. Each source is validated recursively before D decides whether to recompute. This means D recomputes exactly once, not twice.

import { state, computed } from '@esmj/signals';

const a = state(1);
const b = computed(() => a.get() * 2);
const c = computed(() => a.get() * 3);
const d = computed(() => b.get() + c.get());

d.get(); // 5

a.set(2);
d.get(); // 10 — d recomputed only once, not twice

Revision tracking

Every signal tracks a revision number that increments on each value change. This allows downstream computed signals to detect whether a source actually changed or if the dirty flag was a false alarm.

const s = state(1);
s.getRevision(); // 0

s.set(2);
s.getRevision(); // 1

// Same value — revision does not increment
s.set(2);
s.getRevision(); // 1

untrack(callback)

Executes a callback without tracking any signal dependencies. Useful inside effects or computed signals when you want to read a signal without subscribing to it.

import { state, computed, untrack } from '@esmj/signals';

const a = state(1);
const b = state(2);

const result = computed(() => {
  // `a` is tracked — changes to `a` will recompute
  const aVal = a.get();

  // `b` is NOT tracked — changes to `b` will NOT recompute
  const bVal = untrack(() => b.get());

  return aVal + bVal;
});

result.get(); // 3

b.set(100);
result.get(); // 3 (not recomputed because b is untracked)

a.set(10);
result.get(); // 110 (recomputed, picks up current b value)

signal.peek()

Reads the current value of a signal without subscribing to it. Available on both state and computed signals. A concise alternative to untrack(() => signal.get()).

import { state, computed } from '@esmj/signals';

const count = state(5);
count.peek(); // 5 — no tracking

const doubled = computed(() => count.get() * 2);
doubled.peek(); // 10 — no tracking

// Useful inside computed/effects to read without creating a dependency
const a = state(1);
const b = state(2);

const result = computed(() => {
  // a is tracked, b is not
  return a.get() + b.peek();
});

result.get(); // 3

b.set(100);
result.get(); // 3 (b is not tracked)

a.set(10);
result.get(); // 110 (recomputed, picks up current b)

watch(signal) / unwatch(signal) / getPending()

Low-level API for building custom scheduling. Used internally to manage effect execution.

import { computed, watch, unwatch, getPending } from '@esmj/signals';

const c = computed(() => /* ... */);

// Register a signal with the global watcher
watch(c);

// Get all signals with pending updates
const pending = getPending();
pending.forEach((p) => p.get());

// Unregister a signal
unwatch(c);

createWatcher(notify)

Creates a custom watcher with a custom notification strategy. Replaces the default watcher (which uses queueMicrotask).

import { createWatcher, getPending } from '@esmj/signals';

// Synchronous flush strategy
createWatcher(() => {
  for (const pending of getPending()) {
    pending.get();
  }
});

// Or requestAnimationFrame-based strategy for UI
createWatcher(() => {
  requestAnimationFrame(() => {
    for (const pending of getPending()) {
      pending.get();
    }
  });
});

onFlush(callback)

Registers a one-shot callback that runs once after the next flush cycle completes (i.e. after all pending effects have run). Useful for DOM measurements, post-update coordination, or any work that depends on effects being settled.

import { state, effect, onFlush } from '@esmj/signals';

const count = state(0);

effect(() => {
  document.title = `Count: ${count.get()}`;
});

count.set(42);

onFlush(() => {
  // DOM is now updated — safe to measure
  console.log(document.title); // "Count: 42"
});

Multiple callbacks are supported and run in registration order:

onFlush(() => console.log('first'));
onFlush(() => console.log('second'));
// After flush: "first", "second"

Callbacks are one-shot — they do not persist across flush cycles:

onFlush(() => console.log('once'));

count.set(1);
// after flush: logs "once"

count.set(2);
// after flush: (nothing — callback was cleared)

afterFlush()

Returns a promise that resolves after the next flush cycle completes. A convenience wrapper around onFlush. Especially useful in async code and tests:

import { state, effect, afterFlush } from '@esmj/signals';

const count = state(0);

effect(() => {
  console.log(count.get());
});

count.set(42);

await afterFlush();
// All effects have run, all side effects settled

Works seamlessly with batch:

import { state, effect, batch, afterFlush } from '@esmj/signals';

const a = state(1);
const b = state(2);
let sum = null;

effect(() => {
  sum = a.get() + b.get();
});

batch(() => {
  a.set(10);
  b.set(20);
});

await afterFlush();
console.log(sum); // 30

Flush Strategy

Effects are scheduled to run via queueMicrotask after signal updates. This means they run before the next paint but after the current synchronous code finishes:

const count = state(0);
let logged = null;

effect(() => {
  logged = count.get();
});
// logged === 0

count.set(1);
// logged === 0 (microtask hasn't run yet)

await afterFlush();
// logged === 1 (microtask ran)

Multiple set() calls are coalesced — the effect runs only once:

count.set(1);
count.set(2);
count.set(3);
await afterFlush();
// effect ran once with count === 3

Error Handling

Errors in computed callbacks are captured and re-thrown on .get() or .peek(). The error state is tracked separately from the value, so state signals can hold Error objects as legitimate values:

import { state, computed } from '@esmj/signals';

// State signals can store Error objects — they are values, not errors
const validationError = state(new Error('field required'));
validationError.get(); // Error { message: 'field required' } — returned, not thrown

// Computed signals throw when their callback throws
const a = state(0);
const safe = computed(() => {
  if (a.get() === 0) {
    throw new Error('Cannot be zero');
  }
  return 100 / a.get();
});

try {
  safe.get();
} catch (e) {
  console.log(e.message); // 'Cannot be zero'
}

// Recovers when dependency changes
a.set(5);
safe.get(); // 20

Errors propagate through computed chains:

const source = state(0);
const a = computed(() => {
  if (source.get() === 0) throw new Error('bad');
  return source.get() * 2;
});
const b = computed(() => a.get() + 10);

try {
  b.get(); // throws 'bad' — propagated from a
} catch (e) {}

source.set(5);
b.get(); // 20 — recovered

Cycle Detection

Circular dependencies between computed signals are detected and throw a clear error instead of causing a stack overflow:

import { computed } from '@esmj/signals';

const a = computed(() => b.get() + 1);
const b = computed(() => a.get() + 1);

try {
  a.get();
} catch (e) {
  console.log(e.message); // 'Cycle detected in computed signal'
}

This applies to any cycle length — self-referencing, two-node, three-node, etc. Diamond dependencies (where multiple paths lead to the same signal without a cycle) are handled correctly and do not trigger false positives.

TC39 Signals Proposal Alignment

This library follows the API shape and semantics of the TC39 Signals proposal:

| TC39 Proposal | @esmj/signals | Status | |---------------|---------------|--------| | Signal.State | state / createSignal | ✅ | | Signal.Computed | computed | ✅ | | Signal.subtle.Watcher | createWatcher / watch / unwatch | ✅ | | Signal.subtle.untrack | untrack | ✅ | | Signal.subtle.Watcher.prototype.getPending | getPending | ✅ | | Effect (userland in proposal) | effect | ✅ | | Batch (userland in proposal) | batch | ✅ |

Exports

| Export | Description | |--------|-------------| | state | Create a reactive signal (alias: createSignal) | | createSignal | Create a reactive signal | | computed | Create a derived/memoized signal | | effect | Create a reactive side effect | | batch | Batch multiple updates | | untrack | Read signals without tracking | | signal.peek() | Read signal value without tracking | | watch | Register a signal with the watcher | | unwatch | Unregister a signal from the watcher | | getPending | Get pending signals | | createWatcher | Create a custom watcher | | onFlush | Register a one-shot post-flush callback | | afterFlush | Returns a promise that resolves after flush | | setDebugHooks | Register lifecycle hooks for debug tooling | | RX_TYPE | Symbol identifying primitive type ('signal'|'computed'|'effect') | | RX_DEBUG_NAME | Symbol carrying the debug label |

Debug Tooling

@esmj/signals ships a separate, fully tree-shakeable debug module. Import it only in development — it has zero cost in production bundles that don't include it.

import { installDebug } from '@esmj/signals/debug';

installDebug();

Setup

Call installDebug() once at app startup, before creating the signals you want observed. Pass { log: false } to suppress console.debug output while still keeping the registry and DevTools formatter active.

import { installDebug } from '@esmj/signals/debug';

// Enable everything (logging on by default)
installDebug();

// Or silence auto-logging while keeping the formatter and window.__RX__
installDebug({ log: false });

debug option

Give any signal, computed, or effect a name with the debug option. Named primitives are auto-registered and auto-logged.

import { state, computed, effect } from '@esmj/signals';
import { installDebug } from '@esmj/signals/debug';

installDebug();

const count = state(0, { debug: 'count' });
// console: [signal:count] created

const doubled = computed(() => count.get() * 2, { debug: 'doubled' });
// console: [computed:doubled] created

const dispose = effect(() => {
  document.title = `Count: ${count.get()}`;
}, { debug: 'titleEffect' });
// console: [effect:titleEffect] created

count.set(5);
// console: [signal:count] 0 → 5
// console: [computed:doubled] recomputing

Chrome DevTools custom formatters

After calling installDebug(), enable "Enable custom formatters" in Chrome DevTools settings (DevTools → Settings → Preferences → Enable custom formatters).

Signals, computeds, and effects then render with coloured labels in the console instead of raw objects:

Signal[count]: 5          ← purple, bold
Computed[doubled]: 10     ← teal, bold
Effect[titleEffect]       ← amber, bold

Expanding a node in the console shows:

  • Signal: type, name, value, revision
  • Computed: type, name, value, revision, dirty, dependencies (recursive)
  • Effect: type, name, graph (full dependency tree of the underlying computed)

window.__RX__ global registry

installDebug() exposes a window.__RX__ object for live inspection from the browser console:

// Access the live signal by name
window.__RX__.signals.get('count');       // Signal object
window.__RX__.computeds.get('doubled');   // Computed object
window.__RX__.effects.get('titleEffect'); // Dispose function

// Print a named primitive (uses the custom formatter if enabled)
window.__RX__.inspect('count');

// Get all registered primitives
window.__RX__.getRegistry();
// { signals: Map, computeds: Map, effects: Map }

getDependencies(computed)

Returns a recursive dependency tree for the given computed signal. Useful for understanding the reactive graph at runtime.

import { state, computed } from '@esmj/signals';
import { installDebug, getDependencies } from '@esmj/signals/debug';

installDebug({ log: false });

const price = state(10, { debug: 'price' });
const qty   = state(3,  { debug: 'qty' });
const total = computed(() => price.get() * qty.get(), { debug: 'total' });
total.get();

console.log(getDependencies(total));
// {
//   name: 'total', type: 'computed', revision: 1, dirty: false,
//   dependencies: [
//     { name: 'price', type: 'signal', value: 10, revision: 0 },
//     { name: 'qty',   type: 'signal', value: 3,  revision: 0 },
//   ]
// }

getRegistry()

Returns the current registry snapshot without accessing window.

import { getRegistry } from '@esmj/signals/debug';

const { signals, computeds, effects } = getRegistry();

Debug exports summary

| Export (from @esmj/signals/debug) | Description | |-------------------------------------|-------------| | installDebug(options?) | Activate debug tooling (call once at startup) | | getDependencies(computed) | Recursive dependency tree for a computed signal | | getRegistry() | Returns { signals, computeds, effects } Maps |

License

MIT