Angular 18 : Signals et nouvelles fonctionnalités

Découvrez les Signals d'Angular 18, la détection de changement zoneless et les nouvelles APIs signal-based pour créer des applications plus performantes.

Illustration d'Angular 18 avec les Signals et la réactivité moderne

Angular 18 marque un tournant majeur dans l'évolution du framework avec la stabilisation des Signals. Cette nouvelle primitive réactive transforme fondamentalement la façon de construire des composants Angular, offrant une alternative moderne aux décorateurs traditionnels tout en préparant le terrain pour une détection de changement sans Zone.js.

Ce que vous allez apprendre

Les APIs signal-based d'Angular 18 : input(), model(), viewChild(), et la configuration zoneless pour des applications plus légères et performantes.

Comprendre les Signals dans Angular 18

Les Signals représentent une nouvelle approche de la réactivité dans Angular. Contrairement aux décorateurs classiques comme @Input() qui reposent sur la détection de changement de Zone.js, les Signals offrent une réactivité fine et explicite. Chaque Signal encapsule une valeur et notifie automatiquement les consommateurs lorsque cette valeur change.

Cette approche présente plusieurs avantages : une meilleure performance grâce à des mises à jour ciblées, une intégration native avec les fonctions computed() et effect(), et une préparation à l'avenir zoneless d'Angular.

signals-basics.component.tstypescript
// Démonstration des concepts fondamentaux des Signals
import { Component, signal, computed, effect } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div class="counter-container">
      <h2>Compteur : {{ count() }}</h2>
      <p>Double : {{ doubleCount() }}</p>

      <button (click)="increment()">+1</button>
      <button (click)="decrement()">-1</button>
      <button (click)="reset()">Reset</button>
    </div>
  `
})
export class CounterComponent {
  // Signal writable - la valeur peut être modifiée
  count = signal(0);

  // Signal computed - dérivé automatiquement de count
  // Se recalcule uniquement quand count change
  doubleCount = computed(() => this.count() * 2);

  constructor() {
    // Effect - exécuté à chaque changement de count
    // Utile pour les side effects (logs, API calls, etc.)
    effect(() => {
      console.log(`Nouvelle valeur du compteur : ${this.count()}`);
    });
  }

  increment() {
    // update() permet de modifier basé sur la valeur précédente
    this.count.update(value => value + 1);
  }

  decrement() {
    this.count.update(value => value - 1);
  }

  reset() {
    // set() remplace directement la valeur
    this.count.set(0);
  }
}

Les Signals fonctionnent comme des conteneurs réactifs : signal() crée un Signal modifiable, computed() dérive des valeurs calculées, et effect() permet d'exécuter des actions en réponse aux changements.

Signal Inputs avec input()

La fonction input() remplace le décorateur @Input() traditionnel. Elle retourne un InputSignal en lecture seule, garantissant que les données circulent toujours du parent vers l'enfant sans modification accidentelle.

book-card.component.tstypescript
// Composant utilisant les signal inputs
import { Component, input, computed } from '@angular/core';

interface Book {
  id: string;
  title: string;
  author: string;
  price: number;
  discountPercent?: number;
}

@Component({
  selector: 'app-book-card',
  standalone: true,
  template: `
    <article class="book-card">
      <h3>{{ book().title }}</h3>
      <p class="author">Par {{ book().author }}</p>

      @if (hasDiscount()) {
        <p class="price">
          <span class="original">{{ book().price }} €</span>
          <span class="discounted">{{ discountedPrice() }} €</span>
        </p>
      } @else {
        <p class="price">{{ book().price }} €</p>
      }

      @if (featured()) {
        <span class="badge">Coup de cœur</span>
      }
    </article>
  `
})
export class BookCardComponent {
  // Input requis - le template ne compile pas sans cette prop
  book = input.required<Book>();

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

  // Computed basé sur l'input - recalculé automatiquement
  hasDiscount = computed(() => {
    const discount = this.book().discountPercent;
    return discount !== undefined && discount > 0;
  });

  // Calcul du prix réduit
  discountedPrice = computed(() => {
    const { price, discountPercent } = this.book();
    if (!discountPercent) return price;
    return (price * (100 - discountPercent) / 100).toFixed(2);
  });
}

L'utilisation dans un template parent reste similaire, mais avec la garantie de type et la réactivité des Signals :

book-list.component.tstypescript
// Composant parent utilisant book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';

@Component({
  selector: 'app-book-list',
  standalone: true,
  imports: [BookCardComponent],
  template: `
    <div class="book-grid">
      @for (book of books(); track book.id) {
        <app-book-card
          [book]="book"
          [featured]="book.id === featuredBookId()"
        />
      }
    </div>
  `
})
export class BookListComponent {
  books = signal<Book[]>([
    { id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
    { id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
  ]);

  featuredBookId = signal('1');
}

La différence majeure avec @Input() : les signal inputs sont en lecture seule. Impossible de modifier this.book.set() depuis le composant enfant, ce qui renforce le flux de données unidirectionnel.

Two-Way Binding avec model()

Pour les cas nécessitant une synchronisation bidirectionnelle, Angular 18 introduit model(). Cette fonction crée un Signal modifiable qui propage automatiquement les changements vers le composant parent.

search-input.component.tstypescript
// Composant avec binding bidirectionnel via model()
import { Component, model, output, computed } from '@angular/core';

@Component({
  selector: 'app-search-input',
  standalone: true,
  template: `
    <div class="search-container">
      <input
        type="text"
        [value]="query()"
        (input)="onInput($event)"
        [placeholder]="placeholder()"
        class="search-input"
      />

      @if (query().length > 0) {
        <button (click)="clear()" class="clear-btn">×</button>
      }

      <span class="char-count">{{ charCount() }} caractères</span>
    </div>
  `
})
export class SearchInputComponent {
  // model() crée un Signal bidirectionnel
  // Les modifications se propagent au parent
  query = model('');

  // Input classique pour la configuration
  placeholder = model('Rechercher...');

  // Output pour les événements supplémentaires
  searchSubmitted = output<string>();

  // Computed basé sur le model
  charCount = computed(() => this.query().length);

  onInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    // Mise à jour du model - propage au parent
    this.query.set(value);
  }

  clear() {
    this.query.set('');
  }

  submit() {
    if (this.query().length > 0) {
      this.searchSubmitted.emit(this.query());
    }
  }
}

Le parent utilise la syntaxe banana-in-a-box [()] pour le binding bidirectionnel :

app.component.tstypescript
// Utilisation du two-way binding avec model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [SearchInputComponent],
  template: `
    <div class="app-container">
      <app-search-input [(query)]="searchTerm" />

      <p>Recherche en cours : {{ searchTerm() }}</p>

      <div class="results">
        @for (result of filteredResults(); track result.id) {
          <div class="result-item">{{ result.name }}</div>
        }
      </div>
    </div>
  `
})
export class AppComponent {
  // Signal local synchronisé avec le composant enfant
  searchTerm = signal('');

  results = signal([
    { id: 1, name: 'Angular 18' },
    { id: 2, name: 'React 19' },
    { id: 3, name: 'Vue 3' }
  ]);

  // Filtrage réactif basé sur searchTerm
  filteredResults = computed(() => {
    const term = this.searchTerm().toLowerCase();
    if (!term) return this.results();
    return this.results().filter(r =>
      r.name.toLowerCase().includes(term)
    );
  });
}
model() vs input()

Utilisez input() pour les données en lecture seule (parent → enfant). Utilisez model() quand le composant enfant doit pouvoir modifier la valeur (bidirectionnel).

Signal Queries avec viewChild() et contentChild()

Les fonctions viewChild(), viewChildren(), contentChild() et contentChildren() remplacent les décorateurs correspondants. Elles retournent des Signals, éliminant le besoin des lifecycle hooks comme ngAfterViewInit.

form-container.component.tstypescript
// Démonstration des signal queries
import {
  Component,
  viewChild,
  viewChildren,
  ElementRef,
  effect,
  signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';

@Component({
  selector: 'app-form-container',
  standalone: true,
  imports: [FormFieldComponent],
  template: `
    <form #formElement (submit)="onSubmit($event)">
      <input #firstInput type="text" placeholder="Nom" />

      <app-form-field label="Email" />
      <app-form-field label="Téléphone" />

      <div class="actions">
        <button type="submit">Envoyer</button>
        <button type="button" (click)="focusFirst()">Focus premier champ</button>
      </div>
    </form>
  `
})
export class FormContainerComponent {
  // viewChild retourne un Signal<ElementRef | undefined>
  formElement = viewChild<ElementRef>('formElement');

  // viewChild.required garantit que l'élément existe
  firstInput = viewChild.required<ElementRef<HTMLInputElement>>('firstInput');

  // Query sur un composant - retourne le composant lui-même
  firstFormField = viewChild(FormFieldComponent);

  // viewChildren pour plusieurs éléments
  allFormFields = viewChildren(FormFieldComponent);

  constructor() {
    // Effect remplace ngAfterViewInit pour les queries
    effect(() => {
      // Le Signal est automatiquement résolu
      const input = this.firstInput();
      console.log('Premier input disponible:', input.nativeElement);
    });

    // Réagir aux changements de la liste
    effect(() => {
      const fields = this.allFormFields();
      console.log(`${fields.length} champs de formulaire trouvés`);
    });
  }

  focusFirst() {
    // Accès direct via le Signal
    this.firstInput().nativeElement.focus();
  }

  onSubmit(event: Event) {
    event.preventDefault();
    // Accéder au formulaire
    const form = this.formElement();
    if (form) {
      console.log('Formulaire soumis');
    }
  }
}

Pour projeter du contenu et y accéder, contentChild() fonctionne de manière similaire :

card.component.tstypescript
// Utilisation de contentChild pour le contenu projeté
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';

@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <header class="card-header">
        <ng-content select="[card-title]" />
      </header>

      <div class="card-body">
        <ng-content />
      </div>

      @if (hasFooter()) {
        <footer class="card-footer">
          <ng-content select="[card-footer]" />
        </footer>
      }
    </div>
  `
})
export class CardComponent {
  // Détecter si un footer a été projeté
  footerContent = contentChild<ElementRef>('[card-footer]');

  // Computed pour vérifier la présence du footer
  hasFooter = computed(() => this.footerContent() !== undefined);
}

Prêt à réussir tes entretiens Angular ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Détection de changement Zoneless

Angular 18 introduit la détection de changement sans Zone.js en mode expérimental. Cette fonctionnalité réduit la taille du bundle d'environ 13 KB et améliore les performances en éliminant les monkey-patches sur les APIs asynchrones du navigateur.

main.tstypescript
// Configuration de l'application en mode zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    // Active la détection zoneless expérimentale
    provideExperimentalZonelessChangeDetection()
  ]
});

La configuration dans angular.json doit également être mise à jour pour retirer Zone.js :

json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

En mode zoneless, la détection de changement se déclenche automatiquement dans ces cas : mise à jour d'un Signal, appel à markForCheck(), réception d'une nouvelle valeur via AsyncPipe, ou attachement/détachement d'un composant.

zoneless-counter.component.tstypescript
// Composant optimisé pour le mode zoneless
import {
  Component,
  signal,
  ChangeDetectionStrategy,
  inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-zoneless-counter',
  standalone: true,
  // OnPush recommandé pour zoneless
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="counter">
      <p>Compteur : {{ count() }}</p>
      <button (click)="increment()">Incrémenter</button>

      @if (loading()) {
        <p>Chargement...</p>
      }

      @if (data()) {
        <pre>{{ data() | json }}</pre>
      }
    </div>
  `
})
export class ZonelessCounterComponent {
  private http = inject(HttpClient);

  count = signal(0);
  loading = signal(false);
  data = signal<any>(null);

  increment() {
    // La mise à jour du Signal déclenche la détection
    this.count.update(c => c + 1);
  }

  async fetchData() {
    this.loading.set(true);

    try {
      // Les Signals garantissent la mise à jour de la vue
      const response = await fetch('/api/data');
      const json = await response.json();
      this.data.set(json);
    } finally {
      this.loading.set(false);
    }
  }
}
Compatibilité Zoneless

Les composants utilisant ChangeDetectionStrategy.OnPush et les Signals sont généralement compatibles avec le mode zoneless. Évitez les modifications directes de propriétés qui ne sont pas des Signals.

Migration des composants existants

La migration vers les APIs signal-based peut se faire progressivement. Voici un exemple de refactoring d'un composant traditionnel :

typescript
// AVANT : Composant avec décorateurs classiques
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';

@Component({
  selector: 'app-user-profile-legacy',
  template: `
    <div #container>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  `
})
export class UserProfileLegacyComponent implements AfterViewInit {
  @Input() user!: { name: string; email: string };
  @ViewChild('container') container!: ElementRef;

  ngAfterViewInit() {
    console.log('Container ready:', this.container.nativeElement);
  }
}
typescript
// APRÈS : Composant migré vers les Signals
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';

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

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    <div #container>
      <h2>{{ user().name }}</h2>
      <p>{{ user().email }}</p>
    </div>
  `
})
export class UserProfileComponent {
  // input.required remplace @Input() avec !
  user = input.required<User>();

  // viewChild.required remplace @ViewChild avec !
  container = viewChild.required<ElementRef>('container');

  constructor() {
    // effect remplace ngAfterViewInit pour les queries
    effect(() => {
      console.log('Container ready:', this.container().nativeElement);
    });
  }
}

Les avantages de cette migration : typage plus strict, réactivité automatique, moins de code boilerplate, et compatibilité avec le mode zoneless.

Bonnes pratiques avec les Signals

Quelques recommandations pour tirer le meilleur parti des Signals dans Angular 18 :

best-practices.component.tstypescript
// Exemple de bonnes pratiques avec les Signals
import {
  Component,
  signal,
  computed,
  effect,
  untracked,
  ChangeDetectionStrategy
} from '@angular/core';

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

@Component({
  selector: 'app-cart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="cart">
      <h2>Panier ({{ itemCount() }} articles)</h2>

      @for (item of items(); track item.id) {
        <div class="cart-item">
          <span>{{ item.name }}</span>
          <span>{{ item.quantity }} × {{ item.price }} €</span>
          <button (click)="removeItem(item.id)">Supprimer</button>
        </div>
      }

      <div class="cart-total">
        <strong>Total : {{ total() }} €</strong>
      </div>
    </div>
  `
})
export class CartComponent {
  // Signal pour les données mutables
  items = signal<Product[]>([]);

  // Computed pour les valeurs dérivées - évite les recalculs inutiles
  itemCount = computed(() => this.items().length);

  total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  constructor() {
    // Effect pour les side effects (analytics, persistence)
    effect(() => {
      const currentItems = this.items();
      // untracked évite de créer une dépendance
      untracked(() => {
        localStorage.setItem('cart', JSON.stringify(currentItems));
      });
    });
  }

  addItem(product: Product) {
    // update() pour les modifications basées sur l'état précédent
    this.items.update(current => {
      const existing = current.find(i => i.id === product.id);
      if (existing) {
        return current.map(i =>
          i.id === product.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...current, { ...product, quantity: 1 }];
    });
  }

  removeItem(id: string) {
    this.items.update(current => current.filter(i => i.id !== id));
  }
}

Points clés à retenir :

  • Utiliser computed() pour les valeurs dérivées plutôt que de les recalculer dans le template
  • Préférer update() à set() quand la nouvelle valeur dépend de l'ancienne
  • Utiliser untracked() dans les effects pour éviter les dépendances circulaires
  • Toujours spécifier track dans les boucles @for pour optimiser le rendu

Conclusion

Angular 18 pose les fondations d'un futur sans Zone.js grâce aux Signals. Les points essentiels à retenir :

  • input() remplace @Input() avec un typage plus strict et une lecture seule garantie
  • model() permet le two-way binding réactif entre parent et enfant
  • viewChild() et contentChild() éliminent le besoin de lifecycle hooks
  • Zoneless réduit le bundle et améliore les performances
  • computed() et effect() complètent l'écosystème réactif
  • ✅ Migration progressive possible composant par composant

L'adoption des Signals prépare les applications Angular pour les futures versions où le mode zoneless deviendra la norme. Cette transition représente un investissement judicieux pour la maintenabilité et les performances à long terme.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#angular 18
#angular signals
#zoneless
#signal inputs
#réactivité

Partager

Articles similaires