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

  1. Effect starts Set activeConsumer = current effect

  2. Signal read (get) If activeConsumer exists → register effect as a dependency

  3. Effect ends Restore previous activeConsumer

  4. Signal write (set) Mark all dependent consumers as dirty Schedule them with queueMicrotask

  5. 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:

  1. Set a global activeConsumer pointer when an effect runs
  2. Signals automatically register themselves as dependencies when read within that context
  3. When a signal updates, it marks all dependent effects as “dirty” and schedules them to run
  4. Multiple updates batch together via queueMicrotask

Code walkthrough

To consume Angular signal, you basically need to do the following steps:

  1. 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.

  2. 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 calling producerAddLiveConsumer function.
    • consumerMarkedDirty: this function is called when a dependent signal is updated, we need to schedule the effect to run in the future, we can use queueMicrotask to call our effect wrapper.
  3. 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?

  1. Traversal to Root: The function “walks up” the component tree from the component where the signal was read to the root of the application.
  2. Scheduling a Notification: As it traverses, it notifies the ChangeDetectionScheduler. Specifically, it calls lView[ENVIRONMENT].changeDetectionScheduler?.notify(NotificationSource.MarkAncestorsForTraversal).
  3. Scheduling ApplicationRef.tick(): The scheduler does not run change detection immediately. Instead, it calls scheduleCallback, which uses a browser mechanism like requestAnimationFrame or a microtask to schedule an ApplicationRef.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

  1. 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.
  2. No “Double-Edge” Checking: Zone.js often requires multiple passes or complex “Change Detection Strategy” (like OnPush) to be performant. Signals provide OnPush-like performance by default without the developer having to manage it manually.
  3. 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.