Ever had a component in Angular that stubbornly refuses to update when you think it should? You tweak some state, and the UI looks frozen , no error, no warning, just silence. Then, after some trial and error, you realize the problem isn’t your code but how Angular’s new Signals system tracks dependencies and triggers change detection.
I hit this wall recently while migrating a complex form to use Angular Signals. At first glance, signals seemed like magic , reactive primitives that promised simpler state management and automatic updates. But the behavior wasn’t always intuitive, especially when combined with Angular’s existing change detection mechanism.
Here’s what I learned about how Angular Signals work under the hood, how they track dependencies, how they trigger change detection, and some debugging strategies to tame reactive state bugs in production.
Angular Signals: More than just reactive variables
Signals in Angular are reactive containers that hold a value and notify subscribers when that value changes. Unlike simple BehaviorSubjects or EventEmitters, signals automatically track which computations depend on them, so updates propagate precisely where needed.
But how does Angular know which component or template should update when a signal changes? The answer lies in dependency tracking combined with Angular’s change detection.
Dependency tracking: Catching who uses what
When you read a signal's value inside a reactive computation or template, Angular records that this computation depends on the signal. This happens through a global tracking context:
- When Angular evaluates a reactive function (like a computed signal or a component context during change detection), it sets a current "tracking frame."
- When you access a signal’s value inside that frame, the signal registers itself as a dependency.
- Later, when the signal’s value changes, Angular knows exactly which computations or components need to be re-run or marked dirty.
For example:
const count = signal(0);
const doubled = computed(() => count() * 2);
Here, doubled tracks count as a dependency. When count updates, Angular will re-run the doubled computation.
Signals and Angular change detection: The handshake
Angular traditionally uses zones and dirty checking to detect changes. Signals add a layer of precision by marking affected components as dirty immediately when a signal changes.
Here’s the flow:
- A signal’s setter updates its value.
- The signal notifies all subscribed computations and Angular components that depend on it.
- Angular marks those components as dirty.
- At the next change detection cycle, only the dirty components re-render.
This reduces unnecessary re-renders and improves performance.
But it also means if you update a signal outside Angular’s zone or without proper subscription, Angular might not know to check for updates.
Where bugs creep in: Missing subscriptions and outside zone updates
A frequent gotcha I faced was updating signals in contexts Angular doesn’t track, such as:
- Event handlers not wrapped in Angular’s zone.
- Observables or promises updating signals asynchronously.
- Signals used inside third-party libraries or outside Angular’s component context.
In these cases, Angular’s change detection can miss the signal update, leaving the UI stale.
For instance:
import { runOutsideAngular } from '@angular/core';
runOutsideAngular(() => {
setTimeout(() => {
count.set(count() + 1); // Angular may not detect this change
}, 1000);
});
Without wrapping this in NgZone.run(), Angular won’t know to run change detection after count updates.
Debugging reactive state bugs: What to watch for
Check where you update signals: Are updates happening inside Angular’s zone? If not, wrap them in
NgZone.run()to ensure change detection triggers.Verify signal subscriptions: Components automatically subscribe to signals accessed in templates or computations. But if you read signals imperatively without access during change detection, Angular won’t track dependencies.
Use
effect()for side effects: When you need to run code in response to signal changes, useeffect(). It automatically tracks dependencies and runs when signals change.
Example:
effect(() => {
console.log('Count changed:', count());
});
- Inspect the dependency graph: Angular provides debugging tools that show signal dependencies and which computations are dirty. Use these to spot unexpected missing links.
Signals and templates: How Angular wires it all up
When you use signals in templates, Angular calls the signal to get its current value during change detection. This access causes Angular to register the component as a subscriber to the signal.
So, if you write:
<div>{{ count() }}</div>
Angular knows this component depends on count. When count updates, Angular marks this component dirty, triggering a re-render.
Computed signals: Laziness and caching
Computed signals are lazy. They only re-run their function when accessed and when one of their dependencies has changed.
That means if you have a computed signal that isn’t read by anything, it won’t run. This can trip you up if you expect side effects inside computed signals , those should go inside effect() instead.
One more thing: Batch updates
Angular batches synchronous signal updates to avoid redundant change detection runs. If you update multiple signals synchronously, Angular runs change detection once after all updates complete.
This is why sometimes your UI updates only after all state changes finish, which is a good thing for performance but can confuse debugging if you expect immediate UI changes after each setter.
Wrapping up
Angular Signals bring a fresh reactive model to Angular apps, but their magic depends on how Angular tracks dependencies and integrates with change detection. If you find your UI not updating, check if your signals are updated inside Angular’s zone and if components properly subscribe to those signals.
Remember:
- Accessing signals during component rendering registers dependencies.
- Signal updates mark components dirty to trigger change detection.
- Updates outside Angular’s zone need manual zone re-entry.
- Use
effect()for side effects, not computed signals.
Getting these right will save you from mysterious stale UI bugs and make your Angular apps feel snappy and reliable.
If you’re diving into Angular Signals, keep an eye on these mechanisms under the hood , they’re the secret sauce behind smooth reactivity.
Helpful learning resources
- W3C WAI accessibility guidance
- W3Schools accessibility tutorials
- MDN Web Docs Originally published at Under The Hood. Get the next deep dive in your inbox: subscribe to Under The Hood.
Top comments (0)