Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

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

Quick Links

  • About
  • Experience
  • Projects
  • Achievements
  • Blogs
  • Contact

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
Angular Modern FeaturesPart 1 of 4
  • 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 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.

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

typescript
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.

typescript
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.

typescript
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.

typescript
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()
šŸ’” 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)

typescript
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

typescript
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():

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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 asyncSignal was common in Angular 16–18, Angular 19+ introduces the native Resource API (resource, rxResource, and httpResource). 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

typescript
// āŒ 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

typescript
// āŒ 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

typescript
// āŒ 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

typescript
// āŒ 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

typescript
// āŒ 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

typescript
// āŒ 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

typescript
// āŒ 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

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

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

typescript
// 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 FormBuilderDeclared inline as a class field
this.fb.group({ name: ['', Validators.required] })signalFormGroup({ name: signalFormControl('', { validators: [validators.required] }) })
typescript
// āŒ 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)
html
<!-- āŒ 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"
typescript
// āŒ 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 cleanupAutomatic cleanup when component is destroyed
8+ lines of RxJS plumbing3 lines inside the constructor
typescript
// āŒ 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 ().

OperationReactive FormsSignal Forms
Check validityform.validform.valid()
Check if touchedcontrol.touchedcontrol.touched()
Get errorscontrol.errorscontrol.errors()
Derive stateManual 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

FeatureReactive Forms (RxJS)Signal Forms (Signals)
ImportFormControl, FormGroup, FormArraysignalFormControl, signalFormGroup, signalFormArray
Template BindingformControlName="x"[formField]="ctrl"
Read Valuectrl.value (property)ctrl.value() (signal)
Track Changesctrl.valueChanges.subscribe(...)effect(() => ctrl.value())
Validation Statusgroup.valid / group.invalidgroup.valid() / group.invalid()
Nested Accessgroup.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)
CleanupngOnDestroy + SubjectAutomatic

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

typescript
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 first
  4. Explore the Resource API: Replace manual HTTP + loading state plumbing with resource(), rxResource(), and httpResource()
  5. Migrate Your Forms: Move from Reactive Forms to type-safe, signal-powered forms with our Signal Forms Zero to Hero Guide

Resources

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

Happy coding with Signals! šŸŽ‰

NextAngular Routing v19+: Complete Guide from Basics to Advanced

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!

On this page

0/37