Front-end angularforall.com

- Angular 18 : signal inputs, outputs et model()

Angular Angular 18 Signals Input Output
Angular 18 : signal inputs, outputs et model()

Maîtrisez les nouvelles API Angular 18 : input(), output() et model() pour créer des composants réactifs sans décorateurs, plus simples et plus.

Pourquoi les Signal Inputs/Outputs

Angular 16 a introduit les Signals comme nouveau système de réactivité. Angular 18 va plus loin en remplaçant les décorateurs @Input() et @Output() par des fonctions qui retournent des Signals — un seul mécanisme de réactivité unifié dans tout l'application.

Aspect Ancienne API (décorateurs) Nouvelle API (signaux)
Lecture de valeur this.title this.title()
Inputs requis @Input({ required: true }) + assertion ! input.required<T>() — erreur compile
Réactivité ngOnChanges() computed(), effect() directs
Emission événements new EventEmitter() output<T>()
Two-way binding @Input + @Output Change model()
Change Detection Zone.js + OnPush manuel Granulaire, automatique
Philosophie : input(), output() et model() sont des fonctions, pas des décorateurs. Elles s'appellent dans le constructeur de la classe et retournent des objets Signal. Cela les rend composables avec computed() et effect().

input() — Signal Input complet

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

interface User {
    id: number;
    firstName: string;
    lastName: string;
    email: string;
    role: 'admin' | 'user';
}

@Component({
    selector: 'app-user-card',
    standalone: true,
    template: `
        <div class="user-card" [class.admin]="isAdmin()">
            <h3>{{ fullName() }}</h3>
            <p>{{ user().email }}</p>
            @if (showBadge()) {
                <span class="badge">{{ user().role }}</span>
            }
        </div>
    `
})
export class UserCardComponent {
    // Input requis — erreur de compilation si non fourni par le parent
    user = input.required<User>();

    // Input optionnel avec valeur par défaut
    showBadge = input(false);

    // Input avec alias — le parent utilise [userId] mais le composant lit userId
    userId = input.required<number>({ alias: 'id' });

    // Input avec transform — convertit automatiquement string → number
    maxItems = input(10, {
        transform: (v: string | number) => typeof v === 'string' ? parseInt(v) : v
    });

    // Computed dérivé d'un input signal — se met à jour automatiquement
    fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);
    isAdmin = computed(() => this.user().role === 'admin');

    constructor() {
        // Effect déclenché à chaque changement de l'input
        effect(() => {
            console.log('Utilisateur changé:', this.user().id);
            // Pas besoin de ngOnChanges !
        });
    }
}

Inputs depuis les paramètres de route

// Avec withComponentInputBinding() dans app.config.ts,
// les paramètres de route sont automatiquement liés aux inputs

// app.routes.ts
{ path: 'users/:id', component: UserDetailComponent }

// user-detail.component.ts
@Component({ ... })
export class UserDetailComponent {
    // Reçoit automatiquement le paramètre :id de la route
    id = input.required<string>();

    // Computed pour convertir le string de route en number
    userId = computed(() => parseInt(this.id()));
}

output() — Signal Output et outputFromObservable

import { Component, output, outputFromObservable } from '@angular/core';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';

@Component({
    selector: 'app-search-bar',
    standalone: true,
    template: `
        <input
            [value]="value"
            (input)="onInput($event)"
            (keydown.enter)="searched.emit(value)"
            placeholder="Rechercher..."
        />
    `
})
export class SearchBarComponent {
    // output() simple — remplace @Output() EventEmitter
    searched = output<string>();

    // output avec alias — le parent écoute (queryChange) mais en interne c'est onSearch
    onSearch = output<string>({ alias: 'queryChange' });

    value = '';
    private searchSubject = new Subject<string>();

    // outputFromObservable — convertit un Observable en output signal
    // Idéal pour debounce, distinctUntilChanged, etc.
    debouncedSearch = outputFromObservable(
        this.searchSubject.pipe(
            debounceTime(300),
            distinctUntilChanged()
        )
    );

    onInput(event: Event) {
        this.value = (event.target as HTMLInputElement).value;
        this.searchSubject.next(this.value); // Alimente l'Observable debouncé
    }
}

// Utilisation dans le parent :
// <app-search-bar
//     (searched)="handleSearch($event)"
//     (debouncedSearch)="handleDebouncedSearch($event)"
// />

model() — Two-way binding réactif

model() est la combinaison d'un input + output en un seul signal en lecture/écriture. C'est le mécanisme idéal pour les composants de formulaire personnalisés.

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

// === COMPOSANT RATING ÉTOILES ===
@Component({
    selector: 'app-star-rating',
    standalone: true,
    template: `
        @for (star of stars; track star) {
            <span
                class="star"
                [class.filled]="star <= rating()"
                (click)="rating.set(star)"
                (mouseenter)="hovered.set(star)"
                (mouseleave)="hovered.set(0)"
            >★</span>
        }
        <span class="label">{{ ratingLabel() }}</span>
    `
})
export class StarRatingComponent {
    // model() : readable + writable signal
    // Côté parent : [(rating)]="productRating"
    // Génère automatiquement : [rating] input + (ratingChange) output
    rating = model(0);

    // Model requis
    maxStars = model.required<number>();

    hovered = signal(0);

    get stars() {
        return Array.from({ length: this.maxStars() }, (_, i) => i + 1);
    }

    ratingLabel = computed(() => {
        const labels = ['', 'Mauvais', 'Médiocre', 'Correct', 'Bien', 'Excellent'];
        return labels[this.rating()] ?? '';
    });
}

// Utilisation dans le parent :
// <app-star-rating [(rating)]="productRating" [maxStars]="5" />
// Équivalent explicite :
// <app-star-rating [rating]="productRating" (ratingChange)="productRating = $event" [maxStars]="5" />

viewChild() et contentChild() signaux

Angular 17 a introduit les variantes Signal de @ViewChild et @ContentChild. Elles retournent des Signals lus dans les templates sans nécessiter AfterViewInit.

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

@Component({
    selector: 'app-form-field',
    standalone: true,
    template: `
        <div>
            <ng-content />
            <input #inputRef type="text" />
            <span class="error">{{ errorMessage() }}</span>
        </div>
    `
})
export class FormFieldComponent {
    // viewChild — Signal vers un élément du template
    inputRef = viewChild.required<ElementRef>('inputRef');

    // viewChildren — Signal vers une liste d'éléments
    // allInputs = viewChildren<ElementRef>('inputRef');

    // contentChild — Signal vers le contenu projeté
    // labelEl = contentChild(LabelComponent);

    errorMessage = signal('');

    focus() {
        // Accès immédiat sans AfterViewInit !
        this.inputRef().nativeElement.focus();
    }

    validate() {
        const value = this.inputRef().nativeElement.value;
        if (!value) {
            this.errorMessage.set('Ce champ est requis');
        }
    }
}

Tableau comparatif avant/après

// ===== AVANT Angular 18 (décorateurs) =====
import { Component, Input, Output, EventEmitter, ViewChild, OnChanges, SimpleChanges, ElementRef, AfterViewInit } from '@angular/core';

@Component({ ... })
export class OldComponent implements OnChanges, AfterViewInit {
    @Input({ required: true }) user!: User;
    @Input() theme: string = 'light';
    @Input({ alias: 'userId' }) id!: number;
    @Output() userChange = new EventEmitter<User>();
    @ViewChild('inputRef') inputRef!: ElementRef;

    ngOnChanges(changes: SimpleChanges) {
        // Obligé d'utiliser ngOnChanges pour réagir aux changements d'inputs
        if (changes['user']) {
            console.log('user changed', changes['user'].currentValue);
        }
    }

    ngAfterViewInit() {
        // Obligé d'attendre AfterViewInit pour accéder aux éléments du template
        this.inputRef.nativeElement.focus();
    }
}

// ===== APRÈS Angular 18 (signaux) =====
import { Component, input, output, model, viewChild, computed, effect, ElementRef } from '@angular/core';

@Component({ ... })
export class NewComponent {
    user = input.required<User>();          // required, erreur à la compile
    theme = input('light');                  // optionnel avec défaut
    id = input.required<number>({ alias: 'userId' }); // alias
    userChange = output<User>();            // remplace EventEmitter
    inputRef = viewChild.required<ElementRef>('inputRef'); // accès immédiat

    // Réactivité via computed et effect — pas de ngOnChanges
    fullName = computed(() => `${this.user().firstName} ${this.user().lastName}`);

    constructor() {
        effect(() => console.log('user changed', this.user()));
        // Accès au viewChild sans AfterViewInit
        // this.inputRef().nativeElement.focus(); ← dans afterNextRender
    }
}

Impact performance

Les Signal Inputs permettent à Angular de détecter les changements de façon granulaire, sans Zone.js et sans parcourir l'arbre de composants entier.

Bénéfice technique : Avec ChangeDetectionStrategy.OnPush et des Signal Inputs, Angular ne rend le composant que quand un Signal qu'il lit directement change. Zero overhead Zone.js, zero traversée d'arbre.
// Composant optimisé avec Signal Inputs + OnPush
@Component({
    selector: 'app-product-card',
    changeDetection: ChangeDetectionStrategy.OnPush, // Toujours utiliser avec Signals
    standalone: true,
    template: `
        <div class="card">
            <h3>{{ product().name }}</h3>
            <p>{{ discountedPrice() | currency }}</p>
            <button (click)="addToCart.emit(product())">Ajouter</button>
        </div>
    `
})
export class ProductCardComponent {
    product = input.required<Product>();
    discount = input(0); // Pourcentage de réduction

    addToCart = output<Product>();

    // Recalculé SEULEMENT si product() ou discount() change
    discountedPrice = computed(() =>
        this.product().price * (1 - this.discount() / 100)
    );
}

Migration et schematics

Angular fournit des schematics de migration automatique pour convertir progressivement les décorateurs en Signal APIs :

# Migration automatique @Input() → input()
ng generate @angular/core:signal-input-migration

# Migration automatique @Output() → output()
ng generate @angular/core:output-migration

# Migration automatique @ViewChild/@ContentChild → viewChild/contentChild
ng generate @angular/core:signal-queries-migration

# Tout migrer en une fois (Angular 19+)
ng generate @angular/core:signals
Coexistence : Les deux syntaxes coexistent indéfiniment. Vous pouvez migrer composant par composant. Un parent avec @Input() communique parfaitement avec un enfant qui utilise input().

Conclusion

Les Signal Inputs/Outputs d'Angular 18 représentent l'avenir du modèle de composants Angular : moins de boilerplate, réactivité fine, et une API cohérente avec l'écosystème Signals.

  • input.required<T>() remplace @Input({ required: true }) prop!: T
  • output<T>() remplace @Output() prop = new EventEmitter<T>()
  • model<T>() pour le two-way binding sur composants de formulaire
  • viewChild.required<T>() — accès template sans AfterViewInit
  • computed() et effect() remplacent ngOnChanges()
  • Toujours coupler avec ChangeDetectionStrategy.OnPush

Partager