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:
- The framework sets the global reference to point to that specific computation.
- The computation’s inner callback function executes synchronously.
- 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
ProxyObjects: Frameworks like Vue wrap objects in a Proxy. The Proxy'sgettrap intercepts property accesses, while thesettrap intercepts mutations. - Signal Getters/Accessors: Frameworks like SolidJS
and Angular Signals wrap primitives in getter functions (e.g.,
count()) or objects with explicit.valuegetters.
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:
- The reactive variable intercepts the new value.
- It compares the new value to the old value (typically using
Object.is) to avoid unnecessary work. - If the value has changed, the variable iterates over its internal subscriber collection.
- 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.