Fine-Grained Reactivity: Enthusiasm, Technical Context, and the Pitfalls of Signals in Angular
- Angular
- Signals
- Architecture
When you change a single field in a complex form, why does the framework have to check the entire component tree to detect what changed? Because that's how it was designed by default. Whether we're talking about the Reactivity system (Vue), Reconciliation (React), or Change Detection (Angular), front-end technologies have all tried to answer the same fundamental question: how do we know when state has changed and which part of the UI needs to be updated? The traditional answer has been conservative — when you're not sure what changed, you check everything. That works, but it costs.
Why is that? Why aren't they performant by default? Because at the time they were created, alternatives either didn't exist or weren't worth the tradeoff. For example, React chose the Virtual DOM for declarative simplicity, while Angular chose Zone.js for Automatic Change Detection. Both prioritized developer experience over performance — a reasonable tradeoff in the past, but one that becomes problematic today.
The modern solution may be signals.
Just as AJAX replaced full-page reloads with partial updates, fine-grained reactivity replaces component-level re-rendering with surgical updates at the DOM node level.

What Are Signals?
This model is conceptual, meant to explain the fundamentals of the mechanism. There isn't (yet) a universally standardized form of signals in JavaScript. TC39, the committee that decides what goes into upcoming versions of JavaScript, has a proposal on the table, but each framework (Angular, Solid, Vue, etc.) already implements its own mechanism, with significant differences in API and behavior.
At their core, signals are simple but powerful. At a rudimentary technical level, a signal uses various techniques (such as closures) to store two things: the value and the list of consumers. When a consumer (an effect or a template) reads the signal, the signal automatically registers it in its list of consumers. When the value changes, the signal notifies all the consumers in that list to re-run.

As mentioned before, each front-end technology has tried to implement its own vision and has taken its own approach to signals, meant to solve state-change problems (and not only those) in web applications.
To dig a bit deeper into the concept, I'll focus from here on Angular specifically — Angular Signals — and walk through a few concepts that, while based on the conceptual model above, take things a step further.
Reactive Dependencies in Angular
Starting with Angular v16, the framework developed by Google has taken the topic of Signals very seriously, and with each release shows growing interest in developing Angular Signals, mainly due to the performance improvements involved. The framework removes the dependency on Zone.js (smaller bundle size), and Change Detection becomes more efficient through granular tracking. Together with other concepts introduced starting with Angular v16, we can say the framework is going through a new stage of maturity (the third one, if we count AngularJS and Angular v2+).
Although the Angular team seems to be positioning signals everywhere as a perfect solution for any context, the reality is more nuanced: applications can still have architectural problems, reactive context leaks can introduce subtle bugs, and the complexity of certain asynchronous operations still requires RxJS.
So what is Reactive Context (so we can go into detail about how these "reactive context leaks" mentioned above happen)? Angular Signals use a global variable called activeConsumer, which Angular sets automatically whenever a consumer (template, computed signal, or effect) runs.
For example, when a computed is read by a template, Angular sets that particular computed as the activeConsumer — this is the "reactive context" we're talking about, meaning activeConsumer is not null. The signal(s) inside that callback will check whether there's something in activeConsumer, and if it's not null, they'll add that computed to their own list of consumers — and that's how the link between the consumer (computed) and the producer (signal) described above at the conceptual level gets created.
This whole system, through which Angular sets the global activeConsumer variable whenever a consumer runs, is called Dynamic Dependency Tracking. The major advantage is that we don't need to manually manage dependencies when using signals — but as mentioned above, there are also downsides to using Angular Signals, or rather, aspects that require extra attention.
Let's say we have an Angular component that needs to save a list of numbers and an index to localStorage every time the list in the component changes. Both the list and the index are signals.
Such a component needs an effect that saves that data to localStorage, as in the implementation below:
export class LocalStorageActionsPanel {
someIndex: WritableSignal<number> = signal(0);
someDataIds: WritableSignal<number[]>
= signal([1]);
constructor() {
effect((): void => {
const dataIds: number[] = this.someDataIds();
const index: number = this.someIndex();
this.setDataToLocalStorage(dataIds, index);
});
}
private setDataToLocalStorage(
someDataIds: number[],
someIndex: number
): void {
// implementation here...
}
// some other related methods...
}In the implementation above, any change to either someIndex or someDataIds will trigger the effect again and send the data to Local Storage.
Why does this happen? Because on the first run of the effect, Angular internally sets that effect as the activeConsumer.
When someIndex() or someDataIds() are read inside the effect, each signal detects that there's an activeConsumer — namely the effect — and registers it in its own list of consumers. So both signals become reactive dependencies of the effect. And on any subsequent change to their values, they notify the effect to run again.
In the case of someIndex, this created a reactive context leak, since it shouldn't have triggered the effect's execution. This is a simple case with minor impact, and the solution is to use the untracked() function. A signal read inside untracked() won't pick up the activeConsumer and add itself to its list of consumers, so the signal won't become a reactive dependency of the effect.
Although it may seem like the problem is solved, the method used to send data to storage might itself use signals, or call other methods that do — and that implicitly turns reading those signals into reactive dependencies as well, which again means a reactive context leak.
effect((): void => {
const dataIds: number[] = this.someDataIds();
const index: number =
untracked((): number => this.someIndex());
this.setDataToLocalStorage(dataIds, index);
});Using our imagination, we can guess how many problems we could create if, instead of one index, we had 10, and instead of one method being called, we had 3, each of which in turn used 3-4 signals we're not even aware of. All those signals — or even computed values (since a computed can be both a consumer and a producer) — would become reactive dependencies of the effect and could trigger dozens, maybe even hundreds, of unnecessary effect executions. This would hurt application performance, generate very complex bugs, create logical unpredictability (since it would be hard to know when the effect actually runs), and even cause memory leaks in extreme cases (very complex applications).
This is also why it's not recommended to overuse the effect function in an application — it can be very problematic if it's not built correctly and if you don't make sure it doesn't generate reactive context leaks.
To make sure the effect only runs when the list changes, we'll also wrap the method call in untracked(), and the effect will look like this:
effect((): void => {
const dataIds: number[] = this.someDataIds();
untracked((): void => {
const index: number = this.someIndex();
this.setDataToLocalStorage(dataIds, index);
});
});To summarize: reading a signal inside a consumer becomes a reactive dependency, as long as that signal isn't read inside the untracked() function.
Conclusions
Without a doubt, Signals have managed to improve our field and push the idea and concept of fine-grained reactivity to the next level, but, as is normal, they come with a set of precautions that we need to study and keep in mind when using signals.
From my point of view, at the Angular level, we've gone from using AsyncPipe, trackBy, and various forms of manual unsubscribing for observables, to analyzing the reactive context of every part of the code that contains signals, and making sure we don't throw all the changes that came with Angular 16+ into logical chaos. It remains to be seen what the future holds regarding the framework's new signal-based vision…
Sources
- Signals in JavaScript: A (Soon) Standard, or Overhyped?
- The Evolution of Signals in JavaScript
- Building a Reactive Library from Scratch
- Angular Signals, Reactive Context, and Dynamic Dependency Tracking
Originally published in Today Software Magazine, issue 163: Fine-Grained Reactivity: Entuziasm, context tehnic și capcanele Signals în Angular.