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.