Salta al contenuto principale

1 - Angular Signals: da Zero a Produzione

Angular Signals: Il Futuro della Reattività in Angular Con Angular 16 il team ha introdotto una delle innovazioni più significative nella storia del...

Cosa ne pensi di questo articolo?

1 - Angular Signals: da Zero a Produzione
Angular ModernoFoto:Bibek ghoshPexels

In Breve

Angular Signals: Il Futuro della Reattività in Angular Con Angular 16 il team ha introdotto una delle innovazioni più significative nella storia del...

Angular Signals: Il Futuro della Reattività in Angular

Con Angular 16 il team ha introdotto una delle innovazioni più significative nella storia del framework: i Signals. Questa primitiva reattiva rappresenta un cambio di paradigma fondamentale, spostando Angular da un sistema di change detection basato su Zone.js a un modello di reattività fine-grained, dove solo i componenti effettivamente interessati da un cambiamento di stato vengono aggiornati.

In questo primo articolo della serie Angular Moderno esploreremo i Signals in profondità: dalla teoria alla pratica, dalla creazione di semplici stati reattivi fino a pattern avanzati pronti per la produzione. Se hai già familiarita con RxJS e Zone.js, scoprirai come i Signals semplificano drasticamente la gestione dello stato mantenendo prestazioni eccellenti.

Panoramica della Serie Angular Moderno

# Articolo Focus
1Sei qui - Angular SignalsReattività fine-grained
2Zoneless Change DetectionEliminare Zone.js
3Nuovi Template @if, @for, @deferControl flow moderno
4Standalone ComponentsArchitettura senza NgModule
5Signal FormsForm reattivi con Signals
6SSR e Incremental HydrationServer-Side Rendering
7Core Web Vitals in AngularPerformance e metriche
8Angular PWAProgressive Web App
9Dependency Injection AvanzataDI tree-shakable
10Migrare da Angular 17 a 21Guida alla migrazione

perchè i Signals? Il Problema di Zone.js

Per comprendere perchè i Signals sono stati introdotti, bisogna prima capire il problema che risolvono. Dalla sua nascita, Angular ha utilizzato Zone.js per intercettare eventi asincroni (click, timer, HTTP request) e triggherare automaticamente il ciclo di change detection. Questo approccio, seppur comodo, presenta limiti significativi:

Limiti di Zone.js

  • Overhead globale: Zone.js patcha TUTTE le API asincrone del browser (setTimeout, Promise, addEventListener), aggiungendo peso al bundle (~13KB minificato)
  • Change detection troppo ampia: Ogni evento asincrono scatena un check dell'intero albero dei componenti, anche se lo stato non è cambiato
  • Incompatibilita: Alcune librerie di terze parti non funzionano correttamente con Zone.js (Web Components, alcune librerie native)
  • Debugging complesso: Gli stack trace sono inquinati dai frame di Zone.js, rendendo difficile il debugging
  • Scalabilità: In applicazioni con centinaia di componenti, la change detection globale diventa un collo di bottiglia

I Signals risolvono questi problemi introducendo un sistema di reattività basato su grafo: ogni Signal sa esattamente quali consumatori dipendono da esso, e quando il suo valore cambia, solo quei consumatori vengono notificati e aggiornati. Nessun polling, nessun check globale.

Signals vs Zone.js: Il Confronto

Aspetto Zone.js Signals
Granularità Intero albero componenti Singolo binding nel template
Trigger Qualsiasi evento asincrono Solo quando il valore cambia
Bundle size +13KB (zone.js) 0KB aggiuntivi
Tracking Implicito (monkey-patching) Esplicito (dependency graph)
Debugging Stack trace inquinati Stack trace puliti
Lazy evaluation No Si (computed)

Anatomia di un Signal

Un Signal è un wrapper reattivo attorno a un valore. Puoi pensarlo come una variabile che notifica automaticamente i suoi consumatori quando il valore cambia. La funzione signal() crea un WritableSignal, ovvero un Signal che può essere letto e scritto.

Creare e Leggere un Signal

Creazione base di un Signal

import { signal } from '@angular/core';

// Creazione di un WritableSignal con valore iniziale
const count = signal(0);           // WritableSignal<number>
const name = signal('Angular');    // WritableSignal<string>
const items = signal<string[]>([]); // Tipo esplicito con generics

// LEGGERE il valore: chiama il signal come funzione
console.log(count());    // 0
console.log(name());     // 'Angular'
console.log(items());    // []

// SCRIVERE un nuovo valore: usa .set()
count.set(5);
console.log(count());    // 5

// AGGIORNARE basandosi sul valore corrente: usa .update()
count.update(current => current + 1);
console.log(count());    // 6

// AGGIORNARE oggetti/array: usa .update() per immutabilita
items.update(list => [...list, 'Nuovo elemento']);
console.log(items());    // ['Nuovo elemento']
    

perchè si usa la chiamata a funzione per leggere?

La sintassi count() non è solo una scelta stilistica: è il meccanismo che permette ad Angular di tracciare automaticamente le dipendenze. Quando leggi un Signal dentro un computed() o un effect(), Angular registra quella lettura come dipendenza. Se usassi una proprietà semplice come count.value, questo tracking automatico non sarebbe possibile.

WritableSignal vs ReadonlySignal

Angular distingue tra due tipi di Signal. Un WritableSignal espone i metodi .set() e .update(), mentre un Signal (ReadonlySignal) permette solo la lettura. Questa distinzione è fondamentale per l'incapsulamento.

Incapsulamento con asReadonly()

import { signal, Signal, WritableSignal } from '@angular/core';

// Signal privato e scrivibile nel servizio
private _counter: WritableSignal<number> = signal(0);

// Esponi solo la versione read-only ai consumatori
readonly counter: Signal<number> = this._counter.asReadonly();

// Metodo pubblico controllato per modificare lo stato
increment(): void {
  this._counter.update(v => v + 1);
}

// I consumatori possono leggere ma NON scrivere
// this.counterService.counter()    // OK - lettura
// this.counterService.counter.set(5)  // ERRORE - non esiste .set()
    

Uguaglianza e Notifiche

Per default, un Signal notifica i consumatori solo quando il valore cambia secondo Object.is(). Puoi personalizzare il confronto con l'opzione equal:

Personalizzare il confronto di uguaglianza

// Signal con confronto personalizzato
const user = signal(
  { name: 'Federico', age: 30 },
  {
    equal: (a, b) => a.name === b.name && a.age === b.age
  }
);

// Questo NON notifica i consumatori (stesso contenuto)
user.set({ name: 'Federico', age: 30 });

// Questo notifica i consumatori (contenuto diverso)
user.set({ name: 'Federico', age: 31 });
    

Computed Signals: Stato Derivato

La funzione computed() crea un Signal il cui valore e derivato automaticamente da altri Signals. È l'equivalente reattivo di una proprietà calcolata: il valore viene ricalcolato solo quando una delle sue dipendenze cambia, e il risultato è memoizzato (cached) fino al prossimo aggiornamento.

Computed Signals in azione

import { signal, computed } from '@angular/core';

const firstName = signal('Federico');
const lastName = signal('Calo');
const age = signal(30);

// Computed: ricalcolato SOLO quando firstName o lastName cambiano
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName());  // 'Federico Calo'

// Computed con logica condizionale
const canVote = computed(() => age() >= 18);
console.log(canVote());   // true

// Computed che dipende da altri computed (catena)
const greeting = computed(() =>
  `Ciao ${fullName()}, ${canVote() ? 'puoi votare' : 'non puoi votare'}`
);
console.log(greeting());  // 'Ciao Federico Calo, puoi votare'

// Quando cambia firstName, fullName e greeting si aggiornano
// ma canVote NO (non dipende da firstName)
firstName.set('Marco');
console.log(fullName());  // 'Marco Calo'
console.log(greeting());  // 'Ciao Marco Calo, puoi votare'
    

Caratteristiche Chiave di computed()

  • Lazy evaluation: Il valore viene calcolato solo quando qualcuno lo legge, non quando le dipendenze cambiano
  • Memoizzazione: Se nessuna dipendenza è cambiata, la lettura restituisce il valore cached senza ricalcolo
  • Tracking automatico: Le dipendenze vengono scoperte durante l'esecuzione della funzione di derivazione
  • Dipendenze dinamiche: Se la logica include un if, vengono tracciate solo le dipendenze effettivamente lette in quel passaggio
  • Read-only: Un computed Signal non ha metodi .set() o .update()

Attenzione: No Side Effects nei computed()

La funzione passata a computed() deve essere pura: non deve modificare altri Signals, effettuare chiamate HTTP, scrivere nel DOM o eseguire operazioni con effetti collaterali. Angular può rieseguire la funzione in qualsiasi momento per verificare se il valore è cambiato. Per i side effects, usa effect().

Effects: Effetti Collaterali Reattivi

La funzione effect() esegue un side effect ogni volta che i Signals letti al suo interno cambiano valore. È il meccanismo ideale per sincronizzare lo stato dei Signals con il mondo esterno: logging, localStorage, analytics, DOM manipulation.

Uso di effect()

import { signal, effect, Component } from '@angular/core';

@Component({
  selector: 'app-logger',
  standalone: true,
  template: `<button (click)="increment()">Count: {{ count() }}</button>`
})
export class LoggerComponent {
  count = signal(0);

  constructor() {
    // L'effect viene eseguito immediatamente e poi ogni volta
    // che count() cambia valore
    effect(() => {
      console.log(`Il contatore vale: ${this.count()}`);
      // Output: "Il contatore vale: 0" (subito)
      // Output: "Il contatore vale: 1" (dopo click)
    });
  }

  increment() {
    this.count.update(v => v + 1);
  }
}
    

Cleanup negli Effects

Quando un effect viene rieseguito o distrutto, potresti aver bisogno di pulire risorse come timer, sottoscrizioni o listener. Angular fornisce una callback onCleanup per questo scopo:

Cleanup function in effect()

import { signal, effect } from '@angular/core';

const pollingInterval = signal(5000);

effect((onCleanup) => {
  const interval = pollingInterval();

  const timerId = setInterval(() => {
    console.log('Polling dati dal server...');
  }, interval);

  // Questa funzione viene chiamata PRIMA della prossima esecuzione
  // e quando il componente viene distrutto
  onCleanup(() => {
    clearInterval(timerId);
    console.log('Timer pulito');
  });
});
    

Injection Context per gli Effects

effect() deve essere chiamato all'interno di un injection context, ovvero nel costruttore di un componente, direttiva o servizio, oppure all'interno di una funzione factory con inject(). Se hai bisogno di creare un effect fuori dal costruttore, passa esplicitamente l'Injector:

Effect fuori dall'injection context

import { signal, effect, inject, Injector, Component } from '@angular/core';

@Component({
  selector: 'app-deferred',
  standalone: true,
  template: `<button (click)="startTracking()">Avvia tracking</button>`
})
export class DeferredEffectComponent {
  private injector = inject(Injector);
  data = signal('');

  startTracking() {
    // Passiamo l'injector esplicitamente
    effect(() => {
      console.log('Dati aggiornati:', this.data());
    }, { injector: this.injector });
  }
}
    

Signal Inputs: Il Nuovo @Input()

A partire da Angular 17.1, il decoratore @Input() ha un'alternativa moderna basata su Signals: la funzione input(). I Signal Inputs sono read-only e si integrano perfettamente nel grafo reattivo, permettendo di usarli direttamente con computed() e effect().

input() vs @Input()

import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ displayName() }}</h3>
      <span class="badge" [class.active]="isActive()">
        {{ isActive() ? 'Attivo' : 'Inattivo' }}
      </span>
    </div>
  `
})
export class UserCardComponent {
  // Input opzionale con valore di default
  name = input('Anonimo');           // InputSignal<string>

  // Input obbligatorio (il parent DEVE fornirlo)
  userId = input.required<number>(); // InputSignal<number>

  // Input con alias (nome diverso nel template del parent)
  active = input(false, { alias: 'isUserActive' });

  // Computed che dipendono dagli input - tutto reattivo!
  displayName = computed(() =>
    `${this.name()} (ID: ${this.userId()})`
  );
  isActive = computed(() => this.active());
}

// Uso nel parent:
// <app-user-card [name]="'Federico'" [userId]="42" [isUserActive]="true" />
    

Vantaggi di input() su @Input()

  • Type-safe: input.required() genera un errore di compilazione se il parent non fornisce il valore
  • Reattività nativa: Il valore è un Signal, quindi funziona direttamente con computed() e effect()
  • Niente OnChanges: Non serve più implementare ngOnChanges per reagire ai cambiamenti degli input
  • Transform integrato: Puoi trasformare il valore in ingresso con l'opzione transform
  • Immutabilita: Gli input Signal sono read-only, prevenendo modifiche accidentali nel componente figlio

Transform degli Input

Trasformazione dei valori in ingresso

import { Component, input, booleanAttribute, numberAttribute } from '@angular/core';

@Component({
  selector: 'app-settings',
  standalone: true,
  template: `
    <p>Colonne: {{ columns() }}</p>
    <p>Disabilitato: {{ disabled() }}</p>
  `
})
export class SettingsComponent {
  // Converte automaticamente la stringa "3" nel numero 3
  columns = input(1, { transform: numberAttribute });

  // Converte la presenza dell'attributo in boolean
  // <app-settings disabled /> => true
  disabled = input(false, { transform: booleanAttribute });
}
    

Signal Outputs: Il Nuovo @Output()

Analogamente agli input, Angular introduce output() come alternativa moderna al decoratore @Output(). La funzione output() restituisce un OutputEmitterRef con un metodo .emit() per emettere eventi.

output() per emissione eventi

import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-todo-item',
  standalone: true,
  template: `
    <div class="todo">
      <span>{{ text() }}</span>
      <button (click)="onComplete()">Completa</button>
      <button (click)="onDelete()">Elimina</button>
    </div>
  `
})
export class TodoItemComponent {
  // Input
  text = input.required<string>();
  todoId = input.required<number>();

  // Output - tipizzati e type-safe
  completed = output<number>();    // OutputEmitterRef<number>
  deleted = output<number>();      // OutputEmitterRef<number>

  onComplete() {
    this.completed.emit(this.todoId());
  }

  onDelete() {
    this.deleted.emit(this.todoId());
  }
}

// Uso nel parent:
// <app-todo-item
//   [text]="'Studiare Signals'"
//   [todoId]="1"
//   (completed)="handleComplete($event)"
//   (deleted)="handleDelete($event)"
// />
    

Model Signals: Two-Way Binding

La funzione model() crea un Signal speciale che supporta il two-way binding tra componente parent e child. È l'evoluzione moderna della combinazione @Input() + @Output() con la convenzione valueChange.

model() per two-way binding

import { Component, model } from '@angular/core';

@Component({
  selector: 'app-rating',
  standalone: true,
  template: `
    <div class="stars">
      @for (star of stars; track star) {
        <span
          (click)="value.set(star)"
          [class.filled]="star <= value()">
          ★
        </span>
      }
    </div>
  `
})
export class RatingComponent {
  // model() crea un WritableSignal con two-way binding
  value = model(0);         // ModelSignal<number>
  stars = [1, 2, 3, 4, 5];
}

// Uso nel parent con banana-in-a-box syntax:
// <app-rating [(value)]="userRating" />
// Quando il child chiama value.set(3), userRating nel parent si aggiorna
    

Come funziona model() internamente

model() crea automaticamente sia un input che un output. Per un model chiamato value, Angular crea implicitamente un input value e un output valueChange. Quando chiami value.set() o value.update(), il componente emette automaticamente l'evento valueChange, propagando la modifica al parent.

Signal Queries: viewChild() e viewChildren()

I decoratori @ViewChild() e @ViewChildren() hanno ora le loro controparti basate su Signal: viewChild() e viewChildren(). Queste funzioni restituiscono Signal che si aggiornano automaticamente quando gli elementi referenziati cambiano.

viewChild() e viewChildren() Signal queries

import {
  Component, viewChild, viewChildren,
  ElementRef, AfterViewInit, effect
} from '@angular/core';

@Component({
  selector: 'app-gallery',
  standalone: true,
  template: `
    <input #searchInput type="text" placeholder="Cerca..." />

    <div class="gallery">
      @for (img of images(); track img.id) {
        <img #galleryItem [src]="img.url" [alt]="img.title" />
      }
    </div>
  `
})
export class GalleryComponent {
  images = signal([
    { id: 1, url: '/img1.jpg', title: 'Foto 1' },
    { id: 2, url: '/img2.jpg', title: 'Foto 2' },
  ]);

  // viewChild restituisce un Signal<ElementRef | undefined>
  searchInput = viewChild<ElementRef>('searchInput');

  // viewChild.required restituisce Signal<ElementRef> (no undefined)
  // requiredInput = viewChild.required<ElementRef>('searchInput');

  // viewChildren restituisce Signal<readonly ElementRef[]>
  galleryItems = viewChildren<ElementRef>('galleryItem');

  constructor() {
    // Reagisci quando gli elementi della gallery cambiano
    effect(() => {
      const items = this.galleryItems();
      console.log(`Gallery ha ${items.length} elementi`);
    });

    // Focus automatico sull'input di ricerca
    effect(() => {
      const input = this.searchInput();
      if (input) {
        input.nativeElement.focus();
      }
    });
  }
}
    

Vantaggi delle Signal Queries

  • Niente AfterViewInit: Non serve più il lifecycle hook; l'effect reagisce automaticamente quando il riferimento è disponibile
  • Aggiornamento reattivo: Se la lista di elementi cambia (es. con @for), il Signal viewChildren si aggiorna
  • Type safety: viewChild.required() garantisce che il riferimento esiste, eliminando i check undefined
  • Composabilità: Puoi usare computed() per derivare dati dalle query

Pattern Pratici per la Produzione

Dopo aver visto le singole API, esploriamo pattern concreti che userai quotidianamente nelle applicazioni Angular in produzione.

Pattern 1: Stato del Form con Signals

Gestione stato di un form di ricerca

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input
      [value]="query()"
      (input)="onSearch($event)"
      placeholder="Cerca prodotti..." />

    <div class="filters">
      <select (change)="onCategoryChange($event)">
        <option value="">Tutte le categorie</option>
        <option value="electronics">Elettronica</option>
        <option value="books">Libri</option>
      </select>
    </div>

    <p>Trovati {{ filteredProducts().length }} risultati</p>

    @for (product of filteredProducts(); track product.id) {
      <div class="product-card">
        <h4>{{ product.name }}</h4>
        <span>{{ product.price | currency:'EUR' }}</span>
      </div>
    }
  `
})
export class SearchComponent {
  // Stato reattivo del form
  query = signal('');
  category = signal('');
  products = signal<Product[]>([]);

  // Stato derivato: filtraggio reattivo
  filteredProducts = computed(() => {
    const q = this.query().toLowerCase();
    const cat = this.category();
    return this.products().filter(p =>
      p.name.toLowerCase().includes(q) &&
      (!cat || p.category === cat)
    );
  });

  onSearch(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.query.set(value);
  }

  onCategoryChange(event: Event) {
    const value = (event.target as HTMLSelectElement).value;
    this.category.set(value);
  }
}

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}
    

Pattern 2: Stato Condiviso con un Service

Signal-based state management service

import { Injectable, signal, computed } from '@angular/core';

interface CartItem {
  productId: number;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  // Stato privato
  private _items = signal<CartItem[]>([]);
  private _discount = signal(0);

  // Stato pubblico read-only
  readonly items = this._items.asReadonly();
  readonly discount = this._discount.asReadonly();

  // Stato derivato
  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0)
  );

  readonly subtotal = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  readonly total = computed(() => {
    const sub = this.subtotal();
    const disc = this._discount();
    return sub - (sub * disc / 100);
  });

  readonly isEmpty = computed(() => this._items().length === 0);

  // Azioni pubbliche
  addItem(product: Omit<CartItem, 'quantity'>) {
    this._items.update(items => {
      const existing = items.find(i => i.productId === product.productId);
      if (existing) {
        return items.map(i =>
          i.productId === product.productId
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...items, { ...product, quantity: 1 }];
    });
  }

  removeItem(productId: number) {
    this._items.update(items =>
      items.filter(i => i.productId !== productId)
    );
  }

  applyDiscount(percent: number) {
    this._discount.set(Math.min(percent, 50)); // Max 50%
  }

  clearCart() {
    this._items.set([]);
    this._discount.set(0);
  }
}
    

perchè questo pattern funziona bene

Questo service può essere iniettato in qualsiasi componente. Ogni componente che legge cartService.total() nel suo template verrà aggiornato automaticamente quando il carrello cambia. Non servono sottoscrizioni manuali, non serve async pipe, non servono Subject o BehaviorSubject. È stato puro e reattivo, con incapsulamento garantito da asReadonly().

Pattern 3: Caricamento Dati da API

Pattern per loading state con Signals

import { Component, signal, computed, inject, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface ApiState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

@Component({
  selector: 'app-users-list',
  standalone: true,
  template: `
    @if (state().loading) {
      <div class="spinner">Caricamento...</div>
    }

    @if (state().error; as error) {
      <div class="error">Errore: {{ error }}</div>
    }

    @if (state().data; as users) {
      <ul>
        @for (user of users; track user.id) {
          <li>{{ user.name }} - {{ user.email }}</li>
        }
      </ul>
    }
  `
})
export class UsersListComponent implements OnInit {
  private http = inject(HttpClient);

  state = signal<ApiState<User[]>>({
    data: null,
    loading: false,
    error: null
  });

  // Computed di convenienza
  isLoading = computed(() => this.state().loading);
  hasError = computed(() => this.state().error !== null);

  ngOnInit() {
    this.loadUsers();
  }

  loadUsers() {
    // Imposta loading
    this.state.set({ data: null, loading: true, error: null });

    this.http.get<User[]>('/api/users').subscribe({
      next: (users) => {
        this.state.set({ data: users, loading: false, error: null });
      },
      error: (err) => {
        this.state.set({
          data: null,
          loading: false,
          error: err.message || 'Errore sconosciuto'
        });
      }
    });
  }
}

interface User {
  id: number;
  name: string;
  email: string;
}
    

Interoperabilità con RxJS: toSignal() e toObservable()

Angular non abbandona RxJS: i due mondi coesistono e collaborano grazie alle funzioni di interoperabilità nel pacchetto @angular/core/rxjs-interop. Queste funzioni sono il ponte tra il mondo imperativo dei Signals e quello dichiarativo degli Observable.

toSignal(): Da Observable a Signal

Convertire Observable in Signal

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { interval } from 'rxjs';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <p>Utenti: {{ users()?.length ?? 'Caricamento...' }}</p>
    <p>Timer: {{ timer() }} secondi</p>
  `
})
export class DashboardComponent {
  private http = inject(HttpClient);

  // Converti un HTTP Observable in Signal
  // Il Signal sarà undefined finchè la risposta non arriva
  users = toSignal(this.http.get<User[]>('/api/users'));

  // Con valore iniziale (evita undefined)
  timer = toSignal(interval(1000), { initialValue: 0 });

  // Con requireSync per Observable che emettono subito (es. BehaviorSubject)
  // config = toSignal(this.configService.config$, { requireSync: true });
}
    

toObservable(): Da Signal a Observable

Convertire Signal in Observable

import { Component, signal, inject, effect } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-autocomplete',
  standalone: true,
  template: `
    <input
      [value]="searchTerm()"
      (input)="onInput($event)"
      placeholder="Cerca..." />

    @for (result of results(); track result.id) {
      <div>{{ result.name }}</div>
    }
  `
})
export class AutocompleteComponent {
  private http = inject(HttpClient);

  searchTerm = signal('');

  // Converti Signal in Observable, applica operatori RxJS,
  // poi riconverti in Signal
  results = toSignal(
    toObservable(this.searchTerm).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(term =>
        term.length > 2
          ? this.http.get<SearchResult[]>(`/api/search?q=${term}`)
          : []
      )
    ),
    { initialValue: [] as SearchResult[] }
  );

  onInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchTerm.set(value);
  }
}

interface SearchResult {
  id: number;
  name: string;
}
    

Quando usare toSignal() vs toObservable()

Scenario Usa Motivo
HTTP nel template toSignal() Sostituisce async pipe, più pulito
Debounce/throttle toObservable() + RxJS Gli operatori RxJS sono ideali per flussi temporali
Stato locale UI signal() diretto Non serve RxJS per toggle, contatori, form
WebSocket/SSE toSignal() Converti lo stream in Signal per il template
Combinare multipli Signal computed() Più semplice di combineLatest

Migrazione da Zone.js: Guida Pratica

Migrare un'applicazione esistente ai Signals non richiede una riscrittura totale. Puoi adottare un approccio incrementale, convertendo un componente alla volta. Ecco una strategia pratica.

Step 1: Converti le proprietà semplici in Signals

Prima e dopo: proprietà semplici

// PRIMA (Zone.js)
@Component({ ... })
export class CounterComponent {
  count = 0;
  label = 'Contatore';

  increment() {
    this.count++;
  }
}
// Template: {{ count }} - {{ label }}

// DOPO (Signals)
@Component({ ... })
export class CounterComponent {
  count = signal(0);
  label = signal('Contatore');

  increment() {
    this.count.update(v => v + 1);
  }
}
// Template: {{ count() }} - {{ label() }}
    

Step 2: Converti i getter in computed()

Prima e dopo: getter/proprietà derivate

// PRIMA
get fullName(): string {
  return `${this.firstName} ${this.lastName}`;
}
get isValid(): boolean {
  return this.email.includes('@') && this.name.length > 0;
}

// DOPO
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
isValid = computed(() =>
  this.email().includes('@') && this.name().length > 0
);
    

Step 3: Converti le sottoscrizioni in effects o toSignal()

Prima e dopo: sottoscrizioni Observable

// PRIMA (sottoscrizione manuale)
@Component({ ... })
export class UserProfileComponent implements OnInit, OnDestroy {
  user: User | null = null;
  private destroy$ = new Subject<void>();

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.currentUser$
      .pipe(takeUntil(this.destroy$))
      .subscribe(user => this.user = user);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// DOPO (con toSignal - zero boilerplate)
@Component({ ... })
export class UserProfileComponent {
  private userService = inject(UserService);

  // Si sottoscrive automaticamente, si pulisce automaticamente
  user = toSignal(this.userService.currentUser$);
}
    

Attenzione durante la Migrazione

  • Aggiorna i template: Ogni proprietà convertita in Signal richiede () nel template: {{ count }} diventa {{ count() }}
  • toSignal() richiede injection context: Deve essere chiamato nel costruttore o in un campo inizializzato a livello di classe
  • Non mescolare nello stesso componente: Evita di avere meta proprietà come Signal e meta come variabili semplici: conviene migrare tutto il componente in un colpo
  • Test: I test devono essere aggiornati per leggere component.count() invece di component.count

Best Practices e Errori Comuni

Dopo mesi di utilizzo in produzione, la community Angular ha consolidato una serie di best practices. Ecco le più importanti.

Le 8 Regole d'Oro dei Signals

  1. Usa signal() per stato locale - Toggle, contatori, form state: preferisci sempre Signal a variabili semplici
  2. Usa computed() per stato derivato - Mai duplicare stato che può essere calcolato. Se dipende da altri Signals, e un computed
  3. Usa effect() con parsimonia - Solo per side effects reali (logging, localStorage, analytics). Non usarlo per sincronizzare Signals tra loro
  4. Esponi asReadonly() - Nei service, rendi il Signal interno privato e writable, ed esponi solo la versione read-only
  5. Preferisci input() a @Input() - Gli input Signal sono più sicuri, reattivi e type-safe
  6. Usa computed() invece di effect() per derivare stato - Se il risultato e un valore derivato, usa computed. Effect e per azioni, non per calcoli
  7. Non modificare Signals dentro computed() - La funzione deve essere pura. Scrivere su un Signal dentro computed causa comportamenti imprevedibili
  8. Evita la ricorsione - Un effect che scrive su un Signal che l'effect stesso legge crea un loop infinito

Anti-pattern da Evitare

Errori comuni con i Signals

// ERRORE 1: Modificare un Signal dentro computed()
const a = signal(1);
const b = signal(0);
const sum = computed(() => {
  b.set(a() * 2);  // MAI! Side effect in computed
  return a() + b();
});

// ERRORE 2: Dimenticare le parentesi nel template
// <p>{{ count }}</p>     <-- Mostra la funzione, non il valore!
// <p>{{ count() }}</p>   <-- Corretto

// ERRORE 3: Mutare l'oggetto invece di crearne uno nuovo
const user = signal({ name: 'Fed', age: 30 });
// NON fare questo:
user().name = 'Marco';    // La mutation non viene rilevata!
// Fai invece:
user.update(u => ({ ...u, name: 'Marco' }));

// ERRORE 4: Loop infinito con effect
const counter = signal(0);
effect(() => {
  // Legge e scrive lo stesso Signal = loop!
  counter.set(counter() + 1);
});

// ERRORE 5: toSignal() fuori dall'injection context
@Component({ ... })
export class BadComponent {
  // Questo funziona (campo di classe)
  data = toSignal(this.http.get('/api/data'));

  onClick() {
    // Questo NON funziona (metodo, no injection context)
    const result = toSignal(this.http.get('/api/other'));
  }
}
    

Quando NON usare i Signals

RxJS resta la scelta migliore per:

  • Stream complessi: WebSocket, Server-Sent Events, polling con retry
  • Operatori temporali: debounceTime, throttleTime, delay, bufferTime
  • Flussi condizionali: switchMap, exhaustMap, mergeMap con logica complessa
  • Race conditions: Quando serve cancellare richieste precedenti (switchMap)
  • Backpressure: Gestione di stream ad alta frequenza

La regola pratica: se il tuo problema riguarda il tempo o la concorrenza, usa RxJS. Se riguarda lo stato, usa Signals.

Riepilogo e Prossimi Passi

In questo articolo abbiamo esplorato in profondità il sistema di Signals di Angular: dalla motivazione alla sintassi, dai pattern pratici alla migrazione. Ecco i punti chiave:

Concetti Chiave

  • signal() crea stato reattivo con .set() e .update()
  • computed() deriva stato in modo lazy e memoizzato
  • effect() esegue side effects reattivi con cleanup automatico
  • input(), output() e model() sostituiscono i decoratori classici
  • viewChild() e viewChildren() rendono le query reattive
  • toSignal() e toObservable() collegano il mondo RxJS e Signals
  • asReadonly() garantisce incapsulamento nei service

Nel prossimo articolo della serie, Zoneless Change Detection, vedremo come eliminare completamente Zone.js dall'applicazione, sfruttando i Signals come unico meccanismo di reattività e ottenendo miglioramenti significativi nelle performance e nella dimensione del bundle.

Domande frequenti

Come funziona il sistema di reattività dei Signals in Angular?

Il sistema di reattività dei Signals in Angular rappresenta un cambio di paradigma fondamentale, spostando Angular da un sistema di change detection basato su Zone.js a un modello di reattività fine-grained.

Perché i Signals sono stati introdotti?

I Signals sono stati introdotti per risolvere il problema dei limiti significativi del sistema di change detection basato su Zone.js, come ad esempio la necessità di polling e check globale.

Qual è la differenza tra WritableSignal e ReadonlySignal in Angular?

La principale differenza tra WritableSignal e ReadonlySignal in Angular è che il primo permette sia la lettura che la scrittura, mentre il secondo permette solo la lettura. Questa distinzione è fondamentale per l'incapsulamento.

Come funziona la funzione toObservable() per convertire un Signal in un Observable?

La funzione toObservable() restituisce un Observable che emette il valore del Signal ogni volta che cambia. Questo consente di utilizzare i Signals con le librerie di RxJS.

Fonti e approfondimenti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti