Angular est-il vraiment plus compliqué que React ?

Quand on débute avec Angular, il est facile de se sentir découragé face à la multitude de concepts à assimiler. Cette complexité peut inciter à se tourner vers des frameworks comme React, qui semblent plus simples à première vue. Mais est-ce vraiment le cas ?

Abonnez-vous à notre chaîne

Pour profiter des prochaines vidéos sur Angular, abonnez-vous à la nouvelle chaîne YouTube !

Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

L'héritage des composants dans Angular

L'héritage des composants est une technique puissante qui permet de réutiliser du code entre différents composants qui partagent des fonctionnalités communes. Voyons comment l'utiliser efficacement dans Angular.

Comprendre l'héritage avec un exemple concret

Imaginons que vous gérez un restaurant. Vous avez différents types de serveurs : des serveurs en salle, des serveurs au bar, des serveurs en terrasse. Tous partagent des caractéristiques communes (prendre une commande, servir un client) mais ont aussi leurs spécificités. Au lieu de réécrire les fonctionnalités communes pour chaque type de serveur, vous créez un "serveur de base" dont les autres héritent.

Cas d'usage

  • Composants de formulaire partageant une logique de validation
  • Composants de liste avec pagination
  • Composants nécessitant une gestion d'état commune
  • Composants avec des fonctionnalités de CRUD similaires

Implémentation dans Angular

Dans notre application de gestion d'utilisateurs, créons un composant de base pour l'affichage des utilisateurs avec des fonctionnalités communes.

ts
import { Component, Input } from '@angular/core';
import { User } from './user.interface';

interface UserConfig {
  type: string;
  canEdit: boolean;
  maxUsers: number;
}

@Component({
  standalone: true,
  template: ''
})
export class BaseUserComponent {
  @Input() userType = 'user';

  protected getUserFullName(user: User): string {
    return `${user.firstName} ${user.lastName}`;
  }
}
ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BaseUserComponent } from './base-user.component';
import { inject } from '@angular/core';
import { UserService } from '../../core/services/user.service';

@Component({
  standalone: true,
  selector: 'app-user-list',
  template: `
    <div>{{ userType }}</div>

    @for (user of users; track user.id) {
      <div>{{ getUserFullName(user) }}</div>
    }
  `
})
export class UserListComponent extends BaseUserComponent {
  @Input() users: User[] = [];
}

Lorsqu'un composant hérite d'un autre composant, il hérite également de toutes les métadonnées définies dans le décorateur de la classe de base et de ses membres décorés. Dans notre exemple ci-dessus :

UserListComponent hérite de BaseUserComponent et obtient automatiquement :

  • L'input userType avec sa valeur par défaut 'user'
  • La méthode protégée getUserFullName()
  • Le mode standalone: true défini dans le décorateur @Component

Transmission des dépendances injectées

Lorsqu'on utilise l'héritage avec des composants qui ont des dépendances injectées, il faut faire attention à la façon dont on transmet ces dépendances à la classe parente. Voyons comment gérer cela correctement.

Voici l'approche classique utilisant le constructeur :

ts
@Component({
  standalone: true,
  template: ''
})
export class BaseListComponent {
  constructor(protected element: ElementRef) {}
  
  protected getElementWidth(): number {
    return this.element.nativeElement.offsetWidth;
  }
}
ts
@Component({
  standalone: true,
  selector: 'app-custom-list',
  template: `
    <div>Width: {{ getElementWidth() }}px</div>
  `
})
export class CustomListComponent extends BaseListComponent {
  constructor(element: ElementRef) {
    super(element);
  }
}

ATTENTION

Cette approche présente plusieurs inconvénients :

  1. Il faut redéclarer le constructeur dans chaque classe fille
  2. Si la classe parente ajoute de nouvelles dépendances, toutes les classes filles devront être modifiées
  3. Le code est plus verbeux et plus sujet aux erreurs

Approche moderne avec inject()

La meilleure approche consiste à utiliser la fonction inject() :

ts
@Component({
  standalone: true,
  template: ''
})
export class BaseListComponent {
  protected element = inject(ElementRef);
  
  protected getElementWidth(): number {
    return this.element.nativeElement.offsetWidth;
  }
}
ts
@Component({
  standalone: true,
  selector: 'app-custom-list',
  template: `
    <div>Width: {{ getElementWidth() }}px</div>
  `
})
export class CustomListComponent extends BaseListComponent {
  // Pas besoin de constructeur !
  // Les injections sont héritées automatiquement
}

AVANTAGES DE L'INJECTION AVEC INJECT()

  1. Code plus concis et plus lisible
  2. Pas besoin de gérer manuellement la transmission des dépendances
  3. Plus facile à maintenir quand on ajoute de nouvelles dépendances
  4. Moins de risques d'erreurs

Limitation du constructeur avec l'héritage

Imaginons que nous voulions créer des composants de liste avec des fonctionnalités communes comme la pagination, le tri et le filtrage. Voici comment structurer cela correctement :

ts
import { InjectionToken } from '@angular/core';

export interface ListConfig {
  pageSize: number;
  sortable: boolean;
  filterable: boolean;
  columns: {
    key: string;
    label: string;
    sortable?: boolean;
    filterable?: boolean;
    width?: string;
  }[];
  actions?: {
    edit?: boolean;
    delete?: boolean;
    view?: boolean;
  };
}

export const LIST_CONFIG = new InjectionToken<ListConfig>('LIST_CONFIG');
ts
@Component({
  standalone: true,
  selector: 'app-product-list',
  template: `
    <!-- Même template que user-list mais avec des colonnes différentes -->
  `,
  providers: [
    {
      provide: LIST_CONFIG,
      useValue: {
        pageSize: 5,
        sortable: true,
        filterable: false,
        columns: [
          { key: 'reference', label: 'Réf.', width: '80px' },
          { key: 'name', label: 'Produit', sortable: true },
          { key: 'price', label: 'Prix', sortable: true, width: '100px' },
          { key: 'stock', label: 'Stock', width: '80px' }
        ],
        actions: {
          edit: true,
          view: true
        }
      }
    }
  ]
})
export class ProductListComponent extends BaseListComponent<Product> {
  // Implémentation spécifique aux produits
}
ts
import { Component, inject } from '@angular/core';
import { ListConfig, LIST_CONFIG } from './list.config';

@Component({
  standalone: true,
  template: ''
})
export class BaseListComponent<T> {
  protected items: T[] = [];
  protected currentPage = 1;
  protected config = inject(LIST_CONFIG);
  
  protected get totalPages(): number {
    return Math.ceil(this.items.length / this.config.pageSize);
  }

  protected get visibleItems(): T[] {
    const start = (this.currentPage - 1) * this.config.pageSize;
    return this.items.slice(start, start + this.config.pageSize);
  }

  protected sortBy(column: string): void {
    if (!this.config.sortable) return;
    // Logique de tri commune
  }

  protected filter(column: string, value: string): void {
    if (!this.config.filterable) return;
    // Logique de filtrage commune
  }
}
ts
@Component({
  standalone: true,
  selector: 'app-user-list',
  template: `
    <table>
      <thead>
        <tr>
          @for (column of config.columns; track column.key) {
            <th [style.width]="column.width">
              {{ column.label }}
              @if (column.sortable && config.sortable) {
                <button (click)="sortBy(column.key)">↕️</button>
              }
            </th>
          }
          @if (config.actions) {
            <th>Actions</th>
          }
        </tr>
      </thead>
      <tbody>
        @for (user of visibleItems; track user.id) {
          <tr>
            @for (column of config.columns; track column.key) {
              <td>{{ user[column.key] }}</td>
            }
            @if (config.actions) {
              <td>
                @if (config.actions.edit) {
                  <button>✏️</button>
                }
                @if (config.actions.delete) {
                  <button>🗑️</button>
                }
                @if (config.actions.view) {
                  <button>👁️</button>
                }
              </td>
            }
          </tr>
        }
      </tbody>
    </table>

    @if (totalPages > 1) {
      <div class="pagination">
        <button [disabled]="currentPage === 1" 
                (click)="currentPage--">Précédent</button>
        <span>Page {{ currentPage }} / {{ totalPages }}</span>
        <button [disabled]="currentPage === totalPages" 
                (click)="currentPage++">Suivant</button>
      </div>
    }
  `,
  providers: [
    {
      provide: LIST_CONFIG,
      useValue: {
        pageSize: 10,
        sortable: true,
        filterable: true,
        columns: [
          { key: 'id', label: 'ID', width: '50px' },
          { key: 'name', label: 'Nom', sortable: true, filterable: true },
          { key: 'email', label: 'Email', sortable: true, filterable: true },
          { key: 'username', label: 'Pseudo', sortable: true }
        ],
        actions: {
          edit: true,
          delete: true,
          view: true
        }
      }
    }
  ]
})
export class UserListComponent extends BaseListComponent<User> {
  private userService = inject(UserService);

  ngOnInit() {
    this.loadUsers();
  }

  private loadUsers(): void {
    this.userService.getUsers().subscribe({
      next: (users) => this.items = users
    });
  }
}

AVANTAGES DE CETTE CONFIGURATION

  1. Configuration déclarative et typée
  2. Réutilisation maximale du code
  3. Personnalisation facile pour chaque type de liste
  4. Contrôle fin des fonctionnalités (tri, filtrage, actions)
  5. Gestion commune de la pagination

1. L'usage de InjectionToken

InjectionToken est un mécanisme d'Angular qui permet d'injecter des valeurs non-classe dans le système d'injection de dépendances.

ts
import { InjectionToken } from '@angular/core';

export interface ListConfig {
  pageSize: number;
  sortable: boolean;
  // ... autres propriétés
}

// Création du token
export const LIST_CONFIG = new InjectionToken<ListConfig>('LIST_CONFIG');

POURQUOI UTILISER INJECTIONTOKEN ?

  1. Pour éviter les collisions de noms (le token est unique)
  2. Pour typer fortement nos configurations
  3. Pour intégrer des valeurs simples dans le système d'injection de dépendances

En savoir plus sur les InjectionToken : Angular InjectionToken

2. Les providers dans le composant

Les providers au niveau du composant permettent de définir une configuration spécifique pour chaque instance du composant :

ts
@Component({
  // ...
  providers: [
    {
      provide: LIST_CONFIG,
      useValue: {
        pageSize: 10,
        sortable: true,
        columns: [
          { key: 'name', label: 'Nom', sortable: true },
          { key: 'email', label: 'Email', sortable: true }
        ]
      }
    }
  ]
})

AVANTAGES DES PROVIDERS AU NIVEAU COMPOSANT

  1. Isolation : Chaque composant a sa propre configuration
  2. Flexibilité : Facile à modifier pour chaque instance
  3. Encapsulation : La configuration est liée au cycle de vie du composant

Vous pouvez aussi utiliser useFactory pour une configuration dynamique :

ts
@Component({
  providers: [
    {
      provide: LIST_CONFIG,
      useFactory: () => ({
        pageSize: window.innerWidth < 768 ? 5 : 10,
        sortable: true,
        // ... autres propriétés
      })
    }
  ]
})

3. Injection dans BaseListComponent

L'injection de la configuration dans le composant de base se fait avec inject() :

ts
export class BaseListComponent<T> {
  protected config = inject(LIST_CONFIG);
  
  protected get visibleItems(): T[] {
    const start = (this.currentPage - 1) * this.config.pageSize;
    return this.items.slice(start, start + this.config.pageSize);
  }
}

Surcharge des méthodes de cycle de vie

Lorsqu'on utilise l'héritage, il est important de comprendre comment gérer correctement les méthodes de cycle de vie d'Angular (lifecycle hooks) entre la classe parente et les classes filles.

Principe de base

Quand une classe fille définit une méthode de cycle de vie qui existe déjà dans la classe parente, elle surcharge (override) le comportement de la classe parente. Pour conserver le comportement de la classe parente tout en ajoutant des fonctionnalités supplémentaires, il faut explicitement appeler la méthode parente avec super.

ts
@Component({
  standalone: true,
  template: ''
})
export class BaseUserComponent {
  protected isInitialized = false;
  protected loadingState = 'idle';

  ngOnInit() {
    this.isInitialized = true;
    this.loadingState = 'loading';
  }

  ngOnDestroy() {
    // Nettoyage des ressources
    this.loadingState = 'destroyed';
  }
}
ts
@Component({
  standalone: true,
  selector: 'app-user-list',
  template: `
    <div>État: {{ loadingState }}</div>
    @if (users.length > 0) {
      <!-- Affichage des utilisateurs -->
    }
  `
})
export class UserListComponent extends BaseUserComponent {
  private userService = inject(UserService);
  users: User[] = [];

  override ngOnInit() {
    // Appel de la méthode parente d'abord
    super.ngOnInit();
    
    // Logique spécifique au composant enfant
    this.loadUsers();
  }

  override ngOnDestroy() {
    // Nettoyage spécifique au composant enfant
    this.users = [];
    
    // Appel de la méthode parente ensuite
    super.ngOnDestroy();
  }

  private loadUsers(): void {
    this.userService.getUsers().subscribe({
      next: (users) => {
        this.users = users;
        this.loadingState = 'loaded';
      },
      error: () => {
        this.loadingState = 'error';
      }
    });
  }
}

ATTENTION

Si vous ne appelez pas super.ngOnInit(), le code d'initialisation de la classe parente ne sera jamais exécuté. Cela peut conduire à des bugs subtils et difficiles à déboguer.

Bonnes pratiques pour la surcharge des cycles de vie

  1. Utiliser le mot-clé override

POURQUOI UTILISER OVERRIDE ?

  1. Meilleure lisibilité du code
  2. Détection des erreurs à la compilation
  3. Documentation implicite du code
ts
// ❌ Sans override - Moins sûr
ngOnInit() {
  super.ngOnInit();
  // ...
}

// ✅ Avec override - Recommandé
override ngOnInit() {
  super.ngOnInit();
  // ...
}
  1. Ordre d'exécution cohérent

Pour ngOnInit et les hooks d'initialisation :

ts
override ngOnInit() {
  // 1. D'abord appeler la méthode parente
  super.ngOnInit();
  
  // 2. Ensuite exécuter la logique spécifique
  this.initializeSpecificLogic();
}

Pour ngOnDestroy et les hooks de nettoyage :

ts
override ngOnDestroy() {
  // 1. D'abord nettoyer les ressources spécifiques
  this.cleanupSpecificResources();
  
  // 2. Ensuite appeler le nettoyage parent
  super.ngOnDestroy();
}
  1. Gestion des hooks asynchrones
ts
@Component({
  standalone: true,
  template: ''
})
export class BaseAsyncComponent implements OnInit {
  protected subscription = new Subscription();

  ngOnInit() {
    this.subscription.add(
      interval(1000).subscribe(() => {
        // Logique de base
      })
    );
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}
ts
@Component({
  standalone: true,
  template: `...`
})
export class CustomAsyncComponent extends BaseAsyncComponent {
  override ngOnInit() {
    super.ngOnInit();
    
    // Ajouter des souscriptions supplémentaires
    this.subscription.add(
      this.userService.getUsers().subscribe(/* ... */)
    );
  }

  // Pas besoin de surcharger ngOnDestroy
  // La classe parente gère déjà le désabonnement
}
  1. Gestion des hooks avec des décorateurs

Si votre composant de base utilise des décorateurs comme @Input() ou @HostListener(), assurez-vous de gérer correctement leurs cycles de vie :

ts
@Component({
  standalone: true,
  template: ''
})
export class BaseInputComponent {
  @Input() set value(val: string) {
    this.handleValueChange(val);
  }

  protected handleValueChange(val: string): void {
    // Logique de base
  }
}

@Component({
  standalone: true,
  template: `...`
})
export class CustomInputComponent extends BaseInputComponent {
  override protected handleValueChange(val: string): void {
    super.handleValueChange(val);
    // Logique supplémentaire
  }
}

CONSEIL

Pour une meilleure maintenabilité :

  1. Gardez les méthodes de cycle de vie de la classe parente simples et focalisées
  2. Documentez clairement ce que fait chaque méthode de cycle de vie
  3. Évitez les effets de bord dans les méthodes de cycle de vie
  4. Utilisez des méthodes protégées pour la logique que les classes filles pourraient vouloir surcharger

Chaque mois, recevez en avant-première notre newsletter avec les dernières actualités, tutoriels, astuces et ressources Angular directement par email !