← Back to Index

Lesson 1

Signals: The Reactive Primitive

The Core Idea

Mental model: A signal is a value with a notification mechanism. You read it by calling it like a function. Angular tracks where you read it and re-renders only what depends on it. There's no subscription. No teardown. No async pipe.

If you've spent a decade with RxJS, your instinct is to think in streams - sequences of values pushed over time, transformed through operators, eventually subscribed to. Signals flip that model on its head.

A signal is not a stream. It's a synchronous, reactive value. It always has a current value. You read it when you need it. The framework knows where you read it, and when the value changes, it re-evaluates only those consumers.

Pull reads, push invalidation: The word "pull-based" can be confusing. Here's what it means in practice: reading is pull - you call count() to get the value. But change notification is push - when you call count.set(5), Angular immediately marks all dependents as stale via the reactive graph. The actual re-evaluation then happens when something pulls: for computed signals, that's the next read. For templates, Angular's change detection scheduler listens directly to signal notifications and triggers a targeted re-render of only the dirty components. No zone.js needed - Angular 21+ is zoneless by default, so this just works without any configuration. Values do auto-update in the UI - the mechanism is "invalidate, schedule, then re-pull" rather than "push new value directly into the subscriber."
Observable (RxJS)Signal (Angular)
ModelPush-based streamPull reads, push invalidation
Has current value?Only BehaviorSubject/ReplaySubjectAlways
Reading.subscribe() or async pipeInvoke it as a function: mySignal()
CleanupUnsubscribe / takeUntilDestroyedAutomatic (framework-managed)
Async?Yes - values arrive over timeNo - always synchronous
Glitch-free?No (diamond problem - see below)Yes (lazy evaluation prevents glitches)

The closest RxJS analogue to a signal is a BehaviorSubject - it always holds a current value, and you can read it synchronously with .getValue(). But signals shed all the subscription machinery and give the framework fine-grained tracking that observables never could.

The Diamond Problem and Glitch-Free Evaluation

A glitch in reactive programming is when a consumer momentarily sees an inconsistent, half-updated state. The classic cause is the diamond problem:

Observable (Push) - Glitch

A B C D GLITCH! D sees new B + old C

B pushes to D before C has updated - D momentarily sees an inconsistent state.

normal propagation
premature push (glitch)

Signal (Pull) - Glitch-Free

A B C D D pulls B + C together - consistent! stale stale

All nodes are marked stale first, then D pulls B and C together - always consistent.

invalidation (mark stale)
pull (consistent read)

With observables (push-based): When A emits, B recalculates and immediately pushes to D. At that instant, D sees the new B but the old C - an inconsistent snapshot. Then C updates and pushes to D again. That first intermediate emission is the glitch. In RxJS, combineLatest([b$, c$]) exhibits this: it fires once with stale C, then again with both correct.

With signals (lazy evaluation): When A changes, B and C are merely marked stale. D doesn't recalculate until something reads it. By that point, both dependencies are known to be stale, so when D pulls from B and C, they both recalculate against the already-updated A. You only ever see the final consistent state. No glitch.

Zoneless by Default

If you're wondering what actually triggers re-evaluation when a signal is marked stale - the answer used to be zone.js. Not anymore.

Zone.js was Angular's original mechanism for knowing when to run change detection. It monkey-patched every async API in the browser (setTimeout, Promise, addEventListener, etc.) and triggered a full change detection cycle after each one. It worked, but it was a shotgun: it checked the entire component tree after any async event, whether state actually changed or not.

Since Angular 21, zoneless is the default. You don't configure anything - new apps simply don't include zone.js. Angular's scheduler instead listens directly to signal notifications via the reactive graph, and triggers targeted re-renders of only the components whose bindings actually depend on the changed signal.

Why this matters

BenefitWhat it means in practice
Smaller bundlezone.js was ~13KB gzipped. Gone.
Fewer CD cyclesNo more change detection after every setTimeout or HTTP response. Only runs when a signal actually changes.
Targeted updatesZone.js couldn't know which component was dirty. Signals tell Angular exactly which bindings changed.
No phantom cyclesA random setTimeout in a third-party library no longer triggers change detection across your entire app.
No ExpressionChangedAfterItHasBeenCheckedErrorThis error was a symptom of zone-triggered CD running at unexpected times. With signals, state changes are explicit.
Better interopNo more monkey-patching global APIs. Third-party libraries and micro-frontends no longer fight over zone.js.
Clearer mental model"State changes when I call .set() or .update()" is far easier to reason about than "state might be checked after any async event anywhere."
The timeline: Zoneless was experimental in Angular 19 (opt-in via provideExperimentalZonelessChangeDetection()), graduated to stable in Angular 20 (provideZonelessChangeDetection(), still opt-in), and became the default in Angular 21+. Angular 22 additionally makes OnPush the default change detection strategy. If you're on Angular 21 or later, you get all of this for free.

What exactly triggers change detection without zone.js?

In zoneless Angular, change detection runs when any of the following happens:

  1. A signal read in a template changes - the scheduler is notified directly by the reactive graph.
  2. A template event listener fires - any (click), (keyup), (submit) etc. bound in a template automatically schedules CD after the handler runs.
  3. ChangeDetectorRef.markForCheck() is called - this still works exactly as before. The async pipe calls this internally, so observable$ | async still triggers updates.
  4. ComponentRef.setInput() is called - this is what Angular calls internally when a parent passes a new value to a child's @Input(). So traditional inputs still work.
  5. A view is attached that was already marked dirty - e.g. lazy-loaded components or *ngIf revealing a dirty view.

Do I need signals for zoneless to work?

No. Traditional patterns still trigger change detection through the mechanisms above. Specifically:

PatternWhy it still works without zone.js
@Input() bindingsAngular calls setInput() internally when the parent re-renders and passes a new value.
observable$ | asyncThe async pipe calls markForCheck() when the observable emits.
Event bindings (click)="doThing()"Template listeners schedule CD automatically after the handler completes.
Classic NgRx with store.select() | asyncSame as above - the async pipe handles it.

What breaks without zone.js?

The one thing that won't work: mutating a plain class property inside a raw setTimeout, setInterval, or Promise.then() without calling markForCheck() or triggering one of the mechanisms above.

// This WORKED with zone.js (zone intercepted the setTimeout)
// This BREAKS in zoneless (nothing tells Angular to check)
@Component({ template: `{{ name }}` })
export class BrokenComponent {
  name = 'initial';

  ngOnInit() {
    setTimeout(() => {
      this.name = 'updated'; // UI won't update!
    }, 1000);
  }
}

// Fix: use a signal instead
@Component({ template: `{{ name() }}` })
export class FixedComponent {
  name = signal('initial');

  ngOnInit() {
    setTimeout(() => {
      this.name.set('updated'); // signal notifies the scheduler
    }, 1000);
  }
}

This is the only real "gotcha" when moving to zoneless. If you're already using OnPush + observables + async pipe (which you are with classic NgRx), you were never relying on zone.js to catch plain property mutations anyway - so the transition is seamless. Signals just make it explicit and remove the last edge cases.

Our Running Example: The Policy Dashboard

Throughout this course, our code examples live in a Policy Dashboard - an insurance platform that manages policies, claims, endorsements, and pricing. If you see types like Policy, Claim, or CoverType, that's the domain. It gives us realistic data structures (nested objects, arrays, statuses) without needing to explain the business logic each time.

The Policy Dashboard wireframe. Blue annotations show which signal primitives power each UI region - we'll build these patterns throughout the course.

Creating a Writable Signal

The signal() function creates a writable signal. You pass it an initial value:

import { signal } from '@angular/core';

const count = signal(0);          // WritableSignal<number>
const name = signal('');           // WritableSignal<string>
const policies = signal<Policy[]>([]);  // WritableSignal<Policy[]>

The type is inferred from the initial value. For arrays or complex types, you'll sometimes need an explicit generic to avoid a TypeScript gotcha: an empty array literal [] is inferred as never[] in strict mode, meaning you can't assign anything to it later:

// Without explicit type - inferred as WritableSignal<never[]>
const policies = signal([]);
policies.set([{ id: '1' }]); // Error: '{ id: string }' is not assignable to 'never'

// With explicit type - works as expected
const policies = signal<Policy[]>([]);
policies.set([{ id: '1', holder: 'Acme Corp', premium: 50000 }]); // Fine

This isn't specific to signals - it's how TypeScript infers empty arrays anywhere. But it trips people up here because signal([]) looks like it should be flexible. The fix is always to provide the generic: signal<YourType[]>([]).

What is never? Think of types as sets of possible values. string is the set of all strings. unknown is the set of everything. never is the empty set - no value belongs to it. Nothing is assignable to never because there's no value that fits in an empty set. So never[] means "an array that can only contain values from the empty set" - i.e. an array where you can't put anything in. TypeScript infers [] as never[] because it has no evidence of what goes in there and picks the most restrictive type possible.

Reading a Signal

A signal is a function. You read its current value by invoking it with parentheses - whatever you named the variable, just add ():

const count = signal(0);
const name = signal('');
const policies = signal<Policy[]>([]);

// Read by calling the signal as a function
console.log(count());    // 0
console.log(name());     // ''
console.log(policies()); // []
Why a function call? The act of calling count() is what lets Angular track the read. If you read a signal inside a template, a computed(), or an effect(), Angular records the dependency. This is what makes signals reactive - not a subscription, but a tracked read.

The RxJS equivalent: Where you'd previously write {{ count$ | async }} in a template or this.count$.getValue() in class code, you now write {{ count() }} in templates and this.count() in class code. Same signal, same call - templates just don't need this. No pipe. No subscription. No memory leak risk.

Updating a Signal

Writable signals provide two mutation methods:

.set() - Replace the value

count.set(5);       // value is now 5
name.set('Acme Insurance'); // value is now 'Acme Insurance'

.update() - Derive from the previous value

count.update(prev => prev + 1);  // 5 → 6

policies.update(prev => [
  ...prev,
  { id: '1', holder: 'Acme Corp', premium: 50000 }
]);

.update() takes a pure function: previous value in, new value out. This is your bread and butter for immutable state updates - the signal equivalent of the reducer pattern you know from NgRx.

No immutability enforcement: The prev value in .update() is not frozen or typed as Readonly. Nothing stops you from mutating it directly (e.g. prev.push(item); return prev;). But if you do, you return the same reference - Object.is() sees no change, consumers aren't notified, and the UI silently goes stale. Same discipline as NgRx reducers: always return a new reference.

.set() vs .update() - When to use which

Use .set()Use .update()
You have the complete new valueYou need to derive from the current value
Resetting stateIncrementing, appending, toggling
Assigning from an API responseTransforming existing state
Ad

Computed Signals

A computed() signal derives its value from other signals. It's read-only - the type system enforces this at compile time. A computed() returns Signal<T>, not WritableSignal<T>, so .set() and .update() simply don't exist on it. Try it and TypeScript stops you before the code ever runs:

import { signal, computed } from '@angular/core';

const count = signal(0);
const doubleCount = computed(() => count() * 2);

doubleCount.set(10);        // TS Error: Property 'set' does not exist on type 'Signal<number>'
doubleCount.update(v => v); // TS Error: Property 'update' does not exist on type 'Signal<number>'

// To change doubleCount, change its source:
count.set(3);
console.log(doubleCount()); // 6
Lazy and memoized: The derivation function only runs when you read the computed signal, and only if a dependency has changed since the last read. If nothing changed, it returns the cached value. This means expensive computations (filtering, sorting) are safe inside computed - they won't re-run unless their inputs actually change.

The RxJS Comparison

In RxJS, you'd derive state with operators:

// RxJS way
count$ = new BehaviorSubject(0);
doubleCount$ = this.count$.pipe(
  map(c => c * 2),
  distinctUntilChanged(),
  shareReplay(1)
);

// Signals way
count = signal(0);
doubleCount = computed(() => this.count() * 2);

Notice what vanished: pipe(), map(), distinctUntilChanged() (signals already skip if the value hasn't changed), shareReplay(1) (computed is already memoized and shared). The computed signal collapses all of that into a single declaration.

Dynamic Dependencies

Computed signals track dependencies dynamically - only what was actually read during the last evaluation counts. This is different from RxJS combineLatest, which always subscribes to all sources.

const showCount = signal(false);
const count = signal(0);

const message = computed(() => {
  if (showCount()) {
    return `Count is ${count()}`;
  }
  return 'Nothing to see here';
});

// When showCount is false, changes to count don't
// invalidate the computed - count isn't a dependency.
count.set(99);
console.log(message()); // 'Nothing to see here' - not recomputed

This is genuinely different from how combineLatest([showCount$, count$]) works - that would emit on every change to either source, regardless of branching logic. Signals are smarter here.

Equality and Change Detection

By default, signals use Object.is() to determine if a value has changed. Setting the same value again does not trigger consumers:

const count = signal(5);
count.set(5); // No change detected - consumers not notified

For objects and arrays, this means reference equality. Setting a new array with the same contents will trigger consumers (different reference).

Custom Equality Functions

You can override the default Object.is() check by passing an equal option. This works on signal(), computed(), and (as we'll see in Lesson 3) linkedSignal():

import isEqual from 'lodash/isEqual';

// signal with custom equality - deep comparison
const data = signal(['active', 'pending'], { equal: isEqual });
data.set(['active', 'pending']); // Same contents - no notification

// computed with custom equality - only notify if ID changes
const activePolicy = computed(
  () => this.policies().find(p => p.status === 'active'),
  { equal: (a, b) => a?.id === b?.id }
);

This is the signal equivalent of distinctUntilChanged() in RxJS - but built into the primitive itself rather than being an operator you chain on.

Watch out: Custom equality only prevents notification. It does not prevent deep mutation of the existing value from causing bugs. Signals have no built-in immutability enforcement. Treat signal values as immutable - always .set() or .update() with a new reference.

Read-Only Signals with .asReadonly()

Encapsulation matters. Expose a signal publicly for reading, keep writing private:

import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CounterService {
  // Private - only this service can write
  private readonly _count = signal(0);

  // Public - consumers can read but not write
  readonly count = this._count.asReadonly();

  increment(): void {
    this._count.update(c => c + 1);
  }
}

This is the signal equivalent of the pattern where you'd expose a BehaviorSubject as an Observable:

// The old way
private _count$ = new BehaviorSubject(0);
readonly count$ = this._count$.asObservable();

// The signal way
private _count = signal(0);
readonly count = this._count.asReadonly();

Same encapsulation principle. Less machinery.

Signals in Templates

In a component template, you simply call the signal:

@Component({
  template: `
    <h2>Count: {{ count() }}</h2>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  count = signal(0);
  doubleCount = computed(() => this.count() * 2);

  increment(): void {
    this.count.update(c => c + 1);
  }
}
OnPush is now the default: In Angular 22, OnPush is the default change detection strategy - you don't need to declare it. Combined with zoneless (default since Angular 21), Angular knows exactly which component bindings depend on which signals. When a signal changes, only that component is marked dirty - no markForCheck(), no async pipe, no zone.js monkey-patching. The example above omits the changeDetection property entirely and still gets optimal behaviour.

The old way:

// Before signals - OnPush + BehaviorSubject + async pipe
@Component({
  template: `
    <h2>Count: {{ count$ | async }}</h2>
    <p>Double: {{ doubleCount$ | async }}</p>
    <button (click)="increment()">+1</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  private count$ = new BehaviorSubject(0);
  doubleCount$ = this.count$.pipe(map(c => c * 2));

  increment(): void {
    this.count$.next(this.count$.getValue() + 1);
  }
}

The signal version has less noise, no pipe imports, no subscription lifecycle to worry about, and the same (actually better) change detection performance.

When Signals Are Not Enough

Signals solve synchronous, state-centric reactivity beautifully. But they have clear boundaries:

Signals handle wellStill reach for RxJS
Component stateComplex async orchestration (retry, debounce, switchMap)
Derived/computed stateEvent streams (WebSockets, DOM events with operators)
Simple async (resource/httpResource)Race condition management
Template bindingsBackpressure / buffering
Cross-component stateComplex time-based logic (debounceTime, throttle, delay)

We'll explore this boundary in detail in Lesson 18 (When to Keep RxJS). For now, the rule of thumb: if your problem is about state, reach for signals. If it's about events over time, reach for observables.

A Note on Multicasting

One thing signals eliminate entirely is the multicasting question. With observables, reading from the same source in two places means two subscriptions (unicast by default) - hence the need for share(), shareReplay(), or subjects. Signals are inherently multicast: every consumer reads the same value from the same source. There is no concept of "subscribing twice" to a signal. This eliminates an entire class of bugs.

Refresher: Unicast vs. Multicast

If this distinction is fuzzy, here's the concrete version using a random number generator:

// Observable (unicast) - each subscriber triggers its own execution
const random$ = new Observable(subscriber => {
  subscriber.next(Math.random()); // runs independently per subscriber
});

random$.subscribe(v => console.log('A:', v)); // A: 0.4281...
random$.subscribe(v => console.log('B:', v)); // B: 0.7193... (different!)

// Subject (multicast) - one value shared to all subscribers
const random$$ = new Subject<number>();

random$$.subscribe(v => console.log('A:', v));
random$$.subscribe(v => console.log('B:', v));

random$$.next(Math.random()); // A: 0.5612..., B: 0.5612... (same!)

// Signal - no subscription, no question
const random = signal(Math.random()); // computed once, everyone reads the same value
console.log(random()); // same value every time, for every consumer

The observable's producer function runs per subscriber. The subject's value is emitted once and fanned out to all subscribers. Operators like shareReplay() exist to convert unicast observables into multicast ones (internally using a Subject). Signals sidestep the entire concept - there's no subscription, so there's no "per-subscriber execution" to worry about.

No hot/cold distinction: Observables can be cold (each subscriber gets their own execution) or hot (shared execution). Signals are always "hot" in that sense - there's one value, and everyone sees it. But they're also "cold" in that computed signals are lazy (they don't compute until read). The hot/cold mental model simply doesn't apply. You can let it go.

Quiz

What does calling a signal (e.g. count()) do besides returning the value?

It triggers change detection across the application
It registers a dependency if called within a reactive context
It creates a new subscription to the signal's value stream

You set a signal to the same value it already holds. What happens?

Nothing - consumers are not notified (Object.is equality check)
All dependent computed signals are recalculated immediately
The value is set and change detection runs for the component

A computed signal's derivation reads signal A only when signal B is true. Signal B is currently false. You update signal A. What happens to the computed?

Nothing - A is not currently a dependency, so no invalidation occurs
The computed recalculates and returns the new derived value
The computed throws an error because A was read conditionally

What is the signal equivalent of distinctUntilChanged() in RxJS?

You must wrap computed signals with a distinct() utility function
It's built in - signals skip notification when the new value equals the old (via the equal option)
There is no equivalent; signals always notify on every .set() call

Try It Yourself

Exercise 1: Policy Premium Tracker

Build a small service that tracks a policy's base premium and an adjustment percentage. The final premium should be derived automatically.

  1. Create a writable signal basePremium with an initial value of 100000
  2. Create a writable signal adjustmentPct with an initial value of 0.1 (10%)
  3. Create a computed signal finalPremium that calculates basePremium * (1 + adjustmentPct)
  4. Expose finalPremium as read-only to consumers

What should finalPremium() return initially? What happens after basePremium.set(200000)?

import { Injectable, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class PremiumService {
  private readonly _basePremium = signal(100000);
  private readonly _adjustmentPct = signal(0.1);

  readonly basePremium = this._basePremium.asReadonly();
  readonly adjustmentPct = this._adjustmentPct.asReadonly();
  readonly finalPremium = computed(() =>
    this._basePremium() * (1 + this._adjustmentPct())
  );

  setBasePremium(value: number): void {
    this._basePremium.set(value);
  }

  setAdjustment(pct: number): void {
    this._adjustmentPct.set(pct);
  }
}

// finalPremium() initially returns 110000
// After basePremium.set(200000), finalPremium() returns 220000
// No subscription, no pipe - just read the computed when you need it

Exercise 2: Conditional Dependency Tracking

Predict the output. Don't run it - reason through the dependency graph:

const showDetails = signal(false);
const policyCount = signal(3);
const label = computed(() => {
  if (showDetails()) {
    return `${policyCount()} policies on risk`;
  }
  return 'Details hidden';
});

console.log(label());         // (1) What logs here?
policyCount.set(7);
console.log(label());         // (2) What logs here?
showDetails.set(true);
console.log(label());         // (3) What logs here?

(1) 'Details hidden' - showDetails is false, so the else branch runs. policyCount is never read, so it's not a dependency.

(2) 'Details hidden' - policyCount changed, but it's not tracked as a dependency (the if-branch was never entered). The computed is not invalidated and returns its cached value.

(3) '7 policies on risk' - showDetails changed (it IS a dependency), so the computed re-evaluates. Now it enters the if-branch, reads policyCount (which is 7), and returns the new string. From this point forward, BOTH showDetails and policyCount are dependencies.

Exercise 3: Immutable Update Pattern

You have a signal holding a list of claims. Write the .update() call to add a new claim without mutating the existing array:

interface Claim {
  id: string;
  amount: number;
  status: 'open' | 'reserved' | 'settled';
}

const claims = signal<Claim[]>([
  { id: 'CLM-001', amount: 25000, status: 'open' },
  { id: 'CLM-002', amount: 150000, status: 'reserved' },
]);

const newClaim: Claim = { id: 'CLM-003', amount: 75000, status: 'open' };

// Write the .update() call to append newClaim
// Then write another .update() to mark CLM-001 as 'settled'
// Append a new claim - spread creates a new array reference
claims.update(prev => [...prev, newClaim]);

// Update a claim's status - map creates a new array, spread creates a new object
claims.update(prev =>
  prev.map(c => c.id === 'CLM-001' ? { ...c, status: 'settled' } : c)
);

Both operations return new references. The signal detects the change (new array reference !== old) and notifies consumers. If you'd done prev.push(newClaim); return prev;, the reference wouldn't change and consumers would never know.

Your Tangible Win

You now understand the core reactive primitive that everything else in this course builds on. You can create writable signals, derive computed values, control visibility with .asReadonly(), and - critically - you understand why this model is different from observables, not just how.

Next lesson, we'll look at effect() - the signal equivalent of tap()/subscribe() for side effects - and understand the rules of reactive contexts.

Something unclear? Ask me follow-up questions - I'm your teacher and can clarify anything, adjust examples, or go deeper on any concept.