From Pub-Sub to Zoneless: How Angular Signals Changed the Game
Angular Signals have been the talk of the town since their introduction, promising a new way to manage state and trigger updates in Angular applications. But how do they work under the hood, and how do they fit into Angular’s zoneless change detection mechanism? In this post, we’ll demystify Angular Signals by implementing signal from scratch, consuming angular signal outside of angular, and then diving into the Angular source code to see how signals integrate with the change detection system. By the end of this post, you’ll have a clear understanding of what Angular Signals are, how they work, and why they are a game-changer for Angular developers. Let’s get started!
Build a signal prototype from scratch
Signals aren’t new—they’re an implementation of the publish-subscribe pattern. Let’s see how it works.
-
Pub (Signal): Holds the value and a list of subscribers.
-
Sub (Effect): A piece of code that registers itself as a listener simply by “reading” the signal within a global context.
-
Reactivity: When the signal changes, it triggers the registered code to run again.
The following code hosted in typescript playground is a simple implementation of a signal.
🔑 Core Algorithm
-
Effect starts Set
activeConsumer = current effect -
Signal read (
get) IfactiveConsumerexists → register effect as a dependency -
Effect ends Restore previous
activeConsumer -
Signal write (
set) Mark all dependent consumers asdirtySchedule them withqueueMicrotask -
Batching Multiple updates → effect runs once after sync code
👉 Key idea:
activeConsumer is a global pointer that lets signals automatically know which effect depends on them.
While Angular’s signal’s implementation is much more robust, the idea is similar to the above code.
Consuming Angular’s Signal outside of Angular
Now that we have a basic understanding of how signals work, the next step is to integrate it with Angular’s change detection mechanism. But the integration is not trivial, let’s learn how to consume Angular signal first, which is basically writing our effect function. There is already an effect function in Angular, but it is tightly coupled with Angular’s runtime, and it only works within Angular’s context. Let’s write our bare-bones effect function that works in any JavaScript environment, not just in Angular. The following is the code, which need to used with angular source code, because some API is not public.
import { SIGNAL_NODE, setActiveConsumer } from "../primitives/signals";
import {
producerAddLiveConsumer,
ReactiveLink,
ReactiveNode,
} from "../primitives/signals/src/graph";
import { signal } from "./render3/reactivity/signal";
function agnosticEffect(fn: () => void) {
const consumer: ReactiveNode = {
...Object.create(SIGNAL_NODE),
// must implement
consumerOnSignalRead: (producer: any) => {
// this function is called when a signal is read inside the effect,
// we need to create a link between the signal (producer) and the effect (consumer)
console.log(`3. consumer.consumerOnSignalRead() called by singal read`);
producerAddLiveConsumer(producer, {
consumer: consumer,
producer: producer,
lastReadVersion: 0,
prevConsumer: undefined,
nextConsumer: undefined,
nextProducer: undefined,
} as ReactiveLink);
},
// must implement
consumerMarkedDirty: (node: ReactiveNode) => {
// 1. this function is called when a signal that the effect depends on is updated,
console.log(`1. consumer.consumerMarkedDirty() called by signal update`);
queueMicrotask(() => {
runEffect();
});
},
};
// effect wrapper, must implement
function runEffect() {
// must implement: we need to set the active consumer to our effect before running the
console.log(`2. setActiveConsumer(consumer)`);
const prevConsumer = setActiveConsumer(consumer);
// consumer.dirty is back to false because we're about to re-run the effect, and we want to
// allow it to be scheduled again if it reads a signal that changes during this run.
// must implement
consumer.dirty = false;
try {
fn();
} finally {
console.log(`4. setActiveConsumer(prevConsumer)`);
// must implement: after the effect runs, we restore the previous active consumer (if any).
setActiveConsumer(prevConsumer);
}
}
console.log(`▶️ run effect for the first time`);
runEffect();
// Return a cleanup function
return () => {
// Logic to detach from the graph (e.g., consumerDestroy(consumer))
};
}
console.log("🥬 signal created");
const price = signal(100);
agnosticEffect(() => {
console.log(`🔥 enter user effect code`);
const currentPrice = price();
console.log(`🔥 price signal return in user effect: ${currentPrice}`);
});
setTimeout(() => {
console.log("🥬 signal updating to 200");
price.set(200);
}, 1000);
setTimeout(() => {
console.log("🥬 signal updating to 300");
price.set(300);
}, 2000);
/* output should be:
🥬 signal created
▶️ run effect for the first time
2. setActiveConsumer(consumer)
🔥 enter user effect code
3. consumer.consumerOnSignalRead() called by singal read
🔥 price signal return in user effect: 100
4. setActiveConsumer(prevConsumer)
🥬 signal updating to 200
1. consumer.consumerMarkedDirty() called by signal update
2. setActiveConsumer(consumer)
🔥 enter user effect code
3. consumer.consumerOnSignalRead() called by singal read
🔥 price signal return in user effect: 200
4. setActiveConsumer(prevConsumer)
🥬 signal updating to 300
1. consumer.consumerMarkedDirty() called by signal update
2. setActiveConsumer(consumer)
🔥 enter user effect code
3. consumer.consumerOnSignalRead() called by singal read
🔥 price signal return in user effect: 300
4. setActiveConsumer(prevConsumer)
*/
⚡ TL;DR: The algorithm is simple:
- Set a global
activeConsumerpointer when an effect runs - Signals automatically register themselves as dependencies when read within that context
- When a signal updates, it marks all dependent effects as “dirty” and schedules them to run
- Multiple updates batch together via
queueMicrotask
Code walkthrough
To consume Angular signal, you basically need to do the following steps:
-
create effect wrapper that will set the active consumer to to our consumer before call our effect, and restore previous consumer after running the effect, and mark our consumer as not dirty, so that it can be scheduled again if it reads a signal that changes during this run.
- create a consumer object with the following members:
consumerOnSignalRead: this function is called when a dependent signal is read, inside this function, we need to create a link between the signal (producer) and the effect (consumer) by callingproducerAddLiveConsumerfunction.consumerMarkedDirty: this function is called when a dependent signal is updated, we need to schedule the effect to run in the future, we can usequeueMicrotaskto call our effect wrapper.
- run the effect for the first time to register the dependencies.
How to integrate Angular change detection with signals
This process mirrors the code above. Angular’s change detection internals are complex, but the key function is refreshView, which renders the component view. It creates a consumer object, sets it as the active consumer before rendering, and restores it afterward.
Note: The code below uses internal Angular APIs like REACTIVE_NODE, LView, TView, and REACTIVE_TEMPLATE_CONSUMER. These are not part of the public API.
export function refreshView<T>(
tView: TView,
lView: LView,
templateFn: ComponentTemplate<{}> | null,
context: T,
) {
// currentConsumer = getOrCreateTemporaryConsumer(lView);
currentConsumer = {
...REACTIVE_NODE,
consumerIsAlwaysLive: true,
kind: "template",
consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
markAncestorsForTraversal(node.lView!);
},
consumerOnSignalRead(this: ReactiveLViewConsumer): void {
this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
},
lView: lView,
};
prevConsumer = setActiveConsumer(currentConsumer);
//...
consumerAfterComputation(currentConsumer, prevConsumer);
// setActiveConsumer(prevConsumer);
}
So when signal is updated, it will call markAncestorsForTraversal to mark the affected component
and its ancestors as dirty. The markAncestorsForTraversal function is a core part of Angular’s
reactivity system. When a signal used in a template changes, it triggers this function to bridge the
gap between the reactive “Signal world” and Angular’s rendering engine.
What happens after markAncestorsForTraversal?
- Traversal to Root: The function “walks up” the component tree from the component where the signal was read to the root of the application.
- Scheduling a Notification: As it traverses, it notifies the
ChangeDetectionScheduler. Specifically, it callslView[ENVIRONMENT].changeDetectionScheduler?.notify(NotificationSource.MarkAncestorsForTraversal). - Scheduling
ApplicationRef.tick(): The scheduler does not run change detection immediately. Instead, it callsscheduleCallback, which uses a browser mechanism likerequestAnimationFrameor a microtask to schedule anApplicationRef.tick()in the near future.
How it reaches the node
Instead of checking every single component in the entire tree, Angular uses the “flags” set during the markAncestorsForTraversal phase.
- The Path is Lit: When a signal changes, it doesn’t just mark the current component; it marks every parent up to the root with a specific bitmask (like
HasChildToRefresh). - Dirty Checking by Exception: During
tick(), Angular starts at the top. If a component and its children are not marked as dirty, Angular skips that entire branch immediately. - Targeted Execution: Angular only “steps into” components that lie on the path to the signal that changed. Once it reaches the component containing the signal, it re-executes the template (the consumer), updates the DOM, and resets the dirty flags.
Efficiency: Signals vs. Zone.js
The shift from Zone.js to Signal-based change detection is a shift from Global Guessing to Local Certainty.
| Feature | Zone.js Mode (Traditional) | Signal/Zoneless Mode |
|---|---|---|
| Trigger | Any async event (click, timer, XHR) | Explicit signal change |
| Scope | Usually checks the entire component tree | Only checks marked paths |
| Overhead | High (Monkey-patching browser APIs) | Zero (Native JS execution) |
| Granularity | Component-level | Component-level (with future potential for finer updates) |
Why it’s more efficient
- Reduced Work: In Zone.js, if you click a button that does nothing, Angular might still check 500 components just in case. In Signal mode, if no signal changes,
tick()effectively does nothing. - No “Double-Edge” Checking: Zone.js often requires multiple passes or complex “Change Detection Strategy” (like
OnPush) to be performant. Signals provideOnPush-like performance by default without the developer having to manage it manually. - Clearer Tree Path: Because the signal consumer knows exactly which component instance it belongs to, Angular has a direct map to the “affected node” rather than having to discover it.
Practical implications for developers
Understanding how signals work helps you write better Angular code:
- Prefer signals over manual state management: No need to manually subscribe/unsubscribe or manage BehaviorSubjects.
- Use
effect()sparingly: Effects run synchronously and can trigger change detection. Use them for side effects like logging or imperative DOM updates, not frequent state mutations. - Better performance by default: In large component trees with deep nesting, signals skip entire branches that didn’t change, unlike Zone.js which checks everything.
- Zoneless future: As Angular moves toward zoneless change detection, signals become the primary reactivity primitive.
Conclusion
Angular Signals are a game-changer because they bring explicit, fine-grained reactivity to Angular. By implementing the publish-subscribe pattern and integrating deeply with Angular’s change detection engine, signals enable the framework to:
- Know exactly which components need to update
- Skip unnecessary checks on unaffected branches
- Reduce CPU overhead compared to global Zone.js patching
- Support a future where the framework is less “magical” and more explicit
The shift from Zone.js to Signals is a shift from “check everything, then figure out what changed” to “know exactly what changed, then update only what’s needed.” This is not just a performance optimization—it’s a fundamental rethinking of how change detection should work in modern frameworks.