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.
Table of Contents
- 1. What Signals Really Are
- 2. The Core Building Block: a Minimal Signal
- 3. Effects: Automatic Dependency Tracking
- 4. Computed Signals: Derived Values With Memoization
- 5. Batching: Bundling Multiple Updates Into One Cycle
- 6. Cleanup and Memory Management
- 7. Integrating Signals Into Existing Frameworks
- 8. Signals Versus Other Reactivity Models
- 9. Signals Compared
- 10. Summary
- 11. FAQ
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.