Angular Signals: The Complete Guide from Beginner to Advanced
Master Angular Signals from basics to advanced patterns. Learn reactive programming, avoid common pitfalls, and make the right decisions with our comprehensive guide.
- 1. Angular Signals: The Complete Guide from Beginner to Advanced
- 2. Angular Without Zone.js: Signals, New Control Flow & Zoneless Change Detection
- 3. Angular Signal Forms: The In-Depth 'Zero to Hero' Guide
- 4. Angular Resource API: Master resource(), rxResource() & httpResource() from Scratch
What Are Angular Signals?
Angular Signals represent a fundamental shift in how Angular handles reactivity and change detection. Introduced in Angular 16 and becoming stable in Angular 17, Signals provide a fine-grained reactivity system that's simpler, more performant, and more predictable than traditional Zone.js-based change detection.
Why Signals Matter šÆ
- Performance: Fine-grained updates only where data changes
- Simplicity: No more
ChangeDetectorRefgymnastics - Predictability: Clear data flow and dependencies
- Developer Experience: Better TypeScript inference and debugging
- Future-proof: The direction Angular is heading
Beginner: Understanding Signal Basics
Creating Your First Signal
A signal is a wrapper around a value that notifies interested consumers when that value changes.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<button (click)="increment()">Increment</button>
</div>
`
})
export class CounterComponent {
// Creating a signal
count = signal(0);
increment() {
// Updating a signal
this.count.set(this.count() + 1);
// Alternative: update based on current value
// this.count.update(value => value + 1);
}
}
Key Points:
- Call
signal()with an initial value - Read signal value by calling it like a function:
count() - Update with
.set()or.update()
Signal Methods: set() vs update()
export class SignalMethodsComponent {
counter = signal(0);
user = signal({ name: 'John', age: 30 });
// ā
GOOD: Use set() when you have the complete new value
resetCounter() {
this.counter.set(0);
}
// ā
GOOD: Use update() when new value depends on old value
incrementCounter() {
this.counter.update(current => current + 1);
}
// ā BAD: Don't read and set separately (race condition risk)
badIncrement() {
const current = this.counter();
this.counter.set(current + 1); // Not atomic!
}
// ā
GOOD: update() is atomic and safe
updateUserAge() {
this.user.update(u => ({ ...u, age: u.age + 1 }));
}
}
Computed Signals
Computed signals derive their value from other signals and automatically update.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
template: `
<div>
<p>Items: {{ itemCount() }}</p>
<p>Price: {{ itemPrice() }}</p>
<p>Total: {{ total() }}</p>
<p>Status: {{ status() }}</p>
</div>
`
})
export class ShoppingCartComponent {
itemCount = signal(3);
itemPrice = signal(25.99);
// Computed signal - automatically recalculates
total = computed(() => this.itemCount() * this.itemPrice());
// Computed can depend on other computed signals
status = computed(() => {
const t = this.total();
return t > 100 ? 'Premium Order' : 'Standard Order';
});
}
Computed Signal Features:
- ā Automatically tracks dependencies
- ā Memoized (only recalculates when dependencies change)
- ā Lazy evaluation (only runs when read)
- ā Read-only (cannot be set directly)
Effects: Reacting to Signal Changes
Effects run side effects when signals change.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-logger',
template: `<button (click)="increment()">Count: {{ count() }}</button>`
})
export class LoggerComponent {
count = signal(0);
constructor() {
// Effect runs whenever count changes
effect(() => {
console.log(`Count changed to: ${this.count()}`);
// Save to localStorage
localStorage.setItem('count', this.count().toString());
});
}
increment() {
this.count.update(c => c + 1);
}
}
ā ļø Effect Rules:
- Effects run in injection context (constructor, field initializers)
- Don't use effects for state derivation (use
computedinstead) - Effects are for side effects only (logging, storage, API calls)
Intermediate: Advanced Signal Patterns
Signal Inputs (Angular 17.1+)
Replace @Input() with signal-based inputs for better reactivity.
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div>
<h3>{{ fullName() }}</h3>
<p>Age: {{ age() }}</p>
<p>Required: {{ userName() }}</p>
<input [(ngModel)]="email" />
</div>
`
})
export class UserCardComponent {
// Required input
userName = input.required<string>();
// Optional input with default
age = input(18);
// Input with transform
firstName = input('', { transform: (v: string) => v.trim() });
lastName = input('', { transform: (v: string) => v.trim() });
// Two-way binding with model()
email = model<string>('user@example.com');
// Computed based on inputs
fullName = computed(() =>
`${this.firstName()} ${this.lastName()}`.trim()
);
}
Signal Inputs vs Traditional @Input():
| Feature | @Input() | input() |
|---|---|---|
| Type Safety | ā Good | ā Better (stricter) |
| Change Detection | Zone-based | Signal-based |
| Required Inputs | @Input({ required: true }) | input.required<T>() |
| Transform | @Input({ transform: fn }) | input(default, { transform: fn }) |
| Two-way Binding | @Input() + @Output() | model() |
š” tip[!TIP] Signal Forms Integration: Beyond component inputs and outputs, Angular 21+ introduces Signal Forms which expose form controls, groups, and arrays as signals. Learn how to leverage model binding, dynamic control arrays, and complex validation patterns in our Angular Signal Forms: Zero to Hero Guide.
Signal Queries (ViewChild, ContentChild)
import { Component, viewChild, viewChildren, contentChild } from '@angular/core';
import { ElementRef } from '@angular/core';
@Component({
selector: 'app-parent',
template: `
<input #searchInput type="text" />
<div #item class="item">Item 1</div>
<div #item class="item">Item 2</div>
<button (click)="focusSearch()">Focus Search</button>
<ng-content></ng-content>
`
})
export class ParentComponent {
// Single element query (replaces @ViewChild)
searchInput = viewChild<ElementRef>('searchInput');
// Required query
firstItem = viewChild.required<ElementRef>('item');
// Multiple elements (replaces @ViewChildren)
allItems = viewChildren<ElementRef>('item');
// Content query (replaces @ContentChild)
projectedContent = contentChild<ElementRef>('projected');
focusSearch() {
// Signal queries are always defined (or undefined)
this.searchInput()?.nativeElement.focus();
console.log(`Found ${this.allItems().length} items`);
}
}
Working with Arrays and Objects
import { Component, signal } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-todo-list',
template: `
<div *ngFor="let todo of todos()">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)" />
{{ todo.text }}
</div>
<p>Completed: {{ completedCount() }}</p>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([
{ id: 1, text: 'Learn Signals', completed: false },
{ id: 2, text: 'Build App', completed: false }
]);
completedCount = computed(() =>
this.todos().filter(t => t.completed).length
);
// ā
GOOD: Create new array reference
addTodo(text: string) {
this.todos.update(current => [
...current,
{ id: Date.now(), text, completed: false }
]);
}
// ā
GOOD: Immutable update pattern
toggleTodo(id: number) {
this.todos.update(current =>
current.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
// ā BAD: Mutating signal value directly
badToggle(id: number) {
const todos = this.todos();
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed; // Won't trigger updates!
}
}
}
Key Rule: Always create new object/array references when updating signals containing reference types.
Mutating Signals (Angular 17.2+)
For cases where immutability is impractical, use .mutate():
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-large-data',
template: `<p>Items: {{ data().items.length }}</p>`
})
export class LargeDataComponent {
data = signal<{ items: number[], metadata: any }>({
items: Array.from({ length: 10000 }, (_, i) => i),
metadata: {}
});
// ā
Use mutate() for performance with large data structures
addItem(item: number) {
this.data.mutate(d => {
d.items.push(item); // Direct mutation
});
}
// ā Spreading large arrays is expensive
inefficientAdd(item: number) {
this.data.update(d => ({
...d,
items: [...d.items, item] // Creates copy of 10000 items!
}));
}
}
ā ļø Use .mutate() sparingly - it opts out of immutability benefits.
Advanced: Complex Signal Patterns
Signal-based State Management
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
users: User[];
loading: boolean;
error: string | null;
selectedUserId: number | null;
}
@Injectable({ providedIn: 'root' })
export class UserStore {
// Private state signal
private state = signal<UserState>({
users: [],
loading: false,
error: null,
selectedUserId: null
});
// Public computed selectors
users = computed(() => this.state().users);
loading = computed(() => this.state().loading);
error = computed(() => this.state().error);
selectedUser = computed(() => {
const state = this.state();
return state.users.find(u => u.id === state.selectedUserId) ?? null;
});
constructor(private http: HttpClient) {}
// Actions
async loadUsers() {
this.state.update(s => ({ ...s, loading: true, error: null }));
try {
const users = await this.http.get<User[]>('/api/users').toPromise();
this.state.update(s => ({ ...s, users, loading: false }));
} catch (error) {
this.state.update(s => ({
...s,
loading: false,
error: 'Failed to load users'
}));
}
}
selectUser(id: number) {
this.state.update(s => ({ ...s, selectedUserId: id }));
}
updateUser(id: number, updates: Partial<User>) {
this.state.update(s => ({
...s,
users: s.users.map(u => u.id === id ? { ...u, ...updates } : u)
}));
}
}
Signals with RxJS Interop
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';
@Component({
selector: 'app-rxjs-interop',
template: `
<div>
<p>Search: <input [(ngModel)]="searchTerm" /></p>
<p>Results: {{ searchResults() }}</p>
<p>Timer: {{ timer() }}</p>
</div>
`
})
export class RxjsInteropComponent {
searchTerm = signal('');
// Convert signal to observable
searchTerm$ = toObservable(this.searchTerm);
// Use with RxJS operators
searchResults$ = this.searchTerm$.pipe(
map(term => `Searching for: ${term}`)
);
// Convert observable back to signal
searchResults = toSignal(this.searchResults$, { initialValue: '' });
// Observable to signal
timer = toSignal(
interval(1000).pipe(map(n => n)),
{ initialValue: 0 }
);
}
When to Use Each:
Effect Cleanup and Lifecycle
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-websocket',
template: `<p>Messages: {{ messageCount() }}</p>`
})
export class WebSocketComponent {
messageCount = signal(0);
private ws: WebSocket | null = null;
constructor() {
effect((onCleanup) => {
// Setup: runs when effect executes
console.log('Connecting to WebSocket...');
this.ws = new WebSocket('ws://example.com');
this.ws.onmessage = () => {
this.messageCount.update(c => c + 1);
};
// Cleanup: runs before next execution or on destroy
onCleanup(() => {
console.log('Closing WebSocket...');
this.ws?.close();
this.ws = null;
});
});
}
}
Custom Signal Utilities
import { signal, computed, Signal } from '@angular/core';
// Debounced signal
export function debouncedSignal<T>(initialValue: T, delayMs: number) {
const immediate = signal(initialValue);
const debounced = signal(initialValue);
let timeoutId: any;
return {
set: (value: T) => {
immediate.set(value);
clearTimeout(timeoutId);
timeoutId = setTimeout(() => debounced.set(value), delayMs);
},
immediate: immediate.asReadonly(),
debounced: debounced.asReadonly()
};
}
// Async signal
export function asyncSignal<T>(promiseFn: () => Promise<T>, initialValue: T) {
const value = signal(initialValue);
const loading = signal(false);
const error = signal<Error | null>(null);
const load = async () => {
loading.set(true);
error.set(null);
try {
const result = await promiseFn();
value.set(result);
} catch (e) {
error.set(e as Error);
} finally {
loading.set(false);
}
};
return {
value: value.asReadonly(),
loading: loading.asReadonly(),
error: error.asReadonly(),
reload: load
};
}
// Usage
const search = debouncedSignal('', 300);
search.set('Angular'); // Immediate update
console.log(search.debounced()); // Updates after 300ms
const userData = asyncSignal(
() => fetch('/api/user').then(r => r.json()),
null
);
userData.reload();
ā¹ļø note[!NOTE] Angular 19+ Update: While writing custom async signal wrappers like
asyncSignalwas common in Angular 16ā18, Angular 19+ introduces the native Resource API (resource,rxResource, andhttpResource). They manage promise-based and observable-based async loads with native reactive states, caching, and automatic cancellations. Check out our in-depth Angular Resource API Guide to learn how to use these native capabilities!
Common Pitfalls and How to Avoid Them
ā Pitfall 1: Mutating Signal Values Directly
// ā BAD: Direct mutation doesn't trigger updates
const user = signal({ name: 'John', age: 30 });
user().age = 31; // Won't update!
// ā
GOOD: Create new object reference
user.update(u => ({ ...u, age: 31 }));
ā Pitfall 2: Using Effects for Derived State
// ā BAD: Using effect for derived state
const count = signal(0);
const doubled = signal(0);
effect(() => {
doubled.set(count() * 2); // Don't do this!
});
// ā
GOOD: Use computed
const doubled = computed(() => count() * 2);
ā Pitfall 3: Creating Effects Outside Injection Context
// ā BAD: Effect outside constructor
export class MyComponent {
count = signal(0);
ngOnInit() {
effect(() => console.log(this.count())); // Error!
}
}
// ā
GOOD: Effect in constructor or with explicit injector
import { Injector, inject } from '@angular/core';
export class MyComponent {
private injector = inject(Injector);
count = signal(0);
ngOnInit() {
effect(() => console.log(this.count()), {
injector: this.injector
});
}
}
ā Pitfall 4: Forgetting to Call Signal as Function
// ā BAD: Accessing signal without calling it
const count = signal(0);
console.log(count); // Logs the signal object, not the value
// ā
GOOD: Call signal to get value
console.log(count()); // Logs 0
ā Pitfall 5: Over-using Signals
// ā BAD: Signal for static data
export class Component {
config = signal({ apiUrl: 'https://api.example.com' }); // Overkill!
}
// ā
GOOD: Use regular properties for static data
export class Component {
readonly config = { apiUrl: 'https://api.example.com' };
}
ā Pitfall 6: Infinite Effect Loops
// ā BAD: Effect that modifies its own dependencies
const count = signal(0);
effect(() => {
console.log(count());
count.set(count() + 1); // Infinite loop!
});
// ā
GOOD: Effects should only read signals, not modify them
effect(() => {
console.log(count());
// Perform side effects, don't modify dependencies
});
ā Pitfall 7: Not Handling Async Properly
// ā BAD: Async operation in effect
const userId = signal(1);
effect(async () => {
const data = await fetch(`/api/users/${userId()}`);
// Effect doesn't track async dependencies properly
});
// ā
GOOD: Use dedicated pattern
const userId = signal(1);
const userData = signal(null);
effect(() => {
const id = userId(); // Track dependency
fetch(`/api/users/${id}`)
.then(data => userData.set(data));
});
Decision Tree: When to Use What?
Performance Comparison
| Scenario | Traditional | Signals | Performance Gain |
|---|---|---|---|
| Large list updates | Full component check | Targeted updates | 3-5x faster |
| Deeply nested components | Full tree check | Only affected paths | 5-10x faster |
| Computed values | Re-run on every CD | Memoized | 10-100x faster |
| Multiple derived values | Multiple calculations | Cached computed | 2-20x faster |
Migration Strategy
Phase 1: New Features
// Start using signals in new components
export class NewComponent {
// ā
Use signals for new code
count = signal(0);
doubled = computed(() => this.count() * 2);
}
Phase 2: Gradual Refactoring
// Refactor hot paths and performance-critical components
export class ExistingComponent {
// Before: @Input() userId!: number;
// After:
userId = input.required<number>();
// Before: get userName() { ... }
// After:
userName = computed(() => { ... });
}
Phase 3: Full Signal Components
// Eventually: fully signal-based with OnPush
@Component({
selector: 'app-optimized',
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
export class OptimizedComponent {
// All reactive state as signals
}
Phase 4: Migrating Forms (Reactive to Signal Forms)
Forms are the one place where Angular apps accumulate the most boilerplate. If your Reactive Forms code has ever felt like writing legal contracts ā verbose, repetitive, and full of fine print nobody reads ā Signal Forms are the plain-English rewrite.
Let's migrate a User Profile Form that has a nested address group, a dynamic phone numbers array, and auto-save logic. Instead of showing all of "before" and then all of "after", we'll go concept by concept so you can see exactly what changes.
Step 1: Declaring the Form
Think of this as laying out the blueprint for your form. Same data model, radically different syntax.
| Reactive Forms (Before) | Signal Forms (After) |
|---|---|
profileForm!: FormGroup; | No separate type needed! |
Initialized inside ngOnInit() with FormBuilder | Declared inline as a class field |
this.fb.group({ name: ['', Validators.required] }) | signalFormGroup({ name: signalFormControl('', { validators: [validators.required] }) }) |
// ā BEFORE: Reactive Forms ā scattered across constructor + ngOnInit
export class LegacyProfile implements OnInit {
profileForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.profileForm = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
city: ['', Validators.required]
}),
phones: this.fb.array([ this.fb.control('') ])
});
}
}
// ā
AFTER: Signal Forms ā one declarative block, no lifecycle hooks
export class ModernProfile {
profileForm = signalFormGroup({
name: signalFormControl('', { validators: [validators.required] }),
address: signalFormGroup({
city: signalFormControl('', { validators: [validators.required] })
}),
phones: signalFormArray([ signalFormControl('') ])
});
}
What changed? No FormBuilder, no ngOnInit, no FormGroup type annotation. The form is a single, self-describing class field. If you love the signal() basics, this should feel natural.
Step 2: Template Bindings
Here's where the side-by-side difference is most dramatic. Reactive Forms use string-based directives; Signal Forms bind directly to typed references.
| Reactive Forms (Before) | Signal Forms (After) |
|---|---|
formControlName="name" | [formField]="profileForm.controls.name" |
formGroupName="address" | [formGroup]="profileForm.controls.address" |
formArrayName="phones" | Iterate over phonesArray.controls() signal |
[disabled]="profileForm.invalid" (property) | [disabled]="profileForm.invalid()" (signal call) |
<!-- ā BEFORE: String-based binding ā typo "naem" won't error until runtime -->
<form [formGroup]="profileForm">
<input formControlName="name" />
<div formGroupName="address">
<input formControlName="city" />
</div>
<button [disabled]="profileForm.invalid">Submit</button>
</form>
<!-- ā
AFTER: Direct reference binding ā typo caught at compile time -->
<form [formGroup]="profileForm">
<input [formField]="profileForm.controls.name" />
<div [formGroup]="profileForm.controls.address">
<input [formField]="profileForm.controls.address.controls.city" />
</div>
<button [disabled]="profileForm.invalid()">Submit</button>
</form>
The killer benefit? Try typing profileForm.controls.naem ā TypeScript will scream at you before you even save the file. Reactive Forms would happily compile and fail silently at runtime.
Step 3: Dynamic Arrays
This is where Signal Forms truly shine. Reactive Forms required a getter hack to access the array, plus *ngFor with manual index tracking. Signal Forms expose the array entries as a signal, and Angular's @for block handles the rest.
| Reactive Forms (Before) | Signal Forms (After) |
|---|---|
get phones() { return this.profileForm.get('phones') as FormArray; } | get phonesArray() { return this.profileForm.controls.phones; } |
*ngFor="let ctrl of phones.controls; let i = index" | @for (ctrl of phonesArray.controls(); track ctrl; let i = $index) |
[formControlName]="i" | [formField]="ctrl" |
// ā BEFORE: The infamous "cast getter" pattern
get phones() {
return this.profileForm.get('phones') as FormArray; // No type safety!
}
addPhone() {
this.phones.push(this.fb.control(''));
}
// ā
AFTER: Type-safe, no casting needed
get phonesArray() {
return this.profileForm.controls.phones; // Already typed as signalFormArray!
}
addPhone() {
this.phonesArray.push(signalFormControl(''));
}
Step 4: Change Tracking and Auto-Save
This is the single biggest reduction in boilerplate. In Reactive Forms, tracking changes requires Observable subscriptions, teardown management, and lifecycle hooks. With Signal Forms, it's an effect() ā one-liner, auto-cleaned-up, done.
| Reactive Forms (Before) | Signal Forms (After) |
|---|---|
valueChanges.pipe(debounceTime(500), takeUntil(destroy$)).subscribe(...) | effect(() => { const val = profileForm.value(); autoSave(val); }) |
Requires Subject + ngOnDestroy cleanup | Automatic cleanup when component is destroyed |
| 8+ lines of RxJS plumbing | 3 lines inside the constructor |
// ā BEFORE: The RxJS ceremony
private destroy$ = new Subject<void>();
ngOnInit() {
this.profileForm.valueChanges.pipe(
debounceTime(500),
takeUntil(this.destroy$)
).subscribe(val => this.autoSave(val));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
// ā
AFTER: Just an effect, that's it
constructor() {
effect(() => {
const val = this.profileForm.value(); // Reactive tracking!
this.autoSave(val);
});
// No ngOnDestroy needed ā Angular handles cleanup automatically
}
If you're not familiar with how effect() cleanup works under the hood, see the Effect Cleanup and Lifecycle section.
Step 5: Checking Validity
One subtle but important difference: Reactive Forms expose status as plain properties, while Signal Forms expose them as signal getters. Don't forget the ().
| Operation | Reactive Forms | Signal Forms |
|---|---|---|
| Check validity | form.valid | form.valid() |
| Check if touched | control.touched | control.touched() |
| Get errors | control.errors | control.errors() |
| Derive state | Manual getter: get isReady() { ... } | isReady = computed(() => form.valid() && !form.pending()) |
The signal-based approach plays beautifully with computed signals ā derived form states are automatically memoized and only recalculate when something actually changes.
Complete Migration Cheat Sheet
| Feature | Reactive Forms (RxJS) | Signal Forms (Signals) |
|---|---|---|
| Import | FormControl, FormGroup, FormArray | signalFormControl, signalFormGroup, signalFormArray |
| Template Binding | formControlName="x" | [formField]="ctrl" |
| Read Value | ctrl.value (property) | ctrl.value() (signal) |
| Track Changes | ctrl.valueChanges.subscribe(...) | effect(() => ctrl.value()) |
| Validation Status | group.valid / group.invalid | group.valid() / group.invalid() |
| Nested Access | group.get('address.city') (stringly-typed) | group.controls.address.controls.city (type-safe) |
| Array Iteration | *ngFor="let c of arr.controls" | @for (c of arr.controls(); track c) |
| Cleanup | ngOnDestroy + Subject | Automatic |
For the full deep-dive into Signal Forms including custom validators, async validation, and a complete registration form, check out the Angular Signal Forms: Zero to Hero Guide.
Best Practices Checklist
ā DO:
- Use
signal()for mutable state - Use
computed()for derived values - Use
effect()only for side effects - Keep signals immutable (create new references)
- Use
input()for component inputs - Use
model()for two-way binding - Create effects in injection context
- Use descriptive signal names
ā DON'T:
- Mutate signal values directly
- Use effects for derived state
- Create infinite effect loops
- Forget to call signals as functions
- Overuse signals for static data
- Modify signals inside computed
- Create effects outside injection context
Real-World Example: Todo Application
import { Component, signal, computed, effect } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
type Filter = 'all' | 'active' | 'completed';
@Component({
selector: 'app-todo',
template: `
<div class="todo-app">
<input
#newTodo
(keyup.enter)="addTodo(newTodo.value); newTodo.value = ''"
placeholder="What needs to be done?" />
<select [(ngModel)]="filter">
<option value="all">All ({{ todos().length }})</option>
<option value="active">Active ({{ activeCount() }})</option>
<option value="completed">Completed ({{ completedCount() }})</option>
</select>
<ul>
<li *ngFor="let todo of filteredTodos()"
[class.completed]="todo.completed"
[class.priority-high]="todo.priority === 'high'">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)" />
<span>{{ todo.text }}</span>
<button (click)="removeTodo(todo.id)">Delete</button>
</li>
</ul>
<div class="stats">
<p>Progress: {{ progressPercent() }}%</p>
<p>High Priority: {{ highPriorityCount() }}</p>
</div>
</div>
`
})
export class TodoAppComponent {
// State
todos = signal<Todo[]>(this.loadFromStorage());
filter = signal<Filter>('all');
// Computed values
activeCount = computed(() =>
this.todos().filter(t => !t.completed).length
);
completedCount = computed(() =>
this.todos().filter(t => t.completed).length
);
filteredTodos = computed(() => {
const filter = this.filter();
const todos = this.todos();
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
progressPercent = computed(() => {
const total = this.todos().length;
if (total === 0) return 0;
return Math.round((this.completedCount() / total) * 100);
});
highPriorityCount = computed(() =>
this.todos().filter(t => !t.completed && t.priority === 'high').length
);
constructor() {
// Side effect: persist to localStorage
effect(() => {
const todos = this.todos();
localStorage.setItem('todos', JSON.stringify(todos));
console.log(`Saved ${todos.length} todos`);
});
}
// Actions
addTodo(text: string) {
if (!text.trim()) return;
this.todos.update(current => [
...current,
{
id: Date.now(),
text: text.trim(),
completed: false,
priority: 'medium'
}
]);
}
toggleTodo(id: number) {
this.todos.update(current =>
current.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
removeTodo(id: number) {
this.todos.update(current => current.filter(t => t.id !== id));
}
private loadFromStorage(): Todo[] {
try {
return JSON.parse(localStorage.getItem('todos') || '[]');
} catch {
return [];
}
}
}
Conclusion
Angular Signals represent the future of reactive programming in Angular. They offer:
š Better Performance - Fine-grained reactivity with minimal overhead
šÆ Improved DX - Simpler APIs, better TypeScript support
š¦ Smaller Bundles - Less framework code needed
š® Future-proof - Foundation for Angular's evolution
Next Steps
- Start Small: Use signals in new components
- Learn Patterns: Master computed and effect
- Refactor Gradually: Migrate performance-critical code first
- Explore the Resource API: Replace manual HTTP + loading state plumbing with resource(), rxResource(), and httpResource()
- Migrate Your Forms: Move from Reactive Forms to type-safe, signal-powered forms with our Signal Forms Zero to Hero Guide
Resources
Happy coding with Signals! š