Lesson 1
Signals: The Reactive Primitive
The Core Idea
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.
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) | |
|---|---|---|
| Model | Push-based stream | Pull reads, push invalidation |
| Has current value? | Only BehaviorSubject/ReplaySubject | Always |
| Reading | .subscribe() or async pipe | Invoke it as a function: mySignal() |
| Cleanup | Unsubscribe / takeUntilDestroyed | Automatic (framework-managed) |
| Async? | Yes - values arrive over time | No - 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
B pushes to D before C has updated - D momentarily sees an inconsistent state.
normal propagation
premature push (glitch)
Signal (Pull) - Glitch-Free
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
| Benefit | What it means in practice |
|---|---|
| Smaller bundle | zone.js was ~13KB gzipped. Gone. |
| Fewer CD cycles | No more change detection after every setTimeout or HTTP response. Only runs when a signal actually changes. |
| Targeted updates | Zone.js couldn't know which component was dirty. Signals tell Angular exactly which bindings changed. |
| No phantom cycles | A random setTimeout in a third-party library no longer triggers change detection across your entire app. |
No ExpressionChangedAfterItHasBeenCheckedError | This error was a symptom of zone-triggered CD running at unexpected times. With signals, state changes are explicit. |
| Better interop | No 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." |
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:
- A signal read in a template changes - the scheduler is notified directly by the reactive graph.
- A template event listener fires - any
(click),(keyup),(submit)etc. bound in a template automatically schedules CD after the handler runs. ChangeDetectorRef.markForCheck()is called - this still works exactly as before. Theasyncpipe calls this internally, soobservable$ | asyncstill triggers updates.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.- A view is attached that was already marked dirty - e.g. lazy-loaded components or
*ngIfrevealing a dirty view.
Do I need signals for zoneless to work?
No. Traditional patterns still trigger change detection through the mechanisms above. Specifically:
| Pattern | Why it still works without zone.js |
|---|---|
@Input() bindings | Angular calls setInput() internally when the parent re-renders and passes a new value. |
observable$ | async | The 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() | async | Same 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[]>([]).
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()); // []
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.
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 value | You need to derive from the current value |
| Resetting state | Incrementing, appending, toggling |
| Assigning from an API response | Transforming existing state |
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
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.
.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 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 well | Still reach for RxJS |
|---|---|
| Component state | Complex async orchestration (retry, debounce, switchMap) |
| Derived/computed state | Event streams (WebSockets, DOM events with operators) |
| Simple async (resource/httpResource) | Race condition management |
| Template bindings | Backpressure / buffering |
| Cross-component state | Complex 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.
Quiz
What does calling a signal (e.g. count()) do besides returning the value?
You set a signal to the same value it already holds. What happens?
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?
What is the signal equivalent of distinctUntilChanged() in RxJS?
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.
- Create a writable signal
basePremiumwith an initial value of100000 - Create a writable signal
adjustmentPctwith an initial value of0.1(10%) - Create a computed signal
finalPremiumthat calculatesbasePremium * (1 + adjustmentPct) - Expose
finalPremiumas 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.