Salta al contenuto principale

OnPush e Signals come Default, non Eccezione: 1215 Componenti in un Monorepo Angular 21

1215 componenti su 1219 in questo portfolio usano ChangeDetectionStrategy.OnPush. Perché "attivalo quando serve" è una bugia comoda, cosa costa davvero la disciplina di immutabilità, e quando conviene invece usare la strategia Default.

Cosa ne pensi di questo articolo?

OnPush e Signals come Default, non Eccezione: 1215 Componenti in un Monorepo Angular 21
Angular Modernofedericocalo.dev

In Breve

1215 componenti su 1219 in questo portfolio usano ChangeDetectionStrategy.OnPush. Perché "attivalo quando serve" è una bugia comoda, cosa costa davvero la disciplina di immutabilità, e quando conviene invece usare la strategia Default.

Risposta diretta

In questo monorepo Angular 21, 1215 componenti su 1219 (il 99.7%) usano ChangeDetectionStrategy.OnPush, non per policy imposta dall'alto ma perché il generator lo produce di default dalla prima riga. La lezione vera non riguarda la sintassi: la disciplina di immutabilità va imposta fin dall'inizio, non retrofittata dopo su un componente che già muta i suoi @Input in silenzio.

"OnPush è opt-in, lo attivo quando serve" è la frase più comoda e più falsa che si dice sulle strategie di change detection in Angular. Non esiste un "quando serve": o lo forzi da subito su ogni componente, o in un monorepo che cresce (in questo caso 3884 file TypeScript, 1219 componenti standalone) non ci arrivi mai davvero, perché retrofittare OnPush su un componente esistente significa cacciare ogni mutazione diretta nascosta nei suoi figli, e a quel punto costa più che riscriverlo.

Disclaimer di metodo: i numeri di questo articolo vengono da un grep diretto sul codice sorgente di questo stesso portfolio (grep -rl "ChangeDetectionStrategy.OnPush" src/app e affini), non da una slide o da un blog post terzo. Sono verificabili da chiunque abbia accesso al repository.

Il dato: 1215 componenti su 1219

Il conteggio esatto, a oggi, su frontend/src/app:

  • 1219 file con un decoratore @Component(.
  • 1215 file che dichiarano esplicitamente ChangeDetectionStrategy.OnPush (99.7%).
  • 1090 chiamate a signal( nel codice sorgente.
  • 1501 chiamate a computed(.
  • 64 chiamate a effect(.

I quattro componenti che restano fuori dal 99.7% non sono un'eccezione voluta: sono un residuo, quasi certamente componenti scritti prima che la regola diventasse automatica nello scaffolding interno, mai più toccati perché "funzionano". È esattamente il punto di questo articolo: una volta che un componente resta fuori dalla disciplina, il costo per farlo rientrare cresce nel tempo, non si riduce.

Perché "opt-in" è una bugia comoda

La documentazione ufficiale di Angular presenta OnPush come un'ottimizzazione opzionale: il componente si ridisegna solo quando cambia un riferimento di un suo @Input, non ad ogni ciclo di change detection della zona. In teoria puoi introdurla componente per componente, quando "ti serve la performance".

In pratica, in un monorepo con centinaia di componenti che si annidano l'uno nell'altro, la strategia di change detection non è una proprietà locale: è una proprietà dell'intero albero. Se un componente genitore usa Default e passa un oggetto mutato in-place a un figlio OnPush, il figlio smette di aggiornarsi. Non lancia un errore, non c'è uno stack trace: il dato sullo schermo è semplicemente vecchio. Bug del genere si scoprono per caso, mesi dopo, quando qualcuno nota che un contatore non si aggiorna dopo un'azione utente.

Per questo "attivo OnPush quando serve" è comodo da dire e disonesto da fare: significa rimandare la disciplina fino a quando il retrofit costa una riscrittura, non un flag.

Il costo nascosto: l'immutabilità obbligatoria

OnPush non è gratis. La regola implicita che porta con sé è: mai mutare un oggetto o un array passato come @Input, sempre crearne una copia nuova (spread, map, filter). Questa è, non a caso, la stessa regola di immutabilità che questo progetto applica anche lato Java (record immutabili) e lato Python (Pydantic frozen): non è una scelta isolata di Angular, è una disciplina trasversale.

Il costo reale è duplice. Primo, onboarding: uno sviluppatore junior che arriva da un framework più permissivo (o da Angular con Default strategy) scrive istintivamente this.items.push(x) invece di this.items.set([...this.items(), x]), e il primo bug che produce è silenzioso, non un errore di compilazione. Secondo, debug iniziale: quando la UI "non si aggiorna" con OnPush, la causa è quasi sempre una mutazione diretta a monte, ma trovarla richiede di risalire l'albero dei componenti finché non si individua chi ha mutato cosa.

Con la strategia Default, lo stesso identico bug di mutazione diretta "funziona comunque", perché Angular ridisegna tutto ad ogni ciclo indipendentemente dai riferimenti. Questo è precisamente il motivo per cui Default sembra più semplice all'inizio e diventa più costoso alla scala: nasconde il problema invece di prevenirlo.

Signals rendono la disciplina quasi automatica

Il dato che più mi ha sorpreso scrivendo questo articolo non è il 99.7% di OnPush: è il rapporto tra signal() (1090) e computed() (1501). Ci sono più derivazioni computate che sorgenti di stato dirette. In pratica lo stato primitivo viene dichiarato una volta e poi derivato, non ricalcolato manualmente in dieci punti diversi del componente.

Con i Signals, l'immutabilità richiesta da OnPush smette di essere una regola da ricordare a memoria e diventa una conseguenza naturale dell'API: signal.set(newValue) o signal.update(prev => ...) non permettono la mutazione diretta nello stesso modo in cui lo permetteva un campo di classe pubblico. Le 64 chiamate a effect( restano deliberatamente poche: sono la valvola di sfogo per i side effect reali (sync con StorageService, chiamate HTTP derivate), non il meccanismo di aggiornamento primario.

Quando NON vale la pena forzare OnPush

Sarebbe disonesto presentare OnPush come una scelta priva di controindicazioni. Su un prototipo usa-e-getta, su una demo interna che vive due settimane, o su un componente con due soli @Input statici che non cambiano mai dopo l'inizializzazione, il costo di disciplina dell'immutabilità supera il beneficio: la strategia Default fa lo stesso lavoro con meno attrito cognitivo, e nessuno pagherà mai il debug di una mutazione diretta perché quel componente non vivrà abbastanza per accumulare quel tipo di bug.

Il vero trade-off non è "OnPush sempre" contro "Default sempre": è decidere, PRIMA di scrivere la prima riga di un componente destinato a vivere e crescere, quale disciplina applicare, perché cambiarla dopo costa quasi quanto riscrivere.

Cosa farei diversamente

Se dovessi ripartire da zero, non cambierei la scelta di OnPush come default: la confermerebbe di nuovo, con più convinzione. Quello che cambierei è il momento in cui l'ho reso automatico nello scaffolding: i quattro componenti fuori dal 99.7% esistono perché per un periodo la regola era "raccomandata" invece che "generata dal template". La lezione operativa: se una disciplina architetturale vale la pena, va imposta strutturalmente (linter, template di scaffolding, code review bloccante), non lasciata alla memoria dello sviluppatore del momento.


Domande frequenti

Cos'è ChangeDetectionStrategy.OnPush in Angular?

È una strategia di change detection che dice ad Angular di ridisegnare un componente solo quando cambia il riferimento di un suo @Input, un evento del componente stesso si verifica, o un Signal osservato nel template cambia valore, invece di ricontrollare il componente ad ogni ciclo di change detection della zona.

Perché la strategia Default di Angular è considerata un problema alla scala?

Perché nasconde i bug di mutazione diretta invece di prevenirli: con Default, Angular ridisegna il componente ad ogni ciclo indipendentemente dai riferimenti, quindi una mutazione scorretta di un oggetto passato come @Input non produce sintomi visibili finché non si passa a OnPush, momento in cui il retrofit costa molto di più di un'adozione fin dall'inizio.

Quali sono gli svantaggi reali di OnPush?

Richiede disciplina di immutabilità obbligatoria (mai mutare direttamente oggetti o array negli @Input), che comporta un costo di onboarding più alto per sviluppatori abituati a pattern più permissivi e un debug iniziale più complesso quando la UI smette di aggiornarsi in modo silenzioso a causa di una mutazione diretta a monte.

Come si integrano OnPush e Signals in Angular 21?

I Signals rendono la disciplina di immutabilità richiesta da OnPush quasi automatica: l'API signal.set()/signal.update() non permette la mutazione diretta nello stesso modo di un campo di classe pubblico, e i valori derivati con computed() si aggiornano in modo tracciabile senza ricalcoli manuali sparsi nel componente.

Conviene forzare OnPush su ogni componente, senza eccezioni?

Non sempre: su prototipi usa-e-getta, demo di breve durata o componenti con pochissimi Input statici, il costo di disciplina supera il beneficio. Il trade-off reale è decidere la strategia prima di scrivere la prima riga di un componente destinato a crescere, perché cambiarla dopo costa quasi quanto riscriverlo.

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