Modulo 03 — Standalone components + signals server-side

Modulo 3 di 12 · Durata stimata: 60 minuti · Lab GitHub: ✓ (coming soon)


Introduzione

Nel 2026, Angular signals sono il fondamento della reattività moderna. In SSR, i signal aggiungono una complessità: il tuo componente esegue due volte — prima server (rendering HTML), poi client (idratazione e interattività). Questo modulo ti insegna come gestire questa doppia esecuzione e scrivere componenti SSR-safe con signals.

Vedrai:

Fondamenta: signal, computed, effect

Signal: reactive variable

Un signal è una variabile "osservabile" che traccia quando cambia e notifica tutti i consumatori:

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

export class CounterComponent {
  count = signal(0);  // Crea un signal con valore iniziale 0

  increment() {
    this.count.set(this.count() + 1);  // Aggiorna il valore
  }

  // Template
  template = `
    
  `;
}

Differenza da variabile normale:

Perché signals importano in SSR: Angular sa quali signal sono stati letti durante il rendering server, così sa quali dati includere nella transfer state (vedi modulo 04).

Computed: derived signal

Un computed è un signal che dipende da altri signal. Se i signal fonte cambiano, computed ricalcola automaticamente:

export class StatsComponent {
  scores = signal([10, 20, 30]);

  // Computed: somma automatica
  total = computed(() => {
    return this.scores().reduce((a, b) => a + b, 0);
  });

  // Template: {{ total() }} aggiorna quando scores cambia
}

In SSR: Computed viene calcolato sia server che client durante idratazione, risultato dovrebbe essere lo stesso (deterministic). Se computed dipende da randomness o timezone, puoi avere idratazione mismatch.

Effect: side effect quando signal cambia

Un effect esegue una funzione quando un signal cambia. Utile per sync a localStorage, fare HTTP request, etc:

export class LocalStorageComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    // Effect: sincronizza tema con localStorage
    effect(() => {
      const t = this.theme();
      localStorage.setItem('theme', t);  // ← side effect
      document.documentElement.classList.toggle('dark', t === 'dark');
    });
  }
}

⚠️ Problema SSR: il code sopra CRASH!

Perché? Durante rendering server, non esiste `localStorage` (detto prima). Effect esegue automaticamente, tenta accedere `localStorage.setItem()` e fallisce con "localStorage is not defined".

Problema critico SSR: double execution

Flusso SSR:

  1. Server: Express carica il componente Angular, esegue ngOnInit, effect(), rendering HTML
  2. Client: Browser riceve HTML, JavaScript carica Angular, esegue ngOnInit, effect() di nuovo, attiva event listener (idratazione)

Se effect() fa side effect (localStorage, API call):

Conseguenza: side effect esegue due volte (due HTTP call, due localStorage write). Inefficiente e potenzialmente buggato.

Soluzione: effet condizionali con isPlatformBrowser

Usa `isPlatformBrowser()` per eseguire effect solo client:

import { Component, effect, signal, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-theme-switcher',
  standalone: true,
  template: `
    
  `,
})
export class ThemeSwitcherComponent {
  private platformId = inject(PLATFORM_ID);
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    // Effect: SOLO client
    effect(() => {
      if (!isPlatformBrowser(this.platformId)) {
        return; // Exit se server
      }

      const t = this.theme();
      localStorage.setItem('theme', t);
      document.documentElement.classList.toggle('dark', t === 'dark');
    });
  }

  toggleTheme() {
    this.theme.update((t) => (t === 'light' ? 'dark' : 'light'));
  }
}

Vantaggi:

Pattern: afterNextRender e afterRender

Angular 17+ offre due utility per eseguire codice dopo rendering:

afterNextRender(): Esegue DOPO il primo render client (idratazione finita)

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

@Component({
  selector: 'app-page',
  standalone: true,
  template: `

Pagina

`, }) export class PageComponent { constructor() { // Esegue UNA VOLTA dopo idratazione afterNextRender(() => { console.log('Idratazione completa, pagina pronta'); // Perfect per analytics event, tracking, third-party scripts gtag?.('event', 'page_view'); }); } }

afterRender(): Esegue dopo OGNI render (anche updates)

constructor() {
  afterRender(() => {
    // Esegue SEMPRE dopo render
    // Utile per aggiornamenti DOM manuali
    const elem = document.querySelector('video');
    if (elem) {
      elem.play();
    }
  });
}

Quando usare:

Vantaggio SSR: Questi hook eseguono solo client, automaticamente. Zero configurazione.

Input signals: input()

Angular 19+ introduce input() signal, immutabile per design:

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

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    

{{ name() }}

Email: {{ email() }}

`, }) export class UserCardComponent { // Input signal: immutabile, reactive name = input('Unknown'); email = input(''); // Può essere usato in template direttamente // o in computed/effect }

Utilizzo:

// Parent component

Differenza vs @Input():

In SSR: input signal riflette il valore passato dal parent durante rendering server. Quando client si idrata, il valore sincronizza automaticamente (zero mismatch).

Deferred hydration con @defer

Angular 17+ introduce il blocco `@defer` per lazy-load componenti SSR:

@Component({
  selector: 'app-article',
  standalone: true,
  imports: [CommonModule, HeavyChartComponent],
  template: `
    

{{ title() }}

{{ content() }}

@defer (when isViewportVisible) { } @placeholder {
Loading chart...
} @error {
Chart failed to load
}
`, }) export class ArticleComponent { title = signal('My Article'); content = signal('...'); chartData = signal([]); isViewportVisible = signal(false); // Quando chart entra in viewport, setta signal a true // @defer renderizza il componente }

Vantaggi SSR con @defer:

Quando è utile: Chart pesanti, mappe, video player, commenti (infinite scroll)

Pattern pratico: componente analytics SSR-safe

Caso d'uso reale: componente che traccia page view solo client, senza crash server.

import {
  Component,
  input,
  computed,
  effect,
  afterNextRender,
  inject,
  PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-page-analytics',
  standalone: true,
  template: `  `,
})
export class PageAnalyticsComponent {
  private platformId = inject(PLATFORM_ID);

  // Input signal
  pagePath = input('/');
  eventCategory = input('page');

  // Computed: full event name
  eventName = computed(() => `${this.eventCategory()}_view`);

  constructor() {
    // Effect: traccia quando pagePath cambia (client only)
    effect(() => {
      if (!isPlatformBrowser(this.platformId)) {
        return;
      }

      const path = this.pagePath();
      const name = this.eventName();

      // Chiama analytics (Google Analytics, Mixpanel, etc.)
      if (typeof gtag !== 'undefined') {
        gtag('event', name, { page_path: path });
      }
    });

    // Oppure usa afterNextRender per una-tantum tracking
    afterNextRender(() => {
      console.log('Page fully loaded, can initialize third-party scripts');
    });
  }
}

Utilizzo:

// Nel tuo main layout
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, PageAnalyticsComponent],
  template: `
    
    
  `,
})
export class AppComponent {
  router = inject(Router);
}

Gestione PLATFORM_ID: isPlatformBrowser vs isPlatformServer

Angular fornisce token per identificare dove il codice sta eseguendo:

import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Component({...})
export class MyComponent {
  private platformId = inject(PLATFORM_ID);

  constructor() {
    if (isPlatformBrowser(this.platformId)) {
      console.log('Running in browser');
    }

    if (isPlatformServer(this.platformId)) {
      console.log('Running on server');
    }
  }
}

Pattern best practice:

// ❌ WRONG: rischia crash server
constructor() {
  const theme = localStorage.getItem('theme');
  this.theme.set(theme || 'light');
}

// ✅ CORRECT: safe server
constructor() {
  if (isPlatformBrowser(this.platformId)) {
    const theme = localStorage.getItem('theme');
    this.theme.set(theme || 'light');
  }
}

OnPush change detection + signals

Signal e OnPush sono complementari: OnPush dice ad Angular di renderizzare solo quando input o event cambia, signal fa la stessa cosa a fine granulare:

@Component({
  selector: 'app-user-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush, // ← importantissimo SSR
  imports: [CommonModule],
  template: `
    
{{ user.name }}
`, }) export class UserListComponent { users = signal([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]); // Con OnPush + signal: // - Angular non controlla change detection ogni volta // - Signal notifica solo quando utenti cambiano realmente // - Rendering server-side è rapido (no unnecessary checks) }

In SSR, OnPush è IMPORTANTE: Riduce il lavoro server, rende il rendering + veloce.

Troubleshooting: idratazione mismatch con signals

Scenario: Renderizzi qualcosa diverso server vs client con signal.

// ❌ WRONG
export class TimestampComponent {
  now = signal(new Date());

  template = `{{ now().toLocaleString() }}`;
  // Server: "2026-04-22 10:30:00 UTC"
  // Client: "2026-04-22 12:30:00 CEST" (timezone locale diverso!)
  // HYDRATION_MISMATCH_DETECTED
}
// ✅ CORRECT
export class TimestampComponent {
  private platformId = inject(PLATFORM_ID);
  now = signal(null);

  constructor() {
    // Inizializza SOLO client con timezone locale
    if (isPlatformBrowser(this.platformId)) {
      this.now.set(new Date().toLocaleString());
    }
  }

  template = `{{ now() }}`;
}

Oppure usa `@defer` per rimandare il render client.

Lab esercizio: componente counter SSR-safe

Obbiettivo: Crea un componente counter che funziona sia SSR che CSR senza crash o mismatch.

Requisiti:

  1. Usa signal per contatore
  2. Inizializza contatore da localStorage (client only)
  3. Sincronizza a localStorage quando incrementa (client only)
  4. OnPush change detection
  5. Nessun crash server, nessun idratazione mismatch

Snippet template:

@Component({
  selector: 'app-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    

Count: {{ count() }}

`, }) export class CounterComponent { // Implementa qui... }

Code repo (coming soon): github.com/fedcal01/angular-ssr-masterclass-labs/tree/main/module-03

Recap: cosa sai adesso

Prossimi passi

Nel modulo 04 parleremo di data fetching in SSR. Vedrai come:

→ Vai al modulo 04: Data fetching SSR + Transfer State


Domande su signals o SSR? Chiedi nei commenti.