How JavaScript Reactive Primitives Track Dependencies
Modern JavaScript frameworks and libraries utilize reactive primitives—commonly referred to as signals—to automatically synchronize state with the user interface and computed computations. This article explains the internal architecture behind automatic dependency tracking, breaking down how a global execution context, getter interception, setter notifications, and dynamic cleanup allow JavaScript runtimes to map relationships between state and effects without explicit manual subscriptions.
The Foundation: A Shared Tracking Context
Automatic dependency tracking relies on a global variable or
execution stack often called activeEffect or
currentListener. Because JavaScript executes synchronously
within a single thread, the system can determine precisely which
computation is running at any given moment.
When a reactive effect or computation starts: 1. The framework sets
activeEffect to reference the currently running function.
2. The function executes. 3. Once execution finishes,
activeEffect is reset to null (or popped from
the stack if effects are nested).
let activeEffect = null;
function createEffect(fn) {
const effect = () => {
activeEffect = effect;
try {
fn();
} finally {
activeEffect = null;
}
};
effect();
}Reading State: Interception via Getters
Reactive primitives encapsulate raw values behind getter and setter interfaces. When code reads a reactive variable inside an active effect, the getter executes and checks the global context.
If activeEffect is present, the reactive primitive adds
that function to its internal collection of subscribers (typically
stored in a Set to prevent duplicate entries).
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
if (activeEffect) {
subscribers.add(activeEffect);
}
return value;
}
function write(newValue) {
if (newValue !== value) {
value = newValue;
// Notify phase
for (const subscriber of [...subscribers]) {
subscriber();
}
}
}
return [read, write];
}Writing State: Notification and Propagation
When a signal’s value changes via its setter, it iterates through its
internal subscribers set. Each registered effect function
is executed directly or added to a batching queue to run on the next
microtask.
Because each primitive only retains references to the specific computations that read its getter, only the necessary downstream computations run. This provides fine-grained performance without requiring a top-down virtual DOM diffing process.
Dynamic Dependencies and Cleanup
Applications often contain conditional logic where dependencies change during runtime:
const [toggle, setToggle] = createSignal(true);
const [name, setName] = createSignal("Alice");
createEffect(() => {
if (toggle()) {
console.log(name());
} else {
console.log("Hidden");
}
});In this scenario, if toggle becomes false,
the effect no longer depends on name. If name
updates later, the effect should not execute.
To solve this, modern reactive engines manage a bidirectional link between effects and signals: * Before an effect re-runs: The effect removes itself from the subscriber sets of all signals it previously tracked. * During execution: The effect re-registers itself only to the signals accessed during that specific run.
Alternatively, some systems use epoch-based versioning or linked lists to mark dependencies as stale and prune unused associations without full cleanup overhead.
Summary
Automatic dependency tracking operates on four simple steps: 1. An effect sets itself as the active global context. 2. Signal getters read during effect execution register the active context as a subscriber. 3. Signal setters notify all registered subscribers when values change. 4. Dependency sets are pruned and rebuilt on subsequent runs to support dynamic logic.