Module Overview
Estimated Time: 3-4 hours | Difficulty: Intermediate | Prerequisites: Module 4
- Creating and reading signals
- Computed signals for derived state
- Effects for side effects
- Signal patterns and best practices
- Comparison with RxJS observables
- Real-world signal architectures
What are Signals?
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Signals: Reactive Primitives │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Signal: Wrapper around value that notifies when value changes │
│ Computed: Signal derived from other signals, cached automatically │
│ Effect: Side effect that runs when signals change │
│ │
│ ┌───────────────┐ │
│ │ signal(10) │──────────────┐ │
│ └───────────────┘ ▼ │
│ ┌────────────────┐ │
│ ┌───────────────┐ │ │ │
│ │ signal(20) │──────▶│ computed() │──────▶ Template │
│ └───────────────┘ │ (cached) │ │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ effect() │ ─────▶ Side effects │
│ │ (logging, API) │ │
│ └────────────────┘ │
│ │
│ Key Benefits: │
│ ✓ Fine-grained reactivity (knows what changed) │
│ ✓ Synchronous updates (predictable timing) │
│ ✓ No subscription management needed │
│ ✓ Automatic dependency tracking │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Creating Signals
WritableSignal
Copy
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
</div>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
// Create a writable signal with initial value
count = signal(0);
increment() {
// set(): Replace the value
this.count.set(this.count() + 1);
// OR use update(): Transform based on current value
// this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => Math.max(0, value - 1));
}
reset() {
this.count.set(0);
}
}
Signal Methods
Copy
// Create a signal
const name = signal('Alice');
// Read the value (call the signal)
console.log(name()); // 'Alice'
// Set a new value
name.set('Bob');
// Update based on current value
name.update(current => current.toUpperCase());
// Create read-only version (for public APIs)
const readonlyName = name.asReadonly();
// readonlyName.set('X'); // ERROR: No set method
Signals with Objects/Arrays
Copy
interface User {
name: string;
age: number;
address: {
city: string;
country: string;
};
}
@Component({...})
export class UserComponent {
user = signal<User>({
name: 'Alice',
age: 25,
address: { city: 'NYC', country: 'USA' }
});
updateName(name: string) {
// Must replace entire object (immutable updates)
this.user.update(user => ({
...user,
name
}));
}
updateCity(city: string) {
this.user.update(user => ({
...user,
address: { ...user.address, city }
}));
}
}
Signals use reference equality by default. Mutating an object/array won’t trigger updates. Always create new references.
Computed Signals
Computed signals derive values from other signals:Copy
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
standalone: true,
template: `
<h2>Shopping Cart</h2>
@for (item of items(); track item.id) {
<div class="item">
<span>{{ item.name }} - ${{ item.price }} x {{ item.quantity }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
}
<div class="summary">
<p>Items: {{ itemCount() }}</p>
<p>Subtotal: ${{ subtotal() }}</p>
<p>Tax (10%): ${{ tax().toFixed(2) }}</p>
<p><strong>Total: ${{ total().toFixed(2) }}</strong></p>
</div>
`
})
export class ShoppingCartComponent {
items = signal<CartItem[]>([
{ id: 1, name: 'Widget', price: 25, quantity: 2 },
{ id: 2, name: 'Gadget', price: 50, quantity: 1 }
]);
// Computed values - automatically cached and updated
itemCount = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);
subtotal = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
tax = computed(() => this.subtotal() * 0.1);
total = computed(() => this.subtotal() + this.tax());
removeItem(id: number) {
this.items.update(items => items.filter(item => item.id !== id));
// All computed values automatically recalculate!
}
}
Computed Signal Properties
Copy
// Computed signals are:
// 1. LAZY: Only computed when read
// 2. CACHED: Recalculated only when dependencies change
// 3. READ-ONLY: Cannot be set directly
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => {
console.log('Computing fullName...'); // Only logs when deps change
return `${firstName()} ${lastName()}`;
});
// First read: computes and caches
console.log(fullName()); // "John Doe"
// Subsequent reads: uses cache
console.log(fullName()); // "John Doe" (no recomputation)
// Change a dependency
firstName.set('Jane');
// Next read: recomputes
console.log(fullName()); // "Jane Doe"
Effects
Effects run side effects when signals change:Copy
import { Component, signal, effect, inject, OnInit } from '@angular/core';
@Component({
selector: 'app-settings',
standalone: true,
template: `
<select (change)="setTheme($event)">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<input
type="text"
[value]="searchQuery()"
(input)="searchQuery.set($event.target.value)"
placeholder="Search..."
/>
`
})
export class SettingsComponent {
private http = inject(HttpClient);
theme = signal<'light' | 'dark' | 'system'>('light');
searchQuery = signal('');
constructor() {
// Effect: Sync theme to document
effect(() => {
document.documentElement.setAttribute('data-theme', this.theme());
localStorage.setItem('theme', this.theme());
});
// Effect: Debounced search
effect((onCleanup) => {
const query = this.searchQuery();
if (!query) return;
const timeout = setTimeout(() => {
this.performSearch(query);
}, 300);
// Cleanup function for previous effect run
onCleanup(() => clearTimeout(timeout));
});
}
setTheme(event: Event) {
const select = event.target as HTMLSelectElement;
this.theme.set(select.value as any);
}
private performSearch(query: string) {
console.log('Searching for:', query);
}
}
Effect Options
Copy
// Disable automatic tracking for specific reads
effect(() => {
// This signal is NOT tracked (won't trigger re-run)
const config = untracked(() => this.config());
// This signal IS tracked
const user = this.user();
console.log(`User: ${user.name}, Config: ${config.apiUrl}`);
});
// Allow signal writes inside effect (use sparingly!)
effect(() => {
const value = this.sourceSignal();
// Normally writing signals in effects is forbidden
this.derivedSignal.set(transform(value));
}, { allowSignalWrites: true });
Avoid writing signals in effects when possible. Use computed signals for derived state. Effects with
allowSignalWrites can lead to infinite loops if not careful.Signal Inputs and Outputs
Modern Angular provides signal-based component communication:Signal Inputs
Copy
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="user-card">
<h3>{{ name() }}</h3>
@if (email()) {
<p>{{ email() }}</p>
}
<span class="initials">{{ initials() }}</span>
</div>
`
})
export class UserCardComponent {
// Required input
name = input.required<string>();
// Optional input with default
email = input<string>('');
// Transform input value
age = input(0, { transform: (value: string | number) => Number(value) });
// Alias for template attribute
userId = input.required<number>({ alias: 'id' });
// Computed from inputs
initials = computed(() => {
const parts = this.name().split(' ');
return parts.map(p => p[0]).join('').toUpperCase();
});
}
Copy
<!-- Using the component -->
<app-user-card
[name]="userName"
[email]="userEmail"
[id]="user.id"
/>
Model Inputs (Two-Way Binding)
Copy
import { Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
standalone: true,
template: `
<div class="rating">
@for (star of stars; track star) {
<button
(click)="value.set(star)"
[class.active]="star <= value()"
>
★
</button>
}
</div>
`
})
export class RatingComponent {
// Two-way bindable signal
value = model(0);
stars = [1, 2, 3, 4, 5];
}
Copy
<!-- Two-way binding syntax -->
<app-rating [(value)]="userRating" />
Signal Outputs
Copy
import { Component, output, OutputEmitterRef } from '@angular/core';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input
#searchInput
(keyup.enter)="search.emit(searchInput.value)"
/>
<button (click)="search.emit(searchInput.value)">Search</button>
<button (click)="clear.emit()">Clear</button>
`
})
export class SearchComponent {
// Output without value
clear = output();
// Output with value
search = output<string>();
// Aliased output
submitted = output<string>({ alias: 'onSearch' });
}
Signal Patterns
Form State Management
Copy
@Component({
selector: 'app-signup-form',
standalone: true,
template: `
<form (submit)="onSubmit($event)">
<input
[value]="formData().email"
(input)="updateField('email', $event)"
placeholder="Email"
/>
@if (errors().email) {
<span class="error">{{ errors().email }}</span>
}
<input
type="password"
[value]="formData().password"
(input)="updateField('password', $event)"
placeholder="Password"
/>
@if (errors().password) {
<span class="error">{{ errors().password }}</span>
}
<button [disabled]="!isValid() || isSubmitting()">
{{ isSubmitting() ? 'Submitting...' : 'Sign Up' }}
</button>
</form>
`
})
export class SignupFormComponent {
formData = signal({
email: '',
password: ''
});
errors = computed(() => {
const data = this.formData();
return {
email: this.validateEmail(data.email),
password: this.validatePassword(data.password)
};
});
isValid = computed(() => {
const errs = this.errors();
return !errs.email && !errs.password;
});
isSubmitting = signal(false);
updateField(field: 'email' | 'password', event: Event) {
const value = (event.target as HTMLInputElement).value;
this.formData.update(data => ({ ...data, [field]: value }));
}
private validateEmail(email: string): string | null {
if (!email) return 'Email is required';
if (!email.includes('@')) return 'Invalid email format';
return null;
}
private validatePassword(password: string): string | null {
if (!password) return 'Password is required';
if (password.length < 8) return 'Password must be 8+ characters';
return null;
}
async onSubmit(event: Event) {
event.preventDefault();
if (!this.isValid()) return;
this.isSubmitting.set(true);
try {
await this.authService.signup(this.formData());
} finally {
this.isSubmitting.set(false);
}
}
}
Async Data Loading
Copy
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@if (loading()) {
<div class="skeleton">Loading...</div>
} @else if (error()) {
<div class="error">
{{ error() }}
<button (click)="loadUser()">Retry</button>
</div>
} @else if (user(); as user) {
<div class="profile">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
}
`
})
export class UserProfileComponent implements OnInit {
private userService = inject(UserService);
private route = inject(ActivatedRoute);
user = signal<User | null>(null);
loading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
this.loadUser();
}
async loadUser() {
this.loading.set(true);
this.error.set(null);
try {
const userId = this.route.snapshot.params['id'];
const user = await firstValueFrom(
this.userService.getUser(userId)
);
this.user.set(user);
} catch (err) {
this.error.set('Failed to load user');
} finally {
this.loading.set(false);
}
}
}
Linked Signals
Copy
import { signal, linkedSignal } from '@angular/core';
@Component({...})
export class ProductComponent {
products = signal([
{ id: 1, name: 'Widget', variants: ['small', 'large'] },
{ id: 2, name: 'Gadget', variants: ['blue', 'red', 'green'] }
]);
selectedProduct = signal(this.products()[0]);
// Linked signal: resets when selectedProduct changes
selectedVariant = linkedSignal(() => this.selectedProduct().variants[0]);
selectProduct(product: Product) {
this.selectedProduct.set(product);
// selectedVariant automatically resets to first variant!
}
}
Signal vs RxJS
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ When to Use Signals vs RxJS │
├───────────────────────────────┬─────────────────────────────────────────┤
│ Use Signals │ Use RxJS │
├───────────────────────────────┼─────────────────────────────────────────┤
│ • Synchronous state │ • Async operations (HTTP) │
│ • Component-local state │ • Complex event streams │
│ • Derived/computed values │ • Debouncing, throttling │
│ • Template bindings │ • Retries, polling │
│ • Simple parent-child comms │ • Race conditions, cancellation │
│ • Form state management │ • WebSocket streams │
└───────────────────────────────┴─────────────────────────────────────────┘
Converting Between Signals and RxJS
Copy
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { Observable, interval } from 'rxjs';
@Component({...})
export class ConversionComponent {
private http = inject(HttpClient);
// Observable → Signal (for templates)
users$ = this.http.get<User[]>('/api/users');
users = toSignal(this.users$, { initialValue: [] });
// Signal → Observable (for RxJS operators)
searchQuery = signal('');
searchResults$ = toObservable(this.searchQuery).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.searchService.search(query))
);
// Convert back to signal for template
searchResults = toSignal(this.searchResults$, { initialValue: [] });
}
Practice Exercise
Exercise: Build a Todo App with Signals
Create a todo app with:
- Add/remove/toggle todos
- Filter (all, active, completed)
- Computed stats (total, completed, remaining)
- Persist to localStorage via effect
Solution
Solution
Copy
interface Todo {
id: number;
text: string;
completed: boolean;
}
@Component({
selector: 'app-todos',
standalone: true,
template: `
<div class="todo-app">
<input
#newTodo
(keyup.enter)="addTodo(newTodo.value); newTodo.value = ''"
placeholder="Add todo..."
/>
<div class="filters">
<button
(click)="filter.set('all')"
[class.active]="filter() === 'all'"
>All ({{ stats().total }})</button>
<button
(click)="filter.set('active')"
[class.active]="filter() === 'active'"
>Active ({{ stats().remaining }})</button>
<button
(click)="filter.set('completed')"
[class.active]="filter() === 'completed'"
>Completed ({{ stats().completed }})</button>
</div>
<ul class="todo-list">
@for (todo of filteredTodos(); track todo.id) {
<li [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)"
/>
<span>{{ todo.text }}</span>
<button (click)="removeTodo(todo.id)">×</button>
</li>
}
</ul>
@if (stats().completed > 0) {
<button (click)="clearCompleted()">
Clear completed
</button>
}
</div>
`
})
export class TodosComponent {
private nextId = 1;
todos = signal<Todo[]>(this.loadFromStorage());
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTodos = computed(() => {
const todos = this.todos();
const filter = this.filter();
switch (filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
});
stats = computed(() => {
const todos = this.todos();
const completed = todos.filter(t => t.completed).length;
return {
total: todos.length,
completed,
remaining: todos.length - completed
};
});
constructor() {
// Persist on changes
effect(() => {
localStorage.setItem('todos', JSON.stringify(this.todos()));
});
}
addTodo(text: string) {
if (!text.trim()) return;
this.todos.update(todos => [
...todos,
{ id: this.nextId++, text, completed: false }
]);
}
toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}
removeTodo(id: number) {
this.todos.update(todos => todos.filter(t => t.id !== id));
}
clearCompleted() {
this.todos.update(todos => todos.filter(t => !t.completed));
}
private loadFromStorage(): Todo[] {
const data = localStorage.getItem('todos');
const todos = data ? JSON.parse(data) : [];
this.nextId = Math.max(...todos.map(t => t.id), 0) + 1;
return todos;
}
}
Summary
1
signal()
Create reactive containers for values that notify on change
2
computed()
Derive values automatically with caching and lazy evaluation
3
effect()
Run side effects when signals change, with cleanup support
4
Signal Inputs/Outputs
Modern component communication with input(), model(), output()
5
RxJS Interop
Convert between signals and observables with toSignal/toObservable
Next Steps
Next: Routing & Navigation
Build multi-page applications with Angular Router