
Introduction
Signals shipped in Angular 16 as a developer preview and have since become the core of Angular's reactivity: effect(), linkedSignal() and toObservable() went stable in Angular 20, and new projects from Angular CLI 21 are zoneless by default. Three primitives: signal() for writable state, computed() for derived values, effect() for side effects. That is the core API surface. The examples target Angular 20 and later and were type-checked against Angular 22 (current as of October 2026) with strict templates. Components are standalone by default since Angular 19, so none of them set standalone: true.
The old model was zone.js intercepting every async event and triggering a full change detection sweep. Every setTimeout, every HTTP callback, every mouse move — Angular would re-check the entire component tree looking for what changed. You could bolt on OnPush and scatter ChangeDetectorRef.markForCheck() through your codebase, but the framework was still guessing. Signals end the guessing. Angular tracks exactly which template binding reads which signal, and when a signal changes, only those bindings update. Nothing else gets touched.
Signals are synchronous. Always hold a current value. You read by calling the signal as a function — no subscriptions, no timing issues where an observable emits before anything is listening, no two streams racing to produce stale UI. That alone kills a whole class of bugs that plagued RxJS-heavy Angular code. And if you have used SolidJS or Vue's reactivity system, this will feel familiar.
Creating and Reading Signals
signal(0) creates it. count() reads it. .set() replaces the value, .update() derives the new value from the old one. For objects and arrays, always return a new reference — Angular checks reference equality, so mutating in place won't trigger updates.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div class="counter">
<h2>Count: {{ count() }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Create a writable signal with an initial value of 0
count = signal(0);
increment() {
// update() receives the current value and returns the new one
this.count.update(current => current + 1);
}
decrement() {
this.count.update(current => current - 1);
}
reset() {
// set() replaces the value directly
this.count.set(0);
}
}Computed Signals
Computed signals are where things get interesting. A computed() takes a function that reads other signals and returns a derived value. Angular tracks which signals the function reads, and only re-runs it when those specific dependencies change. Lazy and cached — read it ten times in a row with no dependency changes, and the derivation function runs once. You can also chain them: a computed signal can depend on other computed signals, and Angular figures out the correct recalculation order automatically. No more stale derived properties calculated in ngOnChanges in the wrong sequence. No more manual bookkeeping. The shopping cart below has subtotal feeding into tax feeding into total, and you never think about update ordering.
import { Component, signal, computed } from '@angular/core';
interface CartItem {
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-shopping-cart',
template: `
<div class="cart">
<h2>Shopping Cart</h2>
@for (item of items(); track item.name) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} x \${{ item.price }}</span>
</div>
}
<hr />
<p>Items: {{ totalItems() }}</p>
<p>Subtotal: \${{ subtotal().toFixed(2) }}</p>
<p>Tax (8%): \${{ tax().toFixed(2) }}</p>
<p><strong>Total: \${{ total().toFixed(2) }}</strong></p>
</div>
`
})
export class ShoppingCartComponent {
items = signal<CartItem[]>([
{ name: 'TypeScript Handbook', price: 29.99, quantity: 1 },
{ name: 'Angular Stickers', price: 4.99, quantity: 3 },
]);
// Computed signals derive from other signals automatically
totalItems = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);
subtotal = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// Computed signals can depend on other computed signals
tax = computed(() => this.subtotal() * 0.08);
total = computed(() => this.subtotal() + this.tax());
addItem(item: CartItem) {
this.items.update(current => [...current, item]);
}
}
The \$ before each price is there because the template is a TypeScript template literal: an unescaped ${ would start a TypeScript interpolation instead of printing a dollar sign.
Computed signals are read-only. No .set(), no .update(). If you feel the urge to directly override a computed value, your state model needs rethinking.
Effects and Side Effects
Effects run code that talks to the outside world when signals change. Logging. localStorage. Analytics. Avoid using them to update other signals. Since Angular 19 an effect is allowed to write signals by default (the old allowSignalWrites option is deprecated), but copying state from one signal to another in an effect is an easy way to get circular updates and extra change detection passes. Use computed() for derived values and linkedSignal() for values that are derived but can also be set by hand.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-theme-manager',
template: `
<div>
<p>Current theme: {{ theme() }}</p>
<button (click)="toggleTheme()">Toggle Theme</button>
<label>
Font size: {{ fontSize() }}px
<input
type="range"
min="12"
max="24"
[value]="fontSize()"
(input)="onFontSize($event)"
/>
</label>
</div>
`
})
export class ThemeManagerComponent {
theme = signal<'light' | 'dark'>('light');
fontSize = signal(16);
constructor() {
// This effect runs whenever theme() or fontSize() changes
effect(() => {
const currentTheme = this.theme();
const currentFontSize = this.fontSize();
// Side effect: update the DOM directly
document.documentElement.setAttribute('data-theme', currentTheme);
document.documentElement.style.fontSize = `${currentFontSize}px`;
// Side effect: persist to localStorage
localStorage.setItem('user-preferences', JSON.stringify({
theme: currentTheme,
fontSize: currentFontSize
}));
console.log(`Preferences saved: ${currentTheme}, ${currentFontSize}px`);
});
}
// A typed handler works on any element and keeps templates simple
onFontSize(event: Event) {
this.fontSize.set(+(event.target as HTMLInputElement).value);
}
toggleTheme() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}No dependency array. No stale closures. The effect reads theme() and fontSize(), so it re-runs when either changes. Angular figures out the tracking automatically. If you have ever wasted an afternoon on a missing entry in React's useEffect dependency array, you know why this matters.
But here is the gotcha: cleanup. Effects are tied to their injection context's lifecycle, so they clean up when the component is destroyed. But what about cancelling a timer or tearing down a subscription mid-lifecycle? You need onCleanup:
effect((onCleanup) => {
const searchTerm = this.searchQuery();
// Set up a debounced API call
const timeoutId = setTimeout(() => {
this.searchService.search(searchTerm);
}, 300);
// Clean up the previous timeout if the signal changes again
onCleanup(() => {
clearTimeout(timeoutId);
});
});The cleanup runs right before each re-execution and once more when the effect is destroyed. Miss this and you get timers stacking up, duplicate event listeners, memory leaks that only show up after navigating between routes a few dozen times. Every time.
Signals in Components
input() replaces @Input() — read-only signal, value from parent. model() creates two-way binding in one declaration instead of the old @Input() plus @Output() pair. output() emits events without EventEmitter.
All three working together:
import {
Component, input, output, model, computed, signal
} from '@angular/core';
@Component({
selector: 'app-profile-editor',
template: `
<div class="profile-editor">
<h3>Edit Profile for {{ userId() }}</h3>
<label>
Display Name
<input
[value]="displayName()"
(input)="onNameInput($event)"
/>
</label>
<p>Characters remaining: {{ charsRemaining() }}</p>
<label>
<input
type="checkbox"
[checked]="isPublic()"
(change)="onPublicChange($event)"
/>
Make profile public
</label>
<div class="actions">
<button
(click)="save.emit({ name: displayName(), public: isPublic() })"
[disabled]="!isValid()"
>
Save Changes
</button>
</div>
</div>
`
})
export class ProfileEditorComponent {
// Read-only input signal from the parent
userId = input.required<string>();
// Two-way bindable model signal
displayName = model('');
isPublic = model(false);
// Output signal for emitting events
save = output<{ name: string; public: boolean }>();
// Computed signals that react to model changes
charsRemaining = computed(() => 50 - this.displayName().length);
isValid = computed(() =>
this.displayName().trim().length >= 2 &&
this.displayName().length <= 50
);
// Typed handlers: templates cannot cast $event.target themselves
onNameInput(event: Event) {
this.displayName.set((event.target as HTMLInputElement).value);
}
onPublicChange(event: Event) {
this.isPublic.set((event.target as HTMLInputElement).checked);
}
}And the parent side:
<!-- In the parent component template -->
<app-profile-editor
[userId]="currentUserId()"
[(displayName)]="userName"
[(isPublic)]="profileVisibility"
(save)="handleSave($event)"
/>The template calls small typed handlers instead of $event.target.value. Current Angular versions can narrow $event.target for native events on a plain <input>, but on other elements (a wrapper <div>, a custom component) it is EventTarget | null, and with strictTemplates (the CLI default) .value fails to compile. Templates have no cast syntax, so a handler that takes an Event and casts in TypeScript works everywhere.
No glue code. One declaration instead of the old @Input() value plus @Output() valueChange = new EventEmitter() dance.
Signals vs RxJS: When to Use Each
Most RxJS usage in Angular apps is wrong. Not "suboptimal." Wrong. A BehaviorSubject holding a single boolean is not reactive programming — it is a variable with extra steps. A pipe(map(...)) chain that derives one string from one number is not a stream — it is arithmetic wrapped in ceremony. Signals replace all of that, and the code gets shorter and easier to reason about.
RxJS still wins for actual async work. HTTP requests, WebSocket streams, debounced input, race conditions, retry logic. Observables are built for sequences of values over time. But in many Angular codebases, a lot of the RxJS handles synchronous state that never needed observables in the first place.
Angular gives you interop functions so you do not have to pick one or the other:
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { of, switchMap, debounceTime, distinctUntilChanged } from 'rxjs';
interface User {
id: number;
name: string;
}
@Component({
selector: 'app-user-search',
template: `
<input
[value]="query()"
(input)="onQuery($event)"
placeholder="Search users..."
/>
@if (results(); as users) {
@for (user of users; track user.id) {
<div class="user-card">{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UserSearchComponent {
private http = inject(HttpClient);
// Source of truth is a signal
query = signal('');
// Convert signal to observable for async operations
private results$ = toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q =>
q.length > 1
? this.http.get<User[]>(`/api/users?search=${q}`)
: of([])
)
);
// Convert back to a signal. No initialValue, so it is undefined until the
// first response arrives and the template shows the loading branch
results = toSignal(this.results$);
onQuery(event: Event) {
this.query.set((event.target as HTMLInputElement).value);
}
}Signal for the query, observable pipeline for the async search logic, signal again for the template. Leaving out initialValue matters: with initialValue: [] the signal would start as an empty array, which is truthy, and the Loading... branch could never render. Each tool handling what it is actually good at. Or you could do the whole thing in RxJS and manually unsubscribe in ngOnDestroy. Your call.
Start with a signal. If you need more than a couple of RxJS operators chained together, move that logic into an observable pipeline and convert the final result back with toSignal(). Most of what you delete during this kind of migration turns out to be subscription management boilerplate. The actual business logic barely changes.
Real-World State Management with Signals
NgRx is overkill for most applications. Controversial? Maybe. But it is easy to spend weeks setting up actions, reducers, effects, and selectors for state that could have lived in a service with three signals and two methods. The ceremony-to-value ratio is brutal for anything under enterprise scale.
Private writable signals for state. .asReadonly() or computed signals for public access. Explicit methods for state transitions. Components read but never directly mutate.
import { Injectable, signal, computed } from '@angular/core';
interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
type FilterType = 'all' | 'active' | 'completed';
@Injectable({ providedIn: 'root' })
export class TodoStore {
// Private writable state
private todosState = signal<Todo[]>([]);
private filterState = signal<FilterType>('all');
private nextId = signal(1);
// Public read-only access
todos = this.todosState.asReadonly();
filter = this.filterState.asReadonly();
// Derived state with computed signals
filteredTodos = computed(() => {
const todos = this.todosState();
const filter = this.filterState();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
activeCount = computed(() =>
this.todosState().filter(t => !t.completed).length
);
completedCount = computed(() =>
this.todosState().filter(t => t.completed).length
);
allCompleted = computed(() =>
this.todosState().length > 0 &&
this.todosState().every(t => t.completed)
);
// State transition methods
addTodo(title: string) {
const id = this.nextId();
this.nextId.update(n => n + 1);
this.todosState.update(todos => [
...todos,
{ id, title: title.trim(), completed: false, createdAt: new Date() }
]);
}
toggleTodo(id: number) {
this.todosState.update(todos =>
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}
removeTodo(id: number) {
this.todosState.update(todos => todos.filter(t => t.id !== id));
}
setFilter(filter: FilterType) {
this.filterState.set(filter);
}
clearCompleted() {
this.todosState.update(todos => todos.filter(t => !t.completed));
}
}
Components that need this state simply inject the service and read from its public signals. The component for the todo list might look like this:
import { Component, inject } from '@angular/core';
import { TodoStore } from './todo-store.service';
@Component({
selector: 'app-todo-list',
template: `
<input placeholder="What needs doing?" (keyup.enter)="add($event)" />
@for (todo of store.filteredTodos(); track todo.id) {
<label>
<input type="checkbox" [checked]="todo.completed" (change)="store.toggleTodo(todo.id)" />
{{ todo.title }}
</label>
<button (click)="store.removeTodo(todo.id)">Delete</button>
} @empty {
<p>Nothing here.</p>
}
<p>{{ store.activeCount() }} left</p>
<button (click)="store.setFilter('all')">All</button>
<button (click)="store.setFilter('active')">Active</button>
<button (click)="store.setFilter('completed')">Completed</button>
`
})
export class TodoListComponent {
// The store's public signals are read directly in the template
protected store = inject(TodoStore);
add(event: Event) {
const input = event.target as HTMLInputElement;
if (input.value.trim()) {
this.store.addTodo(input.value);
input.value = '';
}
}
}Testing is trivially easy. Signals are synchronous, so you call a method and immediately assert the new state. No subscriptions, no async resolution, no fakeAsync wrappers. Just call and check.
This pattern scales further than people give it credit for. Predictable transitions, derived state that stays in sync, clean read/write separation. But keep state flat. Deeply nested objects need careful handling because Angular checks reference equality — mutating a nested property without producing a new top-level reference will not trigger updates. And keep writable signals private. Expose only read-only projections so components cannot bypass your transition methods. If a component can call .set() directly on your store's internal state, your "store" is just a global variable with a fancy name.
Split stores by feature. A CartStore owns shopping cart items. A UserStore owns authentication and profile data. A UIStore owns sidebar state and notification preferences. Small, focused, testable in isolation. Components inject what they need.
For larger apps with genuinely complex cross-cutting concerns — undo/redo, devtools integration, plugin architectures — NgRx SignalStore builds on these same primitives. But reach for it when you actually need it, not because "state management" sounds like it requires a library.