Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

Frontend Developer crafting scalable web applications with modern technologies and clean code practices.

Quick Links

  • About
  • Experience
  • Projects
  • Achievements

Connect

Ā© 2026 Shaik Munsif. All rights reserved.

Built with Next.js & Tailwind

0%
Welcome back!Continue where you left off
Back to Blogs
Angular

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.

Dec 9, 202418 min read
AngularSignalsReactivityPerformanceState Management

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 ChangeDetectorRef gymnastics
  • 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 computed instead)
  • 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 DetectionZone-basedSignal-based
Required Inputs@Input({ required: true })input.required<T>()
Transform@Input({ transform: fn })input(default, { transform: fn })
Two-way Binding@Input() + @Output()model()

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();

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

ScenarioTraditionalSignalsPerformance Gain
Large list updatesFull component checkTargeted updates3-5x faster
Deeply nested componentsFull tree checkOnly affected paths5-10x faster
Computed valuesRe-run on every CDMemoized10-100x faster
Multiple derived valuesMultiple calculationsCached computed2-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
}

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

  1. Start Small: Use signals in new components
  2. Learn Patterns: Master computed and effect
  3. Refactor Gradually: Migrate performance-critical code
  4. Stay Updated: Follow Angular's signal roadmap

Resources

  • Angular Signals Documentation
  • Angular Blog: Signals Announcement
  • GitHub: Angular Signals RFC

Happy coding with Signals! šŸŽ‰

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!