Angular Signal Components : input, output, model

Front-end angularforall.com
Angular Signals Angular 19 Input Signal Model Signal Reactive Programming
Angular Signal Components : input, output, model

Maîtrisez l'API Signal Components Angular 19+ : input(), output(), model(), transform, alias et interop RxJS pour des composants réactifs sans décorateurs.

Pourquoi les Signal Components ?

Depuis Angular 17.3, les fonctions input(), output() et model() remplacent progressivement les décorateurs @Input(), @Output() et le pattern @Input() + @Output() valueChange. Angular 19 les stabilise et les intègre pleinement à l'outillage (schématics, Angular Language Service, devtools).

L'idée centrale : faire de la communication inter-composants un citoyen natif du graphe réactif des Signals. Avec l'ancien modèle, un @Input() est une propriété TypeScript ordinaire — invisible pour computed() et effect(). Avec input(), la valeur entrante est un Signal, et tout se met à jour automatiquement sans ngOnChanges.

Tableau de correspondance rapide

Ancien modèle (décorateurs) Signal Components (Angular 19+)
@Input() title: string title = input<string>()
@Input({ required: true }) id!: number id = input.required<number>()
@Output() saved = new EventEmitter<void>() saved = output<void>()
@Input() v + @Output() vChange v = model<T>()
ngOnChanges(changes) effect(() => { this.input(); })
Propriété TypeScript classique InputSignal<T> / ModelSignal<T>
Stabilisé en Angular 19 : Les trois APIs sont marquées stable dans le changelog Angular 19. Le support IDE (autocomplétion, refactoring) et les schématics de migration automatique sont disponibles dans Angular CLI 19+.

input() — signal d'entrée en profondeur

La fonction input(), importée de @angular/core, retourne un InputSignal<T> — un Signal en lecture seule dont la valeur est définie exclusivement par le composant parent. L'enfant ne peut jamais écrire dans ce Signal.

Déclaration et lecture dans le template

// task-badge.component.ts
import { Component, input } from '@angular/core';

export type TaskStatus = 'todo' | 'in-progress' | 'done' | 'blocked';

@Component({
  selector: 'app-task-badge',
  standalone: true,
  template: `
    <!-- Le Signal doit être appelé : status() pas status -->
    <span class="badge" [class]="badgeClass()">
      {{ label() }}
    </span>
  `,
})
export class TaskBadgeComponent {
  // InputSignal<TaskStatus | undefined> — optionnel
  status = input<TaskStatus>();

  // InputSignal<string> — avec valeur par défaut
  label = input<string>('En cours');

  // computed() basé directement sur le Signal status
  badgeClass = computed(() => ({
    'badge bg-secondary': this.status() === 'todo',
    'badge bg-primary':   this.status() === 'in-progress',
    'badge bg-success':   this.status() === 'done',
    'badge bg-danger':    this.status() === 'blocked',
  }));
}

Utilisation côté parent

<!-- task-list.component.html -->
<!-- Passer les valeurs via la syntaxe [propriété]="expression" -->
@for (task of tasks(); track task.id) {
  <div class="d-flex align-items-center gap-2 mb-2">
    <span>{{ task.title }}</span>
    <app-task-badge [status]="task.status" [label]="task.statusLabel" />
  </div>
}

Typage selon l'optionnalité

// Sans valeur par défaut → InputSignal<number | undefined>
priority = input<number>();

// Avec valeur par défaut → InputSignal<number> (jamais undefined)
priority = input<number>(0);

// required → InputSignal<number> (erreur de compilation si absent)
priority = input.required<number>();
Appeler le Signal sans parenthèses (this.status) retourne l'objet InputSignal, pas sa valeur. C'est une erreur courante lors de la migration. Toujours utiliser this.status() dans la classe et {{ status() }} dans le template.

input() avancé : required, alias, transform

input.required() — forcer le passage d'une valeur

Avec input.required(), Angular génère une erreur de compilation si le composant parent omet l'input. Le type inféré est T (jamais T | undefined), ce qui élimine les vérifications de nullité dans toute la classe.

// product-card.component.ts
import { Component, input, computed } from '@angular/core';

export interface Product {
  id: number;
  name: string;
  priceHT: number;
  vatRate: number;   // ex: 0.20 pour 20%
  stock: number;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  template: `
    <div class="card">
      <div class="card-body">
        <h5 class="card-title">{{ product().name }}</h5>
        <!-- priceTTC() recalculé seulement si product() change -->
        <p class="card-text text-primary fw-bold">{{ priceTTC() }} €</p>
        <span
          class="badge"
          [class.bg-success]="product().stock > 5"
          [class.bg-warning]="product().stock > 0 && product().stock <= 5"
          [class.bg-danger]="product().stock === 0"
        >
          {{ product().stock === 0 ? 'Rupture' : product().stock + ' en stock' }}
        </span>
      </div>
    </div>
  `,
})
export class ProductCardComponent {
  // Obligatoire — TypeScript infère Product (jamais undefined)
  product = input.required<Product>();

  // computed() utilise product() sans null-check
  priceTTC = computed(() => {
    const p = this.product();
    return (p.priceHT * (1 + p.vatRate)).toFixed(2);
  });
}

alias — séparer nom interne et nom public

L'option alias permet que la propriété de classe TypeScript ait un nom différent du binding template parent. Utile pour respecter des conventions de nommage côté API publique sans polluer l'interface interne.

// weather-icon.component.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-weather-icon',
  standalone: true,
  template: `
    <!-- Utilise le nom interne dans le template de ce composant -->
    <i class="wi wi-{{ iconCode() }}" [title]="description()"></i>
  `,
})
export class WeatherIconComponent {
  // Parent passe [weatherCode] mais la propriété interne s'appelle iconCode
  iconCode    = input.required<string>({ alias: 'weatherCode' });
  description = input<string>('Conditions météo', { alias: 'weatherLabel' });
}
<!-- template parent — utilise les noms publics (alias) -->
<app-weather-icon
  [weatherCode]="forecast.icon"
  [weatherLabel]="forecast.description"
/>

transform — normaliser les valeurs entrantes

L'option transform remplace ngOnChanges pour les normalisations simples. La fonction est appelée à chaque changement de valeur avant que le Signal ne soit mis à jour.

// tag-cloud.component.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-tag-cloud',
  standalone: true,
  template: `
    <div class="d-flex flex-wrap gap-2">
      @for (tag of tags(); track tag) {
        <span class="badge bg-secondary">{{ tag }}</span>
      }
    </div>
  `,
})
export class TagCloudComponent {
  // Accepte une chaîne CSV ou un tableau — normalise en tableau propre
  tags = input<string[]>([], {
    transform: (value: string | string[]) => {
      if (typeof value === 'string') {
        // "angular, signals, typescript" → ['angular', 'signals', 'typescript']
        return value.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
      }
      return value.map(t => t.trim().toLowerCase()).filter(Boolean);
    },
  });
}
<!-- Les deux syntaxes fonctionnent grâce au transform -->
<app-tag-cloud [tags]="'angular, signals, typescript'" />
<app-tag-cloud [tags]="['Angular', '  Signals ', 'TypeScript']" />
transform vs computed() : Utilisez transform pour normaliser la valeur entrante (cast de type, trim, parsing). Utilisez computed() pour les dérivations internes basées sur le signal (affichage formaté, filtrage, calculs).
Checklist input() en production :
  • Utiliser input.required() dès qu'une valeur est indispensable au rendu
  • Remplacer ngOnChanges par effect() ou computed()
  • Toujours appeler le signal : this.title() — jamais this.title
  • Préférer transform à un setter @Input() personnalisé
  • Typer précisément — éviter input<any>()

output() — émissions type-safe

La fonction output() retourne un OutputEmitterRef<T>. Contrairement à EventEmitter (qui étend Subject RxJS), un OutputEmitterRef n'est pas un Observable — il expose uniquement une méthode emit().

Composant avec plusieurs outputs

// invoice-line.component.ts
import { Component, input, output, computed } from '@angular/core';

export interface InvoiceLine {
  id: string;
  description: string;
  quantity: number;
  unitPrice: number;
}

@Component({
  selector: 'app-invoice-line',
  standalone: true,
  template: `
    <tr>
      <td>{{ line().description }}</td>
      <td class="text-end">{{ line().quantity }}</td>
      <td class="text-end">{{ line().unitPrice.toFixed(2) }} €</td>
      <td class="text-end fw-bold">{{ subtotal() }} €</td>
      <td>
        <!-- Émet la ligne courante au parent -->
        <button class="btn btn-sm btn-outline-primary me-1" (click)="lineEdited.emit(line())">
          Modifier
        </button>
        <button class="btn btn-sm btn-outline-danger" (click)="lineRemoved.emit(line().id)">
          Supprimer
        </button>
      </td>
    </tr>
  `,
})
export class InvoiceLineComponent {
  line = input.required<InvoiceLine>();

  // Émet l'objet ligne complet pour l'édition
  lineEdited  = output<InvoiceLine>();
  // Émet uniquement l'ID pour la suppression
  lineRemoved = output<string>();

  subtotal = computed(() =>
    (this.line().quantity * this.line().unitPrice).toFixed(2)
  );
}

Écoute côté parent — syntaxe identique aux décorateurs

<!-- invoice-editor.component.html -->
<!-- La syntaxe (event)="handler($event)" ne change pas -->
<table class="table">
  <tbody>
    @for (line of invoice().lines; track line.id) {
      <app-invoice-line
        [line]="line"
        (lineEdited)="openEditModal($event)"
        (lineRemoved)="removeLine($event)"
      />
    }
  </tbody>
</table>

Alias sur output()

// Nom interne différent du nom public exposé dans le template parent
lineRemoved = output<string>({ alias: 'deleteLine' });

// Le parent écoute (deleteLine)="..." mais le code interne appelle this.lineRemoved.emit()
output() n'accepte pas de valeur initiale — il n'a pas d'état. Si vous avez besoin d'un flux Observable en plus de l'émission, utilisez outputToObservable() décrit dans la section suivante.

output() et interopérabilité RxJS

Angular 19 fournit deux helpers dans @angular/core/rxjs-interop : outputFromObservable() convertit un Observable en Output, et outputToObservable() fait l'inverse.

outputFromObservable() — piloter un output depuis un Observable

// live-price.component.ts — affiche un cours boursier en temps réel
import { Component, inject } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { PriceStreamService } from '../services/price-stream.service';

@Component({
  selector: 'app-live-price',
  standalone: true,
  template: `<span class="badge bg-dark">Live</span>`,
})
export class LivePriceComponent {
  private priceStream = inject(PriceStreamService);

  // Chaque valeur émise par le WebSocket déclenche l'événement parent
  // Le parent n'a pas besoin de connaître RxJS
  priceUpdated = outputFromObservable(this.priceStream.prices$);
}
<!-- dashboard.component.html — écoute comme un output classique -->
<app-live-price (priceUpdated)="refreshChart($event)" />

outputToObservable() — appliquer des opérateurs RxJS sur un output

// search-page.component.ts
import { Component, viewChild, effect } from '@angular/core';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { SearchBarComponent } from './search-bar.component';
import { SearchService }      from '../services/search.service';

@Component({
  selector: 'app-search-page',
  standalone: true,
  imports: [SearchBarComponent],
  template: `
    <app-search-bar #searchBar />
    <ul class="list-group mt-3">
      @for (result of results(); track result.id) {
        <li class="list-group-item">{{ result.title }}</li>
      }
    </ul>
  `,
})
export class SearchPageComponent {
  searchBar   = viewChild.required(SearchBarComponent);
  results     = signal<SearchResult[]>([]);
  private svc = inject(SearchService);

  constructor() {
    effect(() => {
      // Convertit l'output en Observable pour debounce + dedup
      outputToObservable(this.searchBar().queryChanged)
        .pipe(
          debounceTime(300),             // attend 300ms d'inactivité
          distinctUntilChanged(),        // ignore les doublons consécutifs
          switchMap(q => this.svc.search(q)) // annule la requête précédente
        )
        .subscribe(res => this.results.set(res));
    });
  }
}
Quand utiliser outputToObservable ? Principalement pour les pipelines asynchrones : debounceTime, switchMap, combineLatest. Pour des réponses directes sans transformation, l'écoute (event)="handler($event)" reste la solution la plus simple.

model() — liaison bidirectionnelle avec les Signals

model() retourne un ModelSignal<T> — un Signal writable que l'enfant peut modifier et dont les changements se propagent automatiquement au parent. C'est l'implémentation Signal du pattern banana-in-a-box [(valeur)].

Slider de budget — exemple concret

// budget-slider.component.ts
import { Component, model, computed } from '@angular/core';

@Component({
  selector: 'app-budget-slider',
  standalone: true,
  template: `
    <label class="form-label d-flex justify-content-between">
      Budget
      <strong class="text-primary">{{ formatted() }}</strong>
    </label>
    <input
      type="range"
      class="form-range"
      [min]="min()"
      [max]="max()"
      [step]="step()"
      [value]="budget()"
      (input)="budget.set(+$any($event.target).value)"
    />
  `,
})
export class BudgetSliderComponent {
  // ModelSignal<number> — readable ET writable
  // L'enfant appelle budget.set() — la valeur remonte au parent
  budget = model<number>(0);
  min    = input<number>(0);
  max    = input<number>(10000);
  step   = input<number>(100);

  formatted = computed(() =>
    new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
      .format(this.budget())
  );
}

Utilisation parent — banana-in-a-box

// campaign-form.component.ts
import { Component, signal } from '@angular/core';
import { BudgetSliderComponent } from './budget-slider.component';

@Component({
  selector: 'app-campaign-form',
  standalone: true,
  imports: [BudgetSliderComponent],
  template: `
    <h3>Créer une campagne</h3>

    <!-- [(budget)] : le parent lit ET l'enfant peut écrire -->
    <app-budget-slider
      [(budget)]="campaignBudget"
      [min]="500"
      [max]="50000"
      [step]="500"
    />

    <p class="mt-2 text-muted">
      Budget sélectionné : {{ campaignBudget() | number:'1.0-0' }} €
    </p>
    <button class="btn btn-primary" (click)="createCampaign()">
      Lancer la campagne
    </button>
  `,
})
export class CampaignFormComponent {
  // Signal writable côté parent — synchronisé avec l'enfant via model()
  campaignBudget = signal<number>(2000);

  createCampaign(): void {
    console.log('Budget final :', this.campaignBudget());
  }
}

Écouter les changements explicitement

<!-- Angular génère automatiquement un événement <nom>Change -->
<!-- Ici budgetChange est généré par budget = model() -->
<app-budget-slider
  [budget]="campaignBudget()"
  (budgetChange)="onBudgetChanged($event)"
/>
onBudgetChanged(newBudget: number): void {
  this.campaignBudget.set(newBudget);
  this.validateBudget(newBudget); // logique métier déclenchée au changement
}
model() génère automatiquement l'événement <nomDuModel>Change. Ainsi budget = model() expose l'événement budgetChange sans aucune ligne de code supplémentaire — compatible avec [(budget)] et [budget] + (budgetChange).

model() avancé et bonnes pratiques

model.required() — model obligatoire

// color-scheme-picker.component.ts
import { Component, model } from '@angular/core';

export type ColorScheme = 'blue' | 'green' | 'purple' | 'orange' | 'red';

@Component({
  selector: 'app-color-scheme-picker',
  standalone: true,
  template: `
    <div class="d-flex gap-2 flex-wrap">
      @for (scheme of schemes; track scheme) {
        <button
          class="btn btn-sm"
          [class.active]="scheme === selectedScheme()"
          [style.background-color]="schemeColors[scheme]"
          (click)="selectedScheme.set(scheme)"
        >
          {{ scheme }}
        </button>
      }
    </div>
  `,
})
export class ColorSchemePickerComponent {
  // required : le parent DOIT initialiser la couleur
  selectedScheme = model.required<ColorScheme>();

  schemes: ColorScheme[] = ['blue', 'green', 'purple', 'orange', 'red'];
  schemeColors: Record<ColorScheme, string> = {
    blue: '#3b82f6', green: '#22c55e', purple: '#a855f7',
    orange: '#f97316', red: '#ef4444',
  };
}

Alias sur model()

// language-selector.component.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-language-selector',
  standalone: true,
  template: `
    <select class="form-select" [value]="currentLang()" (change)="currentLang.set($any($event.target).value)">
      <option value="fr">Français</option>
      <option value="en">English</option>
      <option value="de">Deutsch</option>
    </select>
  `,
})
export class LanguageSelectorComponent {
  // Propriété interne : currentLang — nom public : lang
  currentLang = model<string>('fr', { alias: 'lang' });
}
<!-- Le parent utilise l'alias [(lang)] -->
<app-language-selector [(lang)]="userPreferredLang" />

Quand ne PAS utiliser model()

La liaison bidirectionnelle masque le flux de données. Dans des hiérarchies profondes, il devient difficile de tracer qui modifie quoi.

Règles d'or pour model() :
  • ✅ Composants UI purs et autonomes (slider, toggle, date-picker, color-picker)
  • ✅ Quand l'enfant gère un état local visible par le parent
  • ✅ Remplacement direct du pattern @Input() + @Output() valueChange
  • ❌ Partage de state applicatif global — utiliser un Signal Store (NgRx)
  • ❌ Chaînes model() imbriquées sur 3+ niveaux de composants
  • ❌ Muter l'objet directement — toujours utiliser .set() ou .update()

Réactivité : computed() et effect() avec les inputs

Le véritable gain des Signal Components apparaît lorsqu'on combine input() avec computed() et effect(). Le graphe réactif Angular met à jour automatiquement les valeurs dérivées sans lifecycle manuel.

computed() sur plusieurs inputs

// shipment-cost.component.ts
import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-shipment-cost',
  standalone: true,
  template: `
    <div class="alert" [class]="alertClass()">
      <strong>Livraison :</strong> {{ shipmentLabel() }}
    </div>
  `,
})
export class ShipmentCostComponent {
  totalWeight = input.required<number>();   // en kg
  destination = input.required<string>();   // 'fr' | 'eu' | 'world'
  isPremium   = input<boolean>(false);

  // Tarif calculé à partir de 3 inputs — recalculé seulement si l'un d'eux change
  cost = computed(() => {
    const base = this.totalWeight() * 1.5;
    const mult = this.destination() === 'fr' ? 1 : this.destination() === 'eu' ? 2 : 4;
    return this.isPremium() ? 0 : base * mult;
  });

  shipmentLabel = computed(() =>
    this.cost() === 0
      ? 'Gratuite (premium)'
      : `${this.cost().toFixed(2)} €`
  );

  alertClass = computed(() =>
    this.cost() === 0 ? 'alert alert-success' : 'alert alert-info'
  );
}

effect() — remplacer ngOnChanges pour les side-effects

// chart-renderer.component.ts
import { Component, input, effect, ElementRef, viewChild, inject } from '@angular/core';
import { ChartService } from '../services/chart.service';

export interface ChartData {
  labels: string[];
  values: number[];
  type: 'bar' | 'line' | 'pie';
}

@Component({
  selector: 'app-chart-renderer',
  standalone: true,
  template: `<canvas #canvas class="w-100"></canvas>`,
})
export class ChartRendererComponent {
  chartData = input.required<ChartData>();
  height    = input<number>(300);

  canvas        = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  private charts = inject(ChartService);

  constructor() {
    // effect() re-run automatiquement si chartData() ou height() change
    // Remplace ngOnChanges + ngAfterViewInit combinés
    effect(() => {
      const data = this.chartData();  // lu : effect re-run si chartData change
      const h    = this.height();     // lu : effect re-run si height change

      this.canvas().nativeElement.height = h;
      this.charts.render(this.canvas().nativeElement, data);
    });
  }
}

linkedSignal() avec un input — Angular 19

// paginated-list.component.ts
import { Component, input, linkedSignal, computed } from '@angular/core';

@Component({
  selector: 'app-paginated-list',
  standalone: true,
  template: `
    <ul class="list-group">
      @for (item of pageItems(); track item.id) {
        <li class="list-group-item">{{ item.label }}</li>
      }
    </ul>
    <div class="d-flex gap-2 mt-2">
      <button class="btn btn-sm btn-outline-secondary"
        [disabled]="page() === 0"
        (click)="page.update(p => p - 1)">← Préc.</button>
      <button class="btn btn-sm btn-outline-secondary"
        [disabled]="(page() + 1) * pageSize() >= items().length"
        (click)="page.update(p => p + 1)">Suiv. →</button>
    </div>
  `,
})
export class PaginatedListComponent {
  items    = input.required<{ id: number; label: string }[]>();
  pageSize = input<number>(10);

  // linkedSignal : remet la page à 0 quand la liste source change
  page = linkedSignal({
    source: this.items,      // se réinitialise si items change
    computation: () => 0,   // retour à la première page
  });

  pageItems = computed(() => {
    const start = this.page() * this.pageSize();
    return this.items().slice(start, start + this.pageSize());
  });
}
linkedSignal() en Angular 19 : Idéal pour les états réinitialisables qui dépendent d'un input. Autres cas : reset d'un filtre textuel quand la liste change, reset d'un onglet actif quand l'objet parent change.

Migration depuis @Input / @Output

Les décorateurs @Input() et @Output() ne seront jamais supprimés. La stratégie recommandée est une migration composant par composant, en commençant par les composants feuilles (sans enfants).

Avant / Après — composant de notification

// ❌ AVANT — style décorateurs (Angular < 17.3)
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';

@Component({ selector: 'app-notification-item', standalone: true, template: '...' })
export class NotificationItemComponent implements OnChanges {
  @Input({ required: true }) message!: string;
  @Input() type: 'info' | 'warning' | 'error' = 'info';
  @Input() autoDismiss = false;
  @Output() dismissed = new EventEmitter<void>();

  iconClass = '';

  ngOnChanges(changes: SimpleChanges): void {
    if (changes['type']) {
      // Recalcul manuel déclenché par ngOnChanges
      this.iconClass = this.computeIcon(this.type);
    }
  }

  private computeIcon(t: string): string { return 'icon-' + t; }
}
// ✅ APRÈS — Signal Components (Angular 19+)
import { Component, input, output, computed } from '@angular/core';

@Component({ selector: 'app-notification-item', standalone: true, template: '...' })
export class NotificationItemComponent {
  // Inputs en Signals — plus de ngOnChanges
  message     = input.required<string>();
  type        = input<'info' | 'warning' | 'error'>('info');
  autoDismiss = input<boolean>(false);

  // Output sans EventEmitter
  dismissed = output<void>();

  // computed() remplace le recalcul manuel dans ngOnChanges
  iconClass = computed(() => 'icon-' + this.type());

  dismiss(): void {
    this.dismissed.emit();
  }
}

Migration automatique via schématics

# Migrer les @Input() → input() sur tout le projet
ng generate @angular/core:signal-input-migration

# Migrer les @Output() → output() sur tout le projet
ng generate @angular/core:output-migration

# Cibler un fichier ou dossier spécifique
ng generate @angular/core:signal-input-migration --path=src/app/shared/components
Les schématics mettent aussi à jour les usages dans les templates parents (bindings [prop]) et les tests unitaires (fixture.componentInstance.prop). Toujours vérifier les changements générés et lancer les tests avant de commiter.

Cas particulier — classes de base et héritage

// Les inputs Signal ne se transfèrent pas par héritage de classe
// Redéclarer les inputs dans chaque classe enfant si nécessaire

// ❌ Ceci ne fonctionne pas comme attendu
abstract class BaseComponent {
  id = input.required<number>(); // Ne sera pas reconnu dans les enfants
}

// ✅ Utiliser une interface ou un mixin à la place
interface WithId {
  id: InputSignal<number>;
}

Patterns et architecture

Container / Presentational avec Signal Components

// ---- CONTAINER (smart) : logique métier et état ----
// order-list-container.component.ts
import { Component, inject } from '@angular/core';
import { OrderService } from '../services/order.service';
import { Order } from '../models/order.model';
import { OrderRowComponent } from './order-row.component';

@Component({
  selector: 'app-order-list-container',
  standalone: true,
  imports: [OrderRowComponent],
  template: `
    @if (orders().length === 0) {
      <p class="text-muted">Aucune commande.</p>
    } @else {
      @for (order of orders(); track order.id) {
        <app-order-row
          [order]="order"
          (orderCancelled)="cancelOrder($event)"
          (orderDuplicated)="duplicateOrder($event)"
        />
      }
    }
  `,
})
export class OrderListContainerComponent {
  private orderService = inject(OrderService);
  orders = this.orderService.orders; // Signal depuis le service

  cancelOrder(orderId: string): void   { this.orderService.cancel(orderId); }
  duplicateOrder(order: Order): void   { this.orderService.duplicate(order); }
}
// ---- PRESENTATIONAL (dumb) : affichage pur, sans inject ----
// order-row.component.ts
import { Component, input, output, computed } from '@angular/core';
import { Order } from '../models/order.model';
import { CurrencyPipe, DatePipe } from '@angular/common';

@Component({
  selector: 'app-order-row',
  standalone: true,
  imports: [CurrencyPipe, DatePipe],
  template: `
    <div class="d-flex justify-content-between align-items-center border-bottom py-2">
      <div>
        <strong>{{ order().ref }}</strong>
        <small class="text-muted ms-2">{{ order().createdAt | date:'dd/MM/yyyy' }}</small>
      </div>
      <div class="d-flex align-items-center gap-3">
        <span class="fw-bold">{{ total() | currency:'EUR':'symbol':'1.2-2':'fr' }}</span>
        <span class="badge" [class]="statusBadge()">{{ order().status }}</span>
        <button
          class="btn btn-sm btn-outline-secondary"
          (click)="orderDuplicated.emit(order())"
        >Dupliquer</button>
        <button
          class="btn btn-sm btn-outline-danger"
          [disabled]="order().status === 'shipped'"
          (click)="orderCancelled.emit(order().id)"
        >Annuler</button>
      </div>
    </div>
  `,
})
export class OrderRowComponent {
  order = input.required<Order>();
  orderCancelled  = output<string>();
  orderDuplicated = output<Order>();

  total = computed(() =>
    this.order().lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0)
  );

  statusBadge = computed(() => ({
    'badge bg-warning text-dark': this.order().status === 'pending',
    'badge bg-info text-dark':    this.order().status === 'processing',
    'badge bg-success':           this.order().status === 'shipped',
    'badge bg-danger':            this.order().status === 'cancelled',
  }));
}

Composant compatible ControlValueAccessor

// numeric-stepper.component.ts — widget formulaire réutilisable
import { Component, model, input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-numeric-stepper',
  standalone: true,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => NumericStepperComponent),
    multi: true,
  }],
  template: `
    <div class="input-group">
      <button class="btn btn-outline-secondary" type="button"
        [disabled]="value() <= min()"
        (click)="decrement()">−</button>
      <input type="number" class="form-control text-center"
        [value]="value()" [min]="min()" [max]="max()"
        (change)="setValue(+$any($event.target).value)" />
      <button class="btn btn-outline-secondary" type="button"
        [disabled]="value() >= max()"
        (click)="increment()">+</button>
    </div>
  `,
})
export class NumericStepperComponent implements ControlValueAccessor {
  // model() gère l'état local ; CVA notifie Angular Forms
  value = model<number>(0);
  min   = input<number>(0);
  max   = input<number>(100);
  step  = input<number>(1);

  increment(): void { this.setValue(Math.min(this.value() + this.step(), this.max())); }
  decrement(): void { this.setValue(Math.max(this.value() - this.step(), this.min())); }

  setValue(v: number): void {
    this.value.set(v);
    this.onChange(v);   // notifie le FormControl Angular
    this.onTouched();
  }

  onChange  = (_: number) => {};
  onTouched = ()           => {};
  writeValue(v: number): void      { this.value.set(v ?? 0); }
  registerOnChange(fn: any): void  { this.onChange = fn; }
  registerOnTouched(fn: any): void { this.onTouched = fn; }
}
model() + ControlValueAccessor : Les deux s'associent parfaitement. model() gère l'état interne du widget (pour la liaison [(value)] standalone), CVA assure la compatibilité avec FormControl et ngModel dans un formulaire Angular.

Conclusion

Les Signal Components d'Angular 19 — input(), output() et model() — unifient la communication inter-composants avec le graphe réactif des Signals. Le résultat en production : suppression des ngOnChanges verbeux, typage plus strict avec input.required(), normalisations déclaratives via transform, et intégration directe avec computed(), effect() et linkedSignal().

La migration est entièrement incrémentale — les anciens décorateurs ne seront jamais retirés — et peut être automatisée via les schématics signal-input-migration et output-migration fournis par Angular CLI. Commencez par les composants feuilles (présentationnels, sans dépendances enfants) pour maximiser la lisibilité de vos reviews.

Récapitulatif des bonnes pratiques :
  • Utiliser input.required() — meilleur typage, zéro null-check inutile
  • Remplacer ngOnChanges par computed() (dérivations) ou effect() (side-effects)
  • Utiliser transform pour normaliser les valeurs entrantes (trim, cast, parse CSV)
  • Réserver model() aux composants UI purs et autonomes
  • Combiner output() + outputToObservable() pour les pipelines RxJS (debounce, switchMap)
  • Utiliser linkedSignal() pour les états réinitialisables liés à un input
  • Lancer les schématics Angular pour automatiser la migration de la base existante

Partager