Replay

Apprendre Angular en 1h

Je me donne un objectif: vous faire découvrir et apprendre Angular en 1 heure: composant, syntaxe dans les templates, les directives, les signaux, les routeurs, les services, l'injection de dépendances, les observables et les requêtes HTTP. Le nécessaire pour faire une application Angular !.

Skip to content

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

Les Effets dans NgRx Signal Store

Les effets permettent de gérer les opérations asynchrones et les effets secondaires dans votre application Angular avec NgRx Signal Store. Ils sont essentiels pour interagir avec des API externes, effectuer des opérations asynchrones, ou déclencher plusieurs actions en cascade.

Les effets secondaires dans la vie quotidienne

Pour comprendre les effets, pensez à un système d'arrosage automatique dans un jardin. L'arrosage lui-même est un effet secondaire déclenché par une condition (comme un horaire programmé ou un capteur d'humidité). De même, dans une application, les effets secondaires sont des opérations qui se produisent en réponse à un changement d'état ou à une action utilisateur, comme l'envoi d'une requête HTTP, le stockage de données en local, ou la navigation entre les pages.

Gestion des effets dans NgRx Signal Store

Dans NgRx Signal Store, vous pouvez gérer les effets de deux manières principales :

  1. withHooks : Pour définir des effets qui s'exécutent à des moments spécifiques du cycle de vie du store
  2. rxMethod : Pour des effets basés sur les Observables RxJS (plus flexibles)

Effets avec withHooks

Pour les effets liés au cycle de vie du signal store, vous pouvez utiliser withHooks :

ts
import { signalStore, withState, withHooks, patchState } from '@ngrx/signals';
import { inject, effect } from '@angular/core';
import { Router } from '@angular/router';

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
}

export const AuthStore = signalStore(
  withState<AuthState>({
    user: null,
    isAuthenticated: false
  }),
  
  withHooks({
    /**
     * Méthode qui s'exécute lors de l'initialisation du store
     * Vérifie si un token est présent et met à jour l'état en conséquence
     */
    onInit(store) {
      // Injecter les dépendances nécessaires
      const router = inject(Router);
      
      // Effet exécuté chaque fois que isAuthenticated change
      effect(() => {
        // Récupérer la valeur actuelle de isAuthenticated
        const isAuthenticated = store.isAuthenticated();
        
        // Naviguer en fonction de l'état d'authentification
        if (isAuthenticated) {
          router.navigate(['/dashboard']);
        } else {
          router.navigate(['/login']);
        }
      });
      
      // Vérifier s'il y a un token dans localStorage
      const token = localStorage.getItem('auth_token');
      
      if (token) {
        // Valider le token et mettre à jour l'état
        validateToken(token).then(user => {
          if (user) {
            patchState(store, {
              user,
              isAuthenticated: true
            });
          } else {
            localStorage.removeItem('auth_token');
          }
        });
      }
    },
    
    /**
     * Méthode qui s'exécute à la destruction du store
     * Nettoyage des ressources si nécessaire
     */
    onDestroy() {
      console.log('Store destroyed, cleaning up resources');
      // Nettoyage des ressources si nécessaire
    }
  }),
  
  withMethods((store) => {
    const router = inject(Router);
    
    return {
      /**
       * Méthode pour déconnecter l'utilisateur avec effet secondaire de redirection
       */
      logout() {
        // Effacer le token
        localStorage.removeItem('auth_token');
        
        // Mettre à jour l'état
        patchState(store, {
          user: null,
          isAuthenticated: false
        });
        
        // Effet secondaire: redirection
        router.navigate(['/login']);
      }
    };
  })
);

// Fonction auxiliaire pour valider un token
async function validateToken(token: string): Promise<User | null> {
  // Implémentation réelle qui validerait le token avec une API
  try {
    const response = await fetch('/api/validate-token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      }
    });
    
    if (response.ok) {
      return await response.json();
    }
    return null;
  } catch (error) {
    console.error('Token validation failed', error);
    return null;
  }
}

HOOK ONINIT

La méthode onInit est automatiquement appelée lorsque le store est initialisé, ce qui est parfait pour charger des données initiales ou mettre en place des effets.

Effets réactifs avec rxMethod

Pour des effets plus complexes impliquant des flux de données, des annulations, ou des opérateurs RxJS avancés, utilisez rxMethod :

ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, catchError, debounceTime, filter, of, EMPTY } from 'rxjs';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface SearchState {
  query: string;
  results: any[];
  isLoading: boolean;
  error: string | null;
}

export const SearchStore = signalStore(
  withState<SearchState>({
    query: '',
    results: [],
    isLoading: false,
    error: null
  }),
  
  withMethods((store) => {
    // Injecter HttpClient
    const http = inject(HttpClient);
    
    return {
      // Méthode synchrone pour mettre à jour la requête
      setQuery(query: string) {
        patchState(store, { query });
      },
      
      // Méthode réactive pour effectuer la recherche
      search: rxMethod<string>(
        pipe(
          // Ignorer les requêtes vides
          filter(query => query.trim().length > 2),
          
          // Attendre que l'utilisateur arrête de taper
          debounceTime(300),
          
          // Indiquer que le chargement est en cours
          tap(() => patchState(store, { isLoading: true, error: null })),
          
          // Effectuer la requête HTTP
          switchMap(query => 
            http.get<any[]>(`/api/search?q=${encodeURIComponent(query)}`).pipe(
              // Gérer le succès
              tap(results => {
                patchState(store, {
                  results,
                  isLoading: false
                });
              }),
              
              // Gérer les erreurs
              catchError(error => {
                console.error('Search failed', error);
                patchState(store, {
                  results: [],
                  isLoading: false,
                  error: 'La recherche a échoué. Veuillez réessayer.'
                });
                return EMPTY; // Ne pas propager l'erreur
              })
            )
          )
        )
      )
    };
  })
);

RXJS CONNAISSANCE REQUISE

L'utilisation de rxMethod nécessite une connaissance de base de RxJS et de ses opérateurs. Si vous n'êtes pas familier avec RxJS, il peut être préférable de commencer par des effets simples avec withHooks.

Gestion des états asynchrones

Pour gérer efficacement les états de chargement, d'erreur et de succès des opérations asynchrones, il faut inclure ces états dans votre modèle et les mettre à jour explicitement :

ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap, catchError, EMPTY } from 'rxjs';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

interface TodoState {
  todos: Todo[];
  isLoading: boolean;
  error: string | null;
}

export const TodoStore = signalStore(
  withState<TodoState>({
    todos: [],
    isLoading: false,
    error: null
  }),
  
  withMethods((store) => {
    const http = inject(HttpClient);
    
    return {
      /**
       * Méthode pour charger les todos depuis l'API
       * Gère manuellement les états de chargement, d'erreur et de succès
       */
      loadTodos: rxMethod<void>(
        pipe(
          // Mettre à jour l'état pour indiquer le chargement
          tap(() => patchState(store, { isLoading: true, error: null })),
          
          switchMap(() => 
            http.get<Todo[]>('/api/todos').pipe(
              // Gérer le succès
              tap(todos => {
                patchState(store, { 
                  todos,
                  isLoading: false 
                });
              }),
              
              // Gérer les erreurs
              catchError(error => {
                console.error('Failed to load todos', error);
                patchState(store, {
                  isLoading: false,
                  error: 'Impossible de charger les tâches. Veuillez réessayer.'
                });
                return EMPTY;
              })
            )
          )
        )
      )
    };
  })
);

Cette approche manuelle vous donne un contrôle total sur la façon dont vous gérez les différents états de vos opérations asynchrones.

STRUCTURE D'ÉTAT COHÉRENTE

Adoptez une structure cohérente pour vos états asynchrones dans toute votre application. Par exemple, utilisez toujours isLoading, error et si nécessaire lastUpdated pour chaque entité que vous chargez depuis une API.

Déclencher des effets

Les effets peuvent être déclenchés de plusieurs façons :

  1. Automatiquement via des effets réactifs qui surveillent l'état
  2. Manuellement en appelant une méthode du store
  3. Au démarrage avec le hook onInit

Exemple d'utilisation dans un composant

ts
import { Component, inject, OnInit } from '@angular/core';
import { SearchStore } from './search.store';

@Component({
  selector: 'app-search',
  template: `
    <div>
      <input 
        [ngModel]="searchStore.query()" 
        (ngModelChange)="updateQuery($event)"
        placeholder="Rechercher..."
      />
      
      @if (searchStore.isLoading()) {
        <div class="spinner">Chargement...</div>
      }
      
      @if (searchStore.error()) {
        <div class="error">{{ searchStore.error() }}</div>
      }
      
      <ul class="results">
        @for (result of searchStore.results(); track result.id) {
          <li>{{ result.title }}</li>
        }
      </ul>
    </div>
  `
})
export class SearchComponent implements OnInit {
  searchStore = inject(SearchStore);
  
  ngOnInit() {
    // Exemple: initialiser avec une recherche par défaut
    this.updateQuery('initial query');
  }
  
  updateQuery(query: string) {
    // Met à jour la requête dans le store
    this.searchStore.setQuery(query);
    
    // Déclenche la recherche
    this.searchStore.search(query);
  }
}

Annulation d'effets

Un avantage majeur de l'utilisation de rxMethod est la gestion automatique des annulations. Par exemple, avec switchMap, si une nouvelle recherche est lancée avant la fin de la précédente, la requête précédente est automatiquement annulée.

Pour une gestion plus explicite des annulations, vous pouvez utiliser takeUntilDestroyed :

ts
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

// Dans un store ou un service
const longOperation = rxMethod<void>(
  pipe(
    switchMap(() => http.get('/api/long-operation').pipe(
      takeUntilDestroyed(), // Annule automatiquement si le composant est détruit
      // ...
    ))
  )
);

Effets combinés et chaînés

Vous pouvez combiner plusieurs effets ou les chaîner pour des workflows complexes :

ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap, mergeMap, map, catchError, of } from 'rxjs';

export const OrderStore = signalStore(
  withState({
    cart: [],
    order: null,
    paymentStatus: null,
    isProcessing: false,
    error: null
  }),
  
  withMethods((store) => {
    const http = inject(HttpClient);
    
    return {
      // Effet qui déclenche une séquence d'opérations
      checkout: rxMethod<{ userId: number }>(
        pipe(
          tap(() => patchState(store, { isProcessing: true, error: null })),
          
          // 1. Créer une commande
          switchMap(({ userId }) => 
            http.post('/api/orders', { userId, items: store.cart() }).pipe(
              // 2. Traiter le paiement pour cette commande
              mergeMap(order => {
                patchState(store, { order });
                return http.post(`/api/payments`, { orderId: order.id });
              }),
              
              // 3. Enregistrer le résultat et nettoyer
              map(paymentResult => {
                patchState(store, {
                  paymentStatus: paymentResult.status,
                  isProcessing: false,
                  // Vider le panier si le paiement est réussi
                  cart: paymentResult.status === 'completed' ? [] : store.cart()
                });
                return paymentResult;
              }),
              
              // Gérer les erreurs à n'importe quelle étape
              catchError(error => {
                patchState(store, {
                  isProcessing: false,
                  error: 'La commande a échoué: ' + error.message
                });
                return of({ status: 'failed', error });
              })
            )
          )
        )
      )
    };
  })
);

Injection de dépendances dans les effets

Vous pouvez facilement injecter des services Angular dans vos effets grâce à la fonction inject :

ts
withMethods((store) => {
  // Injecter des services
  const http = inject(HttpClient);
  const router = inject(Router);
  const notificationService = inject(NotificationService);
  
  return {
    // Utiliser les services injectés dans les effets
    saveUserProfile: rxMethod<UserProfile>(
      pipe(
        switchMap(profile => 
          http.put<UserProfile>(`/api/users/${profile.id}`, profile).pipe(
            tap(updatedProfile => {
              // Mettre à jour l'état
              patchState(store, { profile: updatedProfile });
              
              // Effets secondaires multiples
              notificationService.success('Profil mis à jour avec succès');
              router.navigate(['/profile', updatedProfile.id]);
            })
          )
        )
      )
    )
  };
});

Bonnes pratiques pour les effets

  1. Séparation des préoccupations : Utilisez les effets pour isoler les opérations impures (HTTP, localStorage, etc.) du reste de votre logique métier.

  2. Gestion des erreurs : Assurez-vous de toujours gérer les erreurs dans vos effets pour éviter que les erreurs non interceptées ne se propagent.

  3. Testabilité : Structurez vos effets pour qu'ils soient facilement testables, en injectant les dépendances plutôt qu'en les instanciant directement.

  4. Choix du bon opérateur :

    • switchMap : Pour annuler les requêtes précédentes (idéal pour les recherches)
    • mergeMap : Pour exécuter plusieurs requêtes en parallèle
    • concatMap : Pour exécuter des requêtes dans l'ordre, l'une après l'autre

Exemple complet

Voici un exemple complet d'une gestion d'authentification avec effets :