Angular signals
Angular Signals introduce a fine‑grained reactivity model that lets Angular know exactly which parts of your UI depend on which pieces of state. Instead of re‑running entire components or trees when “something changed”, Signals make updates targeted, predictable, and easier to reason about.
This article walks through Signals step‑by‑step, then ties everything together with a realistic example: a small “team tasks dashboard” that tracks users, filters, and derived counts using Signals.
What are Angular Signals?
A signal is a wrapper around a value (primitive or object) that can notify Angular whenever that value changes.
You read a signal by calling it like a function:
count().You update a writable signal using
set()orupdate().Angular tracks where signals are read so it can update only the affected consumers.
Conceptually:
tsconst count = signal(0);
console.log(count()); // read -> 0
count.set(1); // write
console.log(count()); // read -> 1
Whenever count() is read inside a reactive context (for example, a template, computed, or effect), Angular remembers that relationship and re‑runs only the necessary code when count changes.
Writable Signals: Your Source of Truth
Writable signals are your mutable state containers. You create them with signal(initialValue) and then mutate them using set and update.
tsimport { signal, WritableSignal } from '@angular/core';
const count: WritableSignal<number> = signal(0);
// Read
console.log('Count is', count());
// Write directly
count.set(3);
// Write based on previous value
count.update(value => value + 1);
Real‑time example: Team filter state
Imagine a small task dashboard where you show tasks assigned to a selected team member:
tsinterface User {
id: number;
name: string;
}
interface Task {
id: number;
title: string;
assignedTo: number; // user id
completed: boolean;
}
const users = signal<User[]>([
{ id: 1, name: 'Elmo' },
{ id: 2, name: 'Arya' },
]);
const tasks = signal<Task[]>([
{ id: 1, title: 'Fix login bug', assignedTo: 1, completed: false },
{ id: 2, title: 'Write docs', assignedTo: 1, completed: true },
{ id: 3, title: 'Refactor API', assignedTo: 2, completed: false },
]);
const selectedUserId = signal<number | null>(1);
Here:
usersandtasksare writable signals holding arrays.selectedUserIdis a writable signal representing which user is currently “active” in the UI.
Updating the selection is trivial:
tsfunction selectUser(id: number | null) {
selectedUserId.set(id);
}
Any part of the app that reads selectedUserId() in a reactive context will automatically update.
Computed Signals: Derived, Read‑Only State
Computed signals are read‑only values derived from other signals. You define them using computed(() => ...).
tsimport { computed, Signal } from '@angular/core';
const count = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
doubleCount()always returnscount() * 2.When
countchanges, Angular invalidates the cached value ofdoubleCount.The derivation is lazy and memoized: it only runs when someone reads
doubleCount().
You cannot write to a computed signal:
tsdoubleCount.set(3); // ❌ compile-time error
Example: Selected user and task stats
Continuing our dashboard:
tsconst selectedUser = computed<User | null>(() => {
const id = selectedUserId();
return id == null ? null : users().find(u => u.id === id) ?? null;
});
const tasksForSelectedUser = computed<Task[]>(() => {
const id = selectedUserId();
if (id == null) return [];
return tasks().filter(task => task.assignedTo === id);
});
const completedCount = computed<number>(() =>
tasksForSelectedUser().filter(t => t.completed).length
);
const pendingCount = computed<number>(() =>
tasksForSelectedUser().filter(t => !t.completed).length
);
Here:
All four computed signals depend on lower‑level writable signals (
users,tasks,selectedUserId).If
selectedUserIdchanges, Angular knows it must recomputeselectedUser,tasksForSelectedUser,completedCount, andpendingCount.If you add or update tasks, the counts update automatically.
Because computations are memoized, tasks().filter(...) only re‑runs when tasks or selectedUserId actually change, not on every render.
Dynamic Dependencies: Branching Derivations
Computed signals only track signals that are actually read during a derivation. That means dependencies can change based on conditions.
tsconst showCount = signal(false);
const count = signal(0);
const conditionalCount = computed(() => {
if (showCount()) {
return `The count is ${count()}.`;
} else {
return 'Nothing to see here!';
}
});
Behavior:
If
showCount()isfalsewhenconditionalCount()runs, it does not readcount(), socountis not tracked as a dependency.Changing
countwill not trigger a recomputation untilshowCountbecomestrueand thecount()branch is actually executed.If
showCountflips back tofalse,countis again dropped as a dependency.
This makes complex UIs more efficient: inactive branches simply don’t participate in reactivity.
Reactive Contexts: Where Angular Tracks Reads
A reactive context is a runtime situation where Angular watches which signals are being read. In those contexts, reads create dependencies.
Angular enters a reactive context when it:
Executes an
effectorafterRenderEffectcallback.Evaluates a
computedsignal.Evaluates a
linkedSignalorresourceparams/loader.Renders a component template (including host bindings).
Inside those contexts:
The consumer is the running function or template.
The producer is any signal you call.
Angular wires them together so that producer changes re‑run the consumer.
Example: Component using signals
ts@Component({
selector: 'app-team-dashboard',
standalone: true,
template: `
<section *ngIf="selectedUser(); else noUser">
<h2>{{ selectedUser()?.name }}’s Tasks</h2>
<p>
Completed: {{ completedCount() }} |
Pending: {{ pendingCount() }}
</p>
<ul>
<li *ngFor="let task of tasksForSelectedUser()">
<label>
<input
type="checkbox"
[checked]="task.completed"
(change)="toggleCompleted(task.id)"
/>
{{ task.title }}
</label>
</li>
</ul>
</section>
<ng-template #noUser>
<p>Select a user to see their tasks.</p>
</ng-template>
<hr />
<button *ngFor="let user of users()"
(click)="selectUser(user.id)">
{{ user.name }}
</button>
<button (click)="selectUser(null)">Clear selection</button>
`,
})
export class TeamDashboardComponent {
users = users;
selectedUser = selectedUser;
tasksForSelectedUser = tasksForSelectedUser;
completedCount = completedCount;
pendingCount = pendingCount;
selectUser = selectUser;
toggleCompleted(taskId: number) {
tasks.update(current =>
current.map(task =>
task.id === taskId
? { ...task, completed: !task.completed }
: task,
),
);
}
}
Here, the template is a reactive context:
Every
signal()call inside the bindings (selectedUser(),tasksForSelectedUser(), etc.) is tracked.When
selectedUserIdortaskschange, Angular only re‑renders this component, and only the bindings that depend on those signals are recomputed.
Asserting “Not in a Reactive Context”
Sometimes you want to be sure certain logic is not running inside a reactive context—for example, to avoid subtle bugs when mixing subscriptions or manual state management.
Angular provides assertNotInReactiveContext:
tsimport { assertNotInReactiveContext } from '@angular/core';
function subscribeToEvents() {
assertNotInReactiveContext(subscribeToEvents);
// Safe to run subscription logic here
externalEventSource.subscribe(...);
}
If subscribeToEvents is accidentally called from inside a reactive context, Angular throws a clear, targeted error (pointing to subscribeToEvents) instead of a vague reactive context error.
Use this guard when:
You set up long‑lived subscriptions.
You integrate with libraries that manage their own lifecycles.
You know the call must be “one‑off” and not re‑run automatically.
Reading Without Tracking: untracked
Sometimes you want to peek at a signal inside a reactive context without creating a dependency. Angular’s untracked helper does exactly that.
Example: Logging incidental information
Suppose you have:
tsconst currentUser = signal<User | null>(null);
const counter = signal(0);
Naïve logging with an effect:
tseffect(() => {
console.log(`User set to ${currentUser()} and counter is ${counter()}`);
});
This effect re‑runs when either currentUser or counter changes. But what if you only want it to re‑run when currentUser changes, and just show the current counter value at that time?
tsimport { effect, untracked } from '@angular/core';
effect(() => {
console.log(
`User set to ${currentUser()} and the counter is ${untracked(counter)}`
);
});
Now:
currentUser()is tracked as a dependency.untracked(counter)reads the signal but doesn’t register it as a dependency.
Updating counter alone will not re‑run the effect.
Example: Calling external services without linking their reads
tseffect(() => {
const user = currentUser();
untracked(() => {
// Even if loggingService internally reads signals,
// they won’t become dependencies of this effect.
this.loggingService.log(`User set to ${user?.name ?? 'anonymous'}`);
});
});
untracked(() => { ... }) temporarily disables dependency tracking for everything executed inside the callback.
Putting It Together: A Mini “Live Team Dashboard”
Let’s combine everything into a more cohesive real‑world‑style example.
Requirements
Show a list of team members.
Show each selected member’s tasks, with completed/pending stats.
Log when a user changes, including a snapshot of counter state.
Avoid unneeded recalculations and effects.
State and Signals
tsimport {
signal,
WritableSignal,
computed,
effect,
untracked,
assertNotInReactiveContext,
} from '@angular/core';
interface User {
id: number;
name: string;
}
interface Task {
id: number;
title: string;
assignedTo: number;
completed: boolean;
}
const users = signal<User[]>([
{ id: 1, name: 'Elmo' },
{ id: 2, name: 'Arya' },
]);
const tasks = signal<Task[]>([
{ id: 1, title: 'Fix login bug', assignedTo: 1, completed: false },
{ id: 2, title: 'Write docs', assignedTo: 1, completed: true },
{ id: 3, title: 'Refactor API', assignedTo: 2, completed: false },
]);
const selectedUserId: WritableSignal<number | null> = signal(1);
const counter = signal(0);
Derived state
tsconst selectedUser = computed<User | null>(() => {
const id = selectedUserId();
return id == null ? null : users().find(u => u.id === id) ?? null;
});
const tasksForSelectedUser = computed<Task[]>(() => {
const id = selectedUserId();
if (id == null) return [];
return tasks().filter(task => task.assignedTo === id);
});
const completedCount = computed<number>(() =>
tasksForSelectedUser().filter(t => t.completed).length
);
const pendingCount = computed<number>(() =>
tasksForSelectedUser().filter(t => !t.completed).length
);
Side effect: log when currentUser changes
tsconst currentUser = selectedUser;
effect(() => {
const user = currentUser(); // tracked dependency
const currentCounter = untracked(counter); // incidental read
console.log(
user
? `Switched to ${user.name}, counter snapshot: ${currentCounter}`
: `No user selected, counter snapshot: ${currentCounter}`
);
});
This effect:
Re‑runs only when
selectedUserIdchanges (becausecurrentUser()is derived from it).Always uses the latest
countervalue, but changes tocounteralone don’t trigger logs.
Safe external subscription
Suppose you have a function that wires up WebSocket messages. You want to ensure it’s never called from a reactive context:
tsfunction initWebsocketConnection() {
assertNotInReactiveContext(initWebsocketConnection);
// Connect once at app bootstrap, not during effects/templates
this.socket.connect();
}
Call this from a non‑reactive place (e.g., main bootstrap or constructor of a root service).
When to Reach for Signals in Your App
Angular Signals shine when:
You need fine‑grained performance: dashboards, trading UIs, live metrics, or complex forms.
You want a predictable reactive model: no hidden async zones, clear dependencies.
You’re refactoring from RxJS‑heavy code and want smaller, easier‑to‑read pieces of state.
Use:
Writable signals for local component or service state.
Computed signals for anything derived: filters, counts, views of data.
Effects for logging, network calls, and integration with non‑reactive APIs.
untrackedwhen you need incidental reads that should not drive re‑runs.assertNotInReactiveContextto guard code that must never be executed reactively.
By structuring your state as Signals and derivations, you get a clear mental model:
Sources → Derived values → Effects/UI, with Angular handling the wiring and re‑execution automatically.