A signal is a box that holds a value and automatically notifies everyone who read it when it changes. Angular keeps track: when the template, a computed() or an effect() reads count(), it depends on it. When count changes, only what depends on it updates — no Zone.js, no checking the whole component tree.
From v21 new applications are zoneless, and from v22 components are OnPush by default. So signals are no longer “a modern option” — they’re the main mechanism by which Angular knows what to re-render. If you keep state in plain fields and change it from a setTimeout or a subscribe, the UI may stop updating.
The three basic operations
You create with signal(initialValue), read by calling the signal like a function, and write with set() or update(). Derived state is made with computed().
import { computed, signal, Signal, WritableSignal } from '@angular/core';
const name: WritableSignal<string> = signal('Ana');
name(); // read → 'Ana'
name.set('Maria'); // direct write
name.update((n) => n.toUpperCase()); // write based on the old value
const greeting: Signal<string> = computed(() => `Hello, ${name()}!`);
greeting(); // 'Hello, MARIA!'
// greeting.set(...) ✗ a computed is read-only
const readonlyName: Signal<string> = name.asReadonly(); // exposed without write accessdouble()= 0parity()= evencomputedrecalculated 1 times (only when count changed)
What to remember about computed()
- Lazy: it isn’t computed until someone reads it.
- Memoised: if the dependencies haven’t changed, it returns the cached value — however often you read it.
- Dynamic dependencies: it tracks only the signals actually read in the last run. In a
cond() ? a() : b(), whencondis true, changes tobare ignored. - Pure: no side effects (no HTTP, no
set()on other signals, no DOM).
The golden rule: derived state = computed()
Any value that can be calculated from other state is not stored separately. A cart total, the filtered list, “is the form valid”, “does the user have admin rights” — all of them are computed(). One source of truth, zero manual synchronisation.
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
export class Cart {
protected readonly items = signal<CartItem[]>([]);
protected readonly filter = signal('');
// ✓ Everything that can be calculated from something else = computed()
protected readonly visible = computed(() =>
this.items().filter((i) => i.name.toLowerCase().includes(this.filter().toLowerCase())),
);
protected readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
protected readonly vat = computed(() => Math.round(this.subtotal() * 0.19));
protected readonly total = computed(() => this.subtotal() + this.vat());
protected readonly freeShipping = computed(() => this.subtotal() >= 1000);
protected changeQty(id: number, delta: number): void {
this.items.update((items) =>
items.map((i) => (i.id === id ? { ...i, qty: Math.max(0, i.qty + delta) } : i)),
);
}
}| Product | Price | Quantity | Total |
|---|---|---|---|
| Mechanical keyboard | 450 RON | 1 | 450 RON |
| Ergonomic mouse | 220 RON | 0 | 0 RON |
| Monitor 27" | 1300 RON | 0 | 0 RON |
| USB-C hub | 160 RON | 2 | 320 RON |
// ✓ A single source of truth, the rest derived
export class Cart {
readonly items = signal<CartItem[]>([]);
readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}// ✗ Derived state STORED and synchronised by hand
export class Cart {
items = signal<CartItem[]>([]);
total = signal(0);
add(item: CartItem) {
this.items.update((list) => [...list, item]);
this.total.set(this.total() + item.price); // easy to forget, easy to get out of sync
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
// bug: we forgot to update total
}
}
// ✗ Just as bad: an effect that “copies” a value into another signal
effect(() => this.total.set(this.items().reduce((s, i) => s + i.price, 0)));Immutability: why push() doesn’t work
A signal decides whether it has changed by comparing the old and the new value with Object.is, that is, by reference. If you modify an array or an object in place, the reference stays the same and nobody is notified. The template may re-render by chance (for example because you clicked something in the component), but the computed()s keep the old value. The result: a partially updated UI — the hardest bug to find.
tags())count = computed(() => tags().length)const tags = signal(['a', 'b']);
// ✗ Mutation: the same array reference → Angular learns NOTHING
tags().push('c');
tags.set(tags()); // still doesn’t help: Object.is(old, new) === true
// ✓ Always a new reference
tags.update((t) => [...t, 'c']); // add
tags.update((t) => t.filter((x) => x !== 'a')); // remove
tags.update((t) => t.map((x) => (x === 'b' ? 'B' : x))); // change
const user = signal({ name: 'Ana', address: { city: 'Cluj' } });
// ✗ user().address.city = 'Iași';
// ✓
user.update((u) => ({ ...u, address: { ...u.address, city: 'Iași' } }));Custom equality with equal
Sometimes you receive a new object that is “the same” logically (for example from an API or a form). With the equal option you avoid needless notifications and recalculations.
import { signal } from '@angular/core';
interface Filters {
query: string;
page: number;
}
// By default: equality via Object.is (reference).
// With `equal` you decide when two values are “the same” → no pointless notifications.
const filters = signal<Filters>(
{ query: '', page: 1 },
{ equal: (a, b) => a.query === b.query && a.page === b.page },
);
filters.set({ query: '', page: 1 }); // new object, but “equal” → NOBODY is notified
// Also for computed:
const ids = computed(() => users().map((u) => u.id), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});{ query: 'angular', page: 1 }signal(filters)1loads of the list
- load #1 · page 1
signal(filters, { equal })1loads of the list
- load #1 · page 1
untracked(): reading without a dependency
Inside a computed or effect, every signal read becomes a dependency. When you only want the current value, without re-running when it changes, use untracked().
import { effect, signal, untracked } from '@angular/core';
const user = signal('Ana');
const theme = signal('dark');
effect(() => {
// Dependency: user — the effect runs again when user changes
const name = user();
// Read WITHOUT a dependency: changing the theme does NOT re-run the effect
const currentTheme = untracked(theme);
console.log(`[analytics] ${name} opened the page (theme: ${currentTheme})`);
});
// untracked also accepts a function — useful when you call code that reads signals internally
effect(() => {
const id = selectedId();
untracked(() => this.logger.log(`Selected ${id}`)); // the logger may read any signal
});const t = theme();runs: 1- Ana opened the page (theme: dark)
const t = untracked(theme);runs: 1- Ana opened the page (theme: dark)
Signals in services: private write, public read
The basic pattern for any shared state: the writable signal is private, and outside you expose asReadonly() and computed()s, while changes go through methods with clear names. Full details on the State management page.
import { Service, computed, signal } from '@angular/core';
@Service()
export class CounterStore {
// Writing is private: only the store changes its state…
private readonly _count = signal(0);
// …and outside you expose only reading.
readonly count = this._count.asReadonly();
readonly isZero = computed(() => this._count() === 0);
increment(): void {
this._count.update((c) => c + 1);
}
}When YES, when NO
- The state of a component (filters, selection, toggles, the current page).
- Shared state in a service / store.
- Any value shown in the template.
- Derived values: totals, filtered lists, flags (
computed). - Component inputs (
input()is a signal too).
- Streams of events over time (websocket, drag, typing with complex operators) — RxJS is more expressive there. See Signals ↔ RxJS.
- Asynchronous data from the server — use resource / httpResource, not a signal set by hand from a
subscribe. - Constants that never change — a plain
readonlyis enough. - “Editable” derived state (a selection that resets) — use linkedSignal.
Common pitfalls
- You forget the parentheses:
{{ count }}shows the function, not the value. Correct:{{ count() }}. - You mutate instead of replacing:
push,splice,obj.x = 1don’t notify. - Side effects in computed: a
computedthat makes an HTTP call or sets another signal is a bug waiting to happen. - Async in computed:
computed(async () => ...)returns a Promise, not the value. Useresource(). - You read only once:
const x = this.count();in the constructor is a snapshot, not a reactive link. - Syncing with effect:
effect(() => b.set(a() * 2))→ usecomputed(orlinkedSignalif b must also be writable).
Cheat sheet
// CREATE
const a = signal(0); // WritableSignal<number>
const b = signal<User | null>(null); // explicit type when you start with null
const c = computed(() => a() * 2); // Signal<number>, read-only, lazy, memoised
// READ
a(); // in TS and in the template: {{ a() }}
// WRITE
a.set(5);
a.update((v) => v + 1);
// SAFE EXPOSURE
readonly value = this._value.asReadonly();
// OPTIONS
signal(x, { equal: (p, n) => p.id === n.id, debugName: 'selected' });
// READ WITHOUT A DEPENDENCY
untracked(a); untracked(() => a() + b());
// CHECKS
isSignal(x); isWritableSignal(x);