Automatic Dependency Tracking in Reactive Frameworks

Modern web frameworks like SolidJS, Vue 3, and Angular have moved away from manual dependency arrays toward fine-grained, automatic dependency tracking. This article explains the underlying architecture that makes this possible, focusing on how runtime execution stacks, property interceptors, and signal graphs collaborate to observe state access and manage subscriptions automatically.

The Limitation of Explicit Arrays

In systems relying on manual dependency declaration—most notably React’s useEffect and useMemo hooks—developers must explicitly provide an array of values that trigger a re-run. This approach shifts the burden of graph maintenance onto the developer, frequently introducing bugs such as stale closures, memory leaks, and redundant renders when dependencies are omitted or incorrectly ordered.

Modern reactive systems solve this by detecting dependencies at runtime during actual code execution.

The Global Execution Context

The foundation of automatic dependency tracking is a global tracking context, often called the "active effect" or "running observer."

Because JavaScript runs in a single-threaded environment, frameworks maintain a shared stack or a single reference variable (e.g., activeListener) in memory. When a reactive computation—such as an effect or a computed value—begins execution:

  1. The framework sets the global reference to point to that specific computation.
  2. The computation’s inner callback function executes synchronously.
  3. The framework clears the global reference (or pops it off the stack) once the function returns.

This context provides a window during which any state read operation knows exactly which computation requested it.

Intercepting State Access with Getters and Proxies

To capture reads and writes, reactive frameworks wrap raw values in accessors. They generally use one of two primitives:

  • JavaScript Proxy Objects: Frameworks like Vue wrap objects in a Proxy. The Proxy's get trap intercepts property accesses, while the set trap intercepts mutations.
  • Signal Getters/Accessors: Frameworks like SolidJS and Angular Signals wrap primitives in getter functions (e.g., count()) or objects with explicit .value getters.

When a computation runs, any reactive state accessed inside its body invokes a get trap or accessor function. Inside this getter, the framework checks if the global "active effect" pointer is currently set.

If an active effect exists, the state registers that effect inside its internal collection of subscribers (often a Set). Simultaneously, the effect records the state in its own collection of dependencies. This creates a bidirectional link between the producer of the state and its consumer.

Intercepting Mutations and Triggering Updates

When state changes via a setter function or a Proxy set trap:

  1. The reactive variable intercepts the new value.
  2. It compares the new value to the old value (typically using Object.is) to avoid unnecessary work.
  3. If the value has changed, the variable iterates over its internal subscriber collection.
  4. Each subscribed computation is either executed immediately or placed into a microtask queue to batch updates and prevent layout thrashing.

Because subscriptions are tied directly to functions rather than entire components, only the precise functions or DOM nodes dependent on that mutated value run again.

Handling Dynamic Dependencies and Conditional Logic

A common challenge in dependency tracking is dynamic branching, such as conditional statements:

createEffect(() => {
  if (showDetails()) {
    console.log(user.name());
  } else {
    console.log("Details hidden");
  }
});

If showDetails() evaluates to false, user.name() is never accessed. Therefore, the computation does not register a subscription to user.name().

To prevent outdated subscriptions when branch conditions change, frameworks clean up subscriptions on every execution. Before an effect re-runs, it severs its connections to all previously tracked variables. As the effect executes again, it re-registers only the dependencies encountered during that specific run. This guarantees that the dependency graph remains fully accurate and prevents memory leaks from unused state.

Conclusion

Automatic dependency tracking eliminates manual maintenance by turning state reads into subscription events and state writes into notifications. By combining synchronous execution contexts with getters or Proxies, modern frameworks maintain a live, accurate dependency graph that minimizes redundant computation and optimizes rendering performance.