Skip to main content
Angular Signals

Module Overview

Estimated Time: 3-4 hours | Difficulty: Intermediate | Prerequisites: Module 4
Signals are Angular’s new reactive primitive introduced in v16. They provide fine-grained reactivity, enabling Angular to know exactly which parts of the template need updating, leading to better performance. What You’ll Learn:
  • 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?

┌─────────────────────────────────────────────────────────────────────────┐
│                    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

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

// 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

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:
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

// 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:
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

// 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

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();
  });
}
<!-- Using the component -->
<app-user-card 
  [name]="userName" 
  [email]="userEmail" 
  [id]="user.id"
/>

Model Inputs (Two-Way Binding)

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];
}
<!-- Two-way binding syntax -->
<app-rating [(value)]="userRating" />

Signal Outputs

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

@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

@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

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

┌─────────────────────────────────────────────────────────────────────────┐
│                    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

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:
  1. Add/remove/toggle todos
  2. Filter (all, active, completed)
  3. Computed stats (total, completed, remaining)
  4. Persist to localStorage via effect
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