Angular's classic reactivity relied on Zone.js patching browser APIs to trigger ApplicationRef.tick() after any async operation. This works, but carries overhead: every macro-task potentially triggers a full component-tree dirty check regardless of what actually changed.
Signals are a fine-grained reactive primitive. A signal wraps a value and tracks which consumers (computed values, effects, template expressions) have read it. When the value changes, only those consumers re-evaluate. The runtime never needs to speculate about what might have changed.
The model is a directed acyclic graph of producers and consumers:
Dependencies are tracked automatically by recording signal reads that occur during a reactive context (computed derivation, effect execution, template rendering). No decorators, no subscription management.
import { signal } from '@angular/core';
const count = signal(0);
// Read — invoke as a function
console.log(count()); // 0
// Replace the value entirely
count.set(1);
// Derive the next value from the current one
count.update(v => v + 1);
// Mutate an object/array in-place and notify consumers
const items = signal<string[]>([]);
items.mutate(arr => arr.push('new item')); // Angular 17–18
mutate() was removed in Angular 19. Use update(arr => [...arr, 'new item']) to return a new reference instead. The immutable update pattern is preferred regardless of Angular version.
A computed signal is lazy and memoized. It re-evaluates only when a tracked dependency changes and someone reads the computed value.
import { signal, computed } from '@angular/core';
const firstName = signal('Ada');
const lastName = signal('Lovelace');
const fullName = computed(() =>
`${firstName()} ${lastName()}`
);
console.log(fullName()); // 'Ada Lovelace'
firstName.set('Grace');
console.log(fullName()); // 'Grace Lovelace' — recomputed only once
A computed signal is read-only; there is no setter. Attempting to call .set() on it is a compile-time and runtime error.
An effect is a consumer that runs a side-effecting callback whenever its signal dependencies change. It must be created inside an injection context (constructor, factory function, or via runInInjectionContext).
import { Component, signal, effect, OnInit } from '@angular/core';
@Component({ /* ... */ })
export class CounterComponent {
count = signal(0);
constructor() {
effect(() => {
// Runs once immediately, then whenever count() changes
console.log('count is', this.count());
});
}
}
Effects are destroyed automatically when the component is destroyed. The cleanup callback returned from effect() provides an EffectRef with a .destroy() method for early teardown.
const ref = effect(() => { /* ... */ });
ref.destroy(); // stops the effect manually
Use onCleanup to run teardown logic before each re-run and on destroy:
effect(onCleanup => {
const id = setInterval(() => {
console.log(this.tick());
}, 1000);
onCleanup(() => clearInterval(id));
});
Angular's signal runtime uses a push-pull approach. When a writable signal changes, it marks downstream computed signals as dirty (push) but does not immediately evaluate them. Evaluation is deferred until a consumer reads the value (pull). This prevents "glitches" — intermediate states where a computed value reflects only part of an update.
const a = signal(1);
const b = computed(() => a() * 2);
const c = computed(() => a() + b());
a.set(2);
// b and c are marked dirty, not yet computed
console.log(c()); // 6 — a=2, b=4, c=6. Never sees c=3.
By default, signals use Object.is to compare old and new values. Unchanged values do not propagate. You can supply a custom comparator:
const pos = signal({ x: 0, y: 0 }, {
equal: (a, b) => a.x === b.x && a.y === b.y
});
pos.set({ x: 0, y: 0 });
// No notification — structurally equal
Sometimes you need to read a signal inside a reactive context without recording a dependency. Use untracked():
import { untracked } from '@angular/core';
effect(() => {
console.log(this.primary()); // tracked dependency
const meta = untracked(() => this.secondary()); // not tracked
console.log(meta);
});
untracked() is also useful in computed signals to read auxiliary values (e.g., configuration) that should not trigger recomputation when they change.
Templates are reactive consumers. Call the signal as a function — no pipe, no async, no subscription.
@Component({
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
double = computed(() => this.count() * 2);
increment() { this.count.update(v => v + 1); }
}
When count changes, Angular re-renders only the component that owns it — not the whole tree.
Signals work with both Default and OnPush change detection. With OnPush, the component is scheduled for re-check when any signal it reads has changed. This is the recommended pairing:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
Angular 17.1+ ships input(), input.required(), output(), and model() as signal-based alternatives to @Input() / @Output().
import { Component, input, computed } from '@angular/core';
@Component({ selector: 'app-user-card', /* ... */ })
export class UserCardComponent {
// Optional with default
name = input('Anonymous');
// Required — no default; compile error if parent omits it
userId = input.required<number>();
// Transform on ingestion
score = input(0, { transform: Number });
// Computed from input — no ngOnChanges needed
label = computed(() => `User #${this.userId()}: ${this.name()}`);
}
Signal inputs are read-only inside the component. The parent controls the value; the child cannot set it. This makes data flow explicit.
import { Component, output } from '@angular/core';
@Component({ /* ... */ })
export class SearchComponent {
searched = output<string>();
onSubmit(query: string) {
this.searched.emit(query);
}
}
output() does not use EventEmitter internally. It emits synchronously and has no Observable overhead. Parent template syntax is identical: (searched)="onSearch($event)".
model() creates a writable signal that is also exposed as an input/output pair. It replaces the [(ngModel)]-style pattern for component APIs.
@Component({
selector: 'app-toggle',
template: `<button (click)="toggle()">{{ checked() ? 'ON' : 'OFF' }}</button>`
})
export class ToggleComponent {
checked = model(false);
toggle() {
this.checked.update(v => !v);
// Automatically emits 'checkedChange' to the parent
}
}
// Parent template
// <app-toggle [(checked)]="isActive" />
model.required() when the parent must always bind a value. This prevents the signal from starting as undefined and eliminates a class of null-check boilerplate.
Angular provides two bridge functions in @angular/core/rxjs-interop.
Converts a signal to an Observable. The Observable emits whenever the signal's value changes and is subscribed within a reactive context (typically an injection context).
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class SearchService {
query = signal('');
results$ = toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.http.get(`/api/search?q=${q}`))
);
}
toObservable() schedules emissions via effect() internally, so the first emission is asynchronous (microtask). The signal's current value at subscription time is emitted first.
Converts an Observable to a read-only signal. Angular subscribes automatically and tears down when the injector is destroyed.
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ /* ... */ })
export class PriceComponent {
private price$ = inject(PriceService).stream$;
// initialValue required if the Observable hasn't emitted yet
price = toSignal(this.price$, { initialValue: 0 });
// Alternatively, requireSync: true — throws if Observable doesn't
// emit synchronously (e.g. BehaviorSubject)
syncPrice = toSignal(this.price$, { requireSync: true });
}
catchError before toSignal() to handle errors gracefully.
import { outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop';
export class MyComponent {
// Wraps an Observable as an Angular output
clicked = outputFromObservable(this.clicks$);
// Convert a signal output to an Observable for composition
searched$ = outputToObservable(this.searched);
}
A lightweight service acting as a signal-based store. No library required.
@Injectable({ providedIn: 'root' })
export class CartStore {
private _items = signal<CartItem[]>([]);
// Public read-only surface
items = computed(() => this._items());
total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0)
);
itemCount = computed(() =>
this._items().reduce((n, i) => n + i.qty, 0)
);
addItem(item: CartItem) {
this._items.update(items => {
const existing = items.find(i => i.id === item.id);
if (existing) {
return items.map(i =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...items, { ...item, qty: 1 }];
});
}
removeItem(id: number) {
this._items.update(items => items.filter(i => i.id !== id));
}
clear() { this._items.set([]); }
}
NgRx 17+ ships @ngrx/signals which provides a structured store built on Angular signals.
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';
type CounterState = { count: number; step: number };
export const CounterStore = signalStore(
withState<CounterState>({ count: 0, step: 1 }),
withComputed(({ count, step }) => ({
doubled: computed(() => count() * 2),
nextValue: computed(() => count() + step()),
})),
withMethods(store => ({
increment() { patchState(store, s => ({ count: s.count + s.step })); },
reset() { patchState(store, { count: 0 }); },
}))
);
Provide via providers: [CounterStore] and inject normally. Each state slice becomes a signal automatically.
Chain computed signals and use effects to synchronize side effects (analytics, local storage) without tangling them into components:
@Injectable({ providedIn: 'root' })
export class ThemeService {
preference = signal<'light' | 'dark' | 'system'>('system');
systemDark = toSignal(
fromEvent(window.matchMedia('(prefers-color-scheme: dark)'), 'change').pipe(
map((e: any) => e.matches)
),
{ initialValue: window.matchMedia('(prefers-color-scheme: dark)').matches }
);
resolved = computed(() => {
const pref = this.preference();
return pref === 'system' ? (this.systemDark() ? 'dark' : 'light') : pref;
});
constructor() {
effect(() => {
document.documentElement.dataset['theme'] = this.resolved();
localStorage.setItem('theme', this.preference());
});
}
}
Signal + effect replaces component-level subscribe/unsubscribe for async data:
@Component({ /* ... */ })
export class UserDetailComponent {
userId = input.required<number>();
user = signal<User | null>(null);
loading = signal(false);
error = signal<string | null>(null);
private http = inject(HttpClient);
constructor() {
effect(onCleanup => {
const id = this.userId();
this.loading.set(true);
this.error.set(null);
const sub = this.http.get<User>(`/api/users/${id}`)
.subscribe({
next: u => { this.user.set(u); this.loading.set(false); },
error: e => { this.error.set(e.message); this.loading.set(false); },
});
onCleanup(() => sub.unsubscribe());
});
}
}
resource() primitive that codifies exactly this pattern. If available in your version, prefer it over the manual effect approach above.
import { resource, input } from '@angular/core';
@Component({ /* ... */ })
export class UserDetailComponent {
userId = input.required<number>();
userResource = resource({
request: () => ({ id: this.userId() }),
loader: async ({ request, abortSignal }) => {
const resp = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
if (!resp.ok) throw new Error('fetch failed');
return resp.json() as Promise<User>;
}
});
// Derived signals
user = this.userResource.value;
loading = this.userResource.isLoading;
error = this.userResource.error;
}
The resource automatically aborts in-flight requests when userId changes before the previous request completes.
Signals enable fully zoneless applications. Zone.js is no longer required — Angular uses signal notifications to schedule rendering.
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection()
// Remove provideZoneChangeDetection()
]
});
In angular.json, also remove the Zone.js import from polyfills:
// angular.json (before)
"polyfills": ["zone.js"]
// angular.json (after)
"polyfills": []
ChangeDetectorRef.markForCheck(), or AsyncPipe. Third-party libraries still relying on Zone.js may require wrappers or NgZone.run() to trigger detection.
If you need to trigger rendering outside a signal context (e.g., a Web Worker message), use ChangeDetectorRef:
private cdr = inject(ChangeDetectorRef);
worker.onmessage = ({ data }) => {
this.result.set(data); // signal handles scheduling
// or if not using signals:
this.cdr.markForCheck();
};
const items = signal([1, 2, 3]);
// ❌ Mutates in place — consumers see the same reference, no notification
items().push(4);
// ✅ Returns a new array — notification fires
items.update(arr => [...arr, 4]);
Writing to a signal inside an effect that also reads it creates a cycle. Angular detects this in development mode and throws:
// ❌ Infinite loop in dev, silent in prod (may or may not cycle)
effect(() => {
if (this.count() > 10) this.count.set(0); // reads and writes count
});
// ✅ Break the cycle with untracked
effect(() => {
const c = this.count();
if (c > 10) untracked(() => this.count.set(0));
});
Alternatively, restructure so a computed derives the clamped value and a single set() happens outside the effect.
// ❌ Error: effect() must run in an injection context
export class MyService {
startTracking() {
effect(() => console.log(this.data()));
}
}
// ✅ Use runInInjectionContext if outside constructor
private injector = inject(Injector);
startTracking() {
runInInjectionContext(this.injector, () => {
effect(() => console.log(this.data()));
});
}
// ❌ Unnecessary effect — just derive it
tax = signal(0);
constructor() {
effect(() => this.tax.set(this.subtotal() * 0.08));
}
// ✅ Computed — lazy, memoized, no write necessary
tax = computed(() => this.subtotal() * 0.08);
| API | Import | Type | Key characteristics |
|---|---|---|---|
signal(v) |
@angular/core |
WritableSignal<T> | Root value container. .set(), .update(). |
computed(fn) |
@angular/core |
Signal<T> | Lazy, memoized. Read-only. Tracks own dependencies. |
effect(fn) |
@angular/core |
EffectRef | Side-effect runner. Injection context required. Auto-cleanup. |
untracked(fn) |
@angular/core |
T | Reads signals without recording dependencies. |
input(default) |
@angular/core |
InputSignal<T> | Signal-based @Input. Read-only inside component. |
input.required() |
@angular/core |
InputSignal<T> | Required input. Compile error if omitted by parent. |
output() |
@angular/core |
OutputEmitterRef<T> | Signal-based @Output. Synchronous, no EventEmitter. |
model() |
@angular/core |
ModelSignal<T> | Two-way binding. Writable inside child, reflects to parent. |
toSignal(obs$) |
@angular/core/rxjs-interop |
Signal<T> | Observable → Signal. Auto-subscribes and unsubscribes. |
toObservable(sig) |
@angular/core/rxjs-interop |
Observable<T> | Signal → Observable. Emissions are async (microtask). |
resource(opts) |
@angular/core |
Resource<T> | Angular 19+. Async data loading with abort and status signals. |
| Concern | Old approach | Signal approach |
|---|---|---|
| Reactive state | RxJS BehaviorSubject + async pipe | signal() read in template |
| Derived state | RxJS combineLatest + map |
computed() |
| Side effects | tap/subscribe + lifecycle hooks | effect() |
| Component inputs | @Input() + ngOnChanges |
input() + computed() |
| Component outputs | @Output() EventEmitter |
output() |
| Two-way binding | @Input + @Output + ChangeEvent | model() |
| Async data | subscribe + unsubscribe lifecycle | toSignal() or resource() |
| Change detection | Zone.js + Default strategy | Signals + OnPush or zoneless |