Building Your Own Signals Library: Reactivity Without Framework Lock-in
AI generated
JS
() =>
JavaScript · Signals · Reactivity · Framework agnostic
Building Your Own Signals Library
Reactivity Without Framework Lock-in

Signals are a reactivity pattern that Vue, Solid, Preact and Angular implemented independently of each other, yet all rely on the same few building blocks. Anyone who understands how a minimal signals system built from getters, setters and automatic dependency tracking works can use reactivity in any vanilla JavaScript project, with no framework dependency at all.

19 min read Dependency Tracking · Computed · Batching · Cleanup Framework independent

1. What Signals Really Are

Signals are reactive value containers that automatically update all dependent computations and effects when they change, without a developer having to manually propagate changes. Unlike virtual DOM diffing, which re-renders entire component trees and then compares them, signals update only the exact spots that actually depend on a changed value, a principle often called fine-grained reactivity.

Vue, Solid, Preact and Angular have each implemented signals independently, with slightly different APIs but the same core mechanism: a getter reads the current value and implicitly registers the calling context as a dependency, a setter writes a new value and notifies every registered dependent. Anyone who has implemented this pattern from scratch once understands the reactivity systems of all the frameworks mentioned structurally, regardless of the specific syntax.

This article builds its own minimal signals library from scratch, with no framework dependency, in plain JavaScript. Every section adds one building block: first the signal itself, then automatic dependency tracking through effects, then derived computed values, batching for consistent updates, and finally cleanup strategies against memory leaks.

2. The Core Building Block: a Minimal Signal

The simplest building block of a signals system is a closure that encapsulates a value and a set of subscribers. The getter returns the current value, the setter updates the value and notifies every registered subscriber. Crucially, when reading the value, the system checks whether an active tracking context currently exists, typically through a global variable set while an effect is running.

This global tracking variable is the trick that distinguishes signals from simple getter/setter pairs: instead of an effect explicitly declaring which signals it observes, the signal registers itself with the active effect as soon as it is read. This implicit registration is why signals-based reactivity gets by without manual dependency arrays, a clear difference from useEffect in React, which requires an explicit dependency list.


// Minimal signal implementation with implicit dependency registration
let activeSubscriber = null;

function createSignal(initialValue) {
  let value = initialValue;
  const subscribers = new Set();

  function read() {
    // Implicitly register the currently running effect as a dependent
    if (activeSubscriber) {
      subscribers.add(activeSubscriber);
    }
    return value;
  }

  function write(newValue) {
    if (Object.is(value, newValue)) return; // skip no-op updates
    value = newValue;
    for (const subscriber of subscribers) {
      subscriber();
    }
  }

  return [read, write];
}

const [count, setCount] = createSignal(0);
console.log(count()); // 0
setCount(5);
console.log(count()); // 5

3. Effects: Automatic Dependency Tracking

An effect is a function that, when running, sets the global tracking variable to itself, executes itself, and thereby automatically registers with every signal it reads. After execution, the tracking variable is reset. If one of the signals read later changes, its setter re-invokes the effect, causing the dependency list to be rebuilt on every run, a pattern that correctly captures dynamic dependencies, for example when an effect conditionally reads signal A in one branch and signal B in another.

This dynamic re-registration on every run is a significant advantage over static dependency arrays: a signals effect never needs manual maintenance when the values it reads change depending on the code path. The failure mode of a forgotten dependency, a well-known problem with useEffect in React, structurally does not exist with signals, because dependencies are derived at runtime from actual read behavior.


// Effect with automatic re-tracking on every run
function createEffect(fn) {
  function execute() {
    activeSubscriber = execute;
    try {
      fn();
    } finally {
      activeSubscriber = null;
    }
  }
  execute(); // run once immediately to establish initial dependencies
}

const [firstName, setFirstName] = createSignal('Ada');
const [lastName, setLastName] = createSignal('Lovelace');

createEffect(() => {
  console.log(`Hello, ${firstName()} ${lastName()}`);
});

setFirstName('Grace'); // triggers the effect automatically

4. Computed Signals: Derived Values With Memoization

A computed signal is a derived value calculated from other signals that only recomputes when one of its actual dependencies changes. The implementation combines the two previous building blocks: a computed signal is internally a signal itself, whose value is kept up to date through an internal effect. If another effect reads the computed signal, it registers as a subscriber normally, exactly like with a plain signal.

The memoization effect arises because the internal computation only runs on an actual dependency change, not on every read access. That distinguishes computed signals from a simple getter function, which recomputes on every call regardless of whether anything changed. For expensive computations, such as filtered and sorted lists with thousands of entries, this difference makes the decisive performance advantage.


// Computed signal: memoized derived value
function createComputed(computeFn) {
  const [value, setValue] = createSignal(undefined);
  createEffect(() => {
    setValue(computeFn()); // only re-runs when a real dependency changes
  });
  return value;
}

const [price, setPrice] = createSignal(100);
const [quantity, setQuantity] = createSignal(2);

const total = createComputed(() => price() * quantity());

createEffect(() => {
  console.log(`Total: ${total()}`);
});

setQuantity(3); // recomputes total, logs "Total: 300"

5. Batching: Bundling Multiple Updates Into One Cycle

Without batching, every setter call immediately triggers all dependent effects, which leads to redundant intermediate states when several changes happen in a row. If a code block sets three signals one after another without batching, dependent effects potentially run three times with inconsistent intermediate values. The pattern batch(() => { ... }) collects all updates inside the function and only fires notification after it fully completes.

Implementing batching extends the setter with a check for whether a batch is currently active. If so, the affected subscriber is collected into a set instead of running immediately, and only at the end of the batch are all collected subscribers invoked once. This pattern is standard in signals implementations such as Solid or Preact Signals, and it prevents users from seeing inconsistent intermediate states, such as a cart price briefly displayed with a stale quantity.


// Batching multiple signal updates into a single notification cycle
let batchDepth = 0;
let pendingSubscribers = new Set();

function batch(fn) {
  batchDepth += 1;
  try {
    fn();
  } finally {
    batchDepth -= 1;
    if (batchDepth === 0) {
      const toRun = pendingSubscribers;
      pendingSubscribers = new Set();
      for (const subscriber of toRun) subscriber();
    }
  }
}

// setSignalValue would check batchDepth and defer notification:
// if (batchDepth > 0) { subscribers.forEach(s => pendingSubscribers.add(s)); }
// else { subscribers.forEach(s => s()); }

batch(() => {
  setFirstName('Alan');
  setLastName('Turing');
  // effect runs only once, after the batch completes
});

6. Cleanup and Memory Management

A frequently overlooked problem with hand-built signals systems is that subscriber sets grow without bound when effects are never properly unsubscribed. An effect that gets destroyed, for example because its associated UI component was removed from the DOM, must remove itself from every subscriber set of every signal it ever read, otherwise the reference keeps the entire effect closure artificially alive.

The robust solution: every effect keeps track of which signals it registered with on each run, and calls a cleanup function before each subsequent run as well as on final disposal, one that removes it from all previously registered subscriber sets. Frameworks like Solid couple this cleanup automatically to the component lifecycle, whereas a framework-independent signals library must hand this responsibility back to the calling application explicitly, for example through a dispose() function called when a component is removed.


// Effect with explicit dispose to avoid subscriber leaks
function createDisposableEffect(fn) {
  let dependencies = [];

  function execute() {
    cleanup();
    activeSubscriber = execute;
    try {
      fn();
    } finally {
      activeSubscriber = null;
    }
  }

  function cleanup() {
    for (const subs of dependencies) subs.delete(execute);
    dependencies = [];
  }

  execute();
  return () => cleanup(); // dispose function, call on teardown
}

const disposeEffect = createDisposableEffect(() => {
  console.log(`Total: ${total()}`);
});

// Later, when the component unmounts:
disposeEffect();

7. Integrating Signals Into Existing Frameworks

A framework-independent signals library can be integrated into existing applications by hooking into the framework's own render cycle. In a React application, a custom hook reads the signal and subscribes via useSyncExternalStore, so React automatically re-renders on changes without React ever having to leave its own reactivity model. In Vue, an external signal can be hooked into the reactive system through customRef, so templates treat it like a native ref.

With Web Components, integration is most direct: a createEffect call in connectedCallback updates the DOM directly, and the associated dispose() is called in disconnectedCallback. This compatibility with different rendering models is the actual value of a hand-built signals library: it remains a pure state-management layer, regardless of which framework handles rendering.

8. Signals Versus Other Reactivity Models

Signals do not exist in isolation but compete with older reactivity models such as RxJS observables, Redux-style reducers and proxy-based reactivity, as used by Vue 3 in its reactivity core. RxJS observables are more powerful for complex asynchronous data streams with operators like debounceTime or switchMap, but come with a noticeably steeper learning curve than the simple getter/setter model of signals.

Redux-style state management with a central store and explicit reducers works well for predictable, time-travelable state changes in large applications, but requires considerably more boilerplate than signals for simple, local reactive values. Proxy-based reactivity, as used internally by Vue, achieves fine-grained updates similar to signals, but hides tracking behind transparent object proxies instead of explicit getter calls, a tradeoff between ergonomics and explicitness.

9. Signals Compared

The choice between signals and alternative state-management approaches depends heavily on the use case: simple, local UI state benefits from the low complexity of signals, while complex asynchronous data streams or time-travelable global state call for different tools.

Model Update Granularity Learning Curve Best Fit
Signals Fine-grained, exact Low Local reactive UI state
RxJS Observables Stream based High Complex asynchronous data streams
Redux-Style Store Coarse, component level Medium Large, time-travelable global state
Proxy Reactivity (Vue) Fine-grained, implicit Low Framework-integrated reactivity
Virtual DOM Diffing Component-wide Low Simple, less performance-critical UIs

In practice, a hand-built signals library brings the most value when an application needs to work across frameworks, for example as a shared state-management layer between a React and a Web-Components-based interface. The core principles stay identical across all implementations: getters register, setters notify, computed values memoize, batching keeps things consistent.

Mironsoft

State-management architecture and cross-framework reactivity

Reactivity that isn't tied to a single framework?

We build and integrate framework-independent signals systems with dependency tracking, computed values and batching, connected to React, Vue or native Web Components.

State audit

Analysis of existing reactivity patterns for redundancies and unnecessary re-renders

Signals library

Building your own tested signals implementation for your stack

Framework bridge

Connecting to React, Vue or Web Components without duplicated state

10. Summary

A hand-built signals library consists of a few core building blocks at heart: a getter/setter pair with a subscriber set, a global tracking variable for implicit dependency registration, effects that re-register on every run, and computed values that combine these building blocks for memoized, derived values. Batching prevents inconsistent intermediate states across multiple simultaneous updates, and explicit cleanup prevents memory leaks from subscribers that never unsubscribe.

The value of this understanding lies not just in building your own library, but in seeing through existing signals implementations in Vue, Solid, Preact and Angular, all of which rest on the same core principles. Anyone who has implemented this mechanism from scratch once no longer sees framework reactivity as a black box, but as a traceable interplay of getter tracking, subscriber notification and memoization.

Building Your Own Signals Library — Key Takeaways

Core Building Block

Getter implicitly registers the active effect, setter notifies every registered subscriber.

Computed & Batching

Computed signals memoize derived values, batch() bundles multiple updates into one cycle.

Cleanup

Effects must explicitly remove themselves from subscriber sets, otherwise memory leaks occur.

Integration

Usable via useSyncExternalStore (React), customRef (Vue) or directly inside Web Components.

11. FAQ: Building Your Own Signals Library

1Signals vs. regular variables?
Signals register readers and notify on change, regular variables do not.
2How does dependency tracking work?
A global variable holds the active effect, signals register it automatically when read.
3What is a computed signal?
A memoized derived value that only recomputes on a real dependency change.
4What is batching used for?
Bundles multiple updates into one notification, prevents inconsistent intermediate states.
5Avoiding memory leaks?
Explicit dispose that removes effects from all subscriber sets.
6Using signals in React?
Yes, via useSyncExternalStore in a custom hook.
7Vue vs. Solid signals?
Same core principle, different API and integration into the respective framework.
8When to use RxJS instead?
For complex asynchronous streams with operators like debounceTime or switchMap.
9Why doesn't my effect react?
Usually the signal is read in a code path not executed on the last run.
10Signals more performant than virtual DOM?
Yes for targeted updates, since no comparison step over the whole tree is needed.