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.

Utilisation efficace de NgRx Signal Store

Dans ce tutoriel, nous allons explorer les meilleures pratiques et les modèles de conception pour utiliser NgRx Signal Store de manière efficace dans vos applications Angular. Vous découvrirez comment structurer vos stores, quand les utiliser, et comment les intégrer harmonieusement dans l'architecture de votre application.

Dans la vie quotidienne

Pour comprendre l'utilisation optimale de NgRx Signal Store, pensez à l'organisation d'une bibliothèque. Dans une bibliothèque bien gérée, les livres sont classés par catégories (comme vos différents stores), les sections sont clairement identifiées (comme les différentes parties de votre état), et il existe un système de catalogage (équivalent à vos selectors) qui vous permet de trouver rapidement ce que vous cherchez. De plus, il y a des procédures pour emprunter ou retourner des livres (comme vos méthodes de mise à jour). Cette organisation permet à la bibliothèque de fonctionner efficacement, tout comme une bonne structure de stores permet à votre application de gérer l'état de manière optimale.

Quand utiliser NgRx Signal Store

NgRx Signal Store est particulièrement adapté pour :

  1. États partagés : Lorsque plusieurs composants ont besoin d'accéder et de modifier le même état.
  2. États complexes : Quand l'état de votre application devient trop complexe pour être géré directement dans les composants.
  3. Logique métier centralisée : Pour centraliser la logique de votre application indépendamment des composants.
  4. Opérations asynchrones : Pour gérer proprement les requêtes HTTP, les WebSockets, ou d'autres opérations asynchrones.
  5. États persistants : Lorsque vous devez persister et restaurer l'état (session utilisateur, préférences, etc.).

En revanche, vous pourriez ne pas avoir besoin de Signal Store pour :

  1. Formulaires simples : Pour des formulaires basiques, FormGroup et FormControl peuvent suffire.
  2. États locaux éphémères : Utilisez simplement signal() dans le composant pour des états temporaires qui ne concernent qu'un seul composant.
  3. Applications très petites : Pour des applications minuscules, la surcharge d'architecture pourrait ne pas être justifiée.

Architecture recommandée

Structuration des stores

Pour les applications de taille moyenne à grande, nous recommandons la structure suivante :

src/
  app/
    shared/
      models/             # Interfaces et types partagés
      utils/              # Fonctions utilitaires
    
    core/
      stores/            # Stores globaux
        auth/
          auth.interface.ts
          auth.store.ts
        theme/
          theme.interface.ts
          theme.store.ts
    
    features/
      products/
        stores/          # Stores spécifiques aux fonctionnalités
          product.interface.ts
          product.store.ts
        components/
        services/
      cart/
        stores/
          cart.interface.ts
          cart.store.ts
        components/
        services/

Niveaux de stores

Nous recommandons de distinguer trois niveaux de stores :

  1. Global Stores (niveau application) : Injectés avec { providedIn: 'root' }, ils gèrent les états partagés par toute l'application (authentification, thème, etc.).

  2. Feature Stores (niveau fonctionnalité) : Configurés au niveau d'un module de fonctionnalité ou d'un composant parent, ils gèrent l'état spécifique à une fonctionnalité.

  3. Component Stores (niveau composant) : Fournis directement dans un composant, ils gèrent l'état local d'un composant ou d'un petit groupe de composants.

Bonnes pratiques

1. Interfaces et typage fort

Définissez toujours des interfaces claires pour vos états :

ts
// user.interface.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

export interface UserState {
  users: User[];
  selectedUserId: number | null;
  isLoading: boolean;
  error: string | null;
}

2. Séparation des préoccupations

Séparez la définition de l'état, la lecture et l'écriture :

ts
// user.store.ts
export const UserStore = signalStore(
  // État initial
  withState<UserState>({ /*...*/ }),
  
  // Lecture (computed signals)
  withComputed(/*...*/),
  
  // Écriture (méthodes)
  withMethods(/*...*/),
  
  // Effets secondaires
  withEffects(/*...*/)
);

3. Actions atomiques et significatives

Créez des méthodes qui représentent des actions significatives du domaine :

ts
// MAUVAIS
updateUser(userId: number, data: any) {
  // Code générique sans validation
}

// BON
promoteToAdmin(userId: number) {
  const user = this.getUserById(userId);
  if (!user) throw new Error('User not found');
  
  patchState(store, (state) => ({
    users: state.users.map(u => 
      u.id === userId ? { ...u, role: 'admin' } : u
    )
  }));
  
  // Événements de journalisation ou autres effets secondaires
  this.logUserAction(userId, 'promoted to admin');
}

4. Immutabilité

Respectez toujours l'immutabilité lors des mises à jour de l'état :

ts
// MAUVAIS: Mutation directe
addItem(item) {
  // ❌ Ne fonctionne pas, mutation directe
  store.items.push(item);
}

// BON: Update immutable
addItem(item) {
  patchState(store, (state) => ({
    items: [...state.items, item]
  }));
}

5. Erreurs et chargement

Gérez systématiquement les états de chargement et d'erreur :

ts
fetchUsers: rxMethod<void>(
  pipe(
    tap(() => patchState(store, { isLoading: true, error: null })),
    switchMap(() => 
      http.get<User[]>('/api/users').pipe(
        tap(users => {
          patchState(store, { users, isLoading: false });
        }),
        catchError(error => {
          patchState(store, { 
            isLoading: false, 
            error: 'Impossible de charger les utilisateurs'
          });
          return EMPTY;
        })
      )
    )
  )
)

6. Optimisations de performance

Évitez les recalculs inutiles en utilisant judicieusement les signaux calculés :

ts
// Potentiellement inefficace si appelé souvent
getNonAdminUsers() {
  return store.users().filter(user => user.role !== 'admin');
}

// Optimisé avec computed
withComputed((store) => ({
  nonAdminUsers: computed(() => 
    store.users().filter(user => user.role !== 'admin')
  )
}))

7. Composition de stores

Pour les applications complexes, décomposez et composez vos stores :

ts
// Créer des features réutilisables
function withPagination<T extends object>() {
  return signalStoreFeature(
    withState({
      page: 1,
      pageSize: 20,
      totalItems: 0
    }),
    withMethods((store) => ({
      nextPage() {
        patchState(store, { page: store.page() + 1 });
      },
      prevPage() {
        if (store.page() > 1) {
          patchState(store, { page: store.page() - 1 });
        }
      },
      goToPage(page: number) {
        patchState(store, { page });
      }
    }))
  );
}

// Utiliser la feature dans différents stores
export const ProductStore = signalStore(
  withState<ProductState>({ products: [] }),
  withPagination(), // Ajoute les fonctionnalités de pagination
  // ...autres features
);

Modèles courants

Communication entre composants

NgRx Signal Store simplifie la communication entre composants :

ts
// Dans un composant parent ou enfant
const userStore = inject(UserStore);

// Réagir aux changements
effect(() => {
  const selectedUser = userStore.selectedUser();
  if (selectedUser) {
    // Faire quelque chose quand l'utilisateur sélectionné change
  }
});

// Mettre à jour depuis n'importe quel composant
userStore.selectUser(123);

Gestion des formulaires

Synchroniser les formulaires avec votre état :

ts
import { Component, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  template: `
    <form [formGroup]="userForm" (ngSubmit)="onSubmit()">
      <input formControlName="name" />
      <input formControlName="email" />
      <button type="submit">Sauvegarder</button>
    </form>
  `
})
export class UserEditComponent implements OnInit {
  userStore = inject(UserStore);
  fb = inject(FormBuilder);
  
  userForm = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]]
  });
  
  ngOnInit() {
    // Remplir le formulaire avec les données actuelles
    const user = this.userStore.selectedUser();
    if (user) {
      this.userForm.patchValue({
        name: user.name,
        email: user.email
      });
    }
  }
  
  onSubmit() {
    if (this.userForm.invalid) return;
    
    const userId = this.userStore.selectedUserId();
    if (userId) {
      this.userStore.updateUser(userId, this.userForm.value);
    }
  }
}

Lazy loading et stores par fonctionnalité

Pour les applications de grande taille avec lazy loading :

ts
// Dans un module de fonctionnalité
@NgModule({
  imports: [...],
  providers: [
    // Fournir le store au niveau du module
    ProductStore
  ]
})
export class ProductModule { }

Dans un composant standalone :

ts
@Component({
  standalone: true,
  imports: [...],
  providers: [ProductStore], // Portée limitée au composant et ses enfants
  template: `...`
})
export class ProductsComponent { }

Sélecteurs avec paramètres

Les sélecteurs paramétrés peuvent être implémentés comme des méthodes retournant des signaux calculés :

ts
// Dans le store
withMethods((store) => ({
  getProductsByCategory(category: string) {
    return computed(() => 
      store.products().filter(p => p.category === category)
    );
  }
}))

// Utilisation
@Component({
  template: `
    <div>
      <h2>Électronique</h2>
      <product-list [products]="electronicsProducts()"></product-list>
      
      <h2>Vêtements</h2>
      <product-list [products]="clothingProducts()"></product-list>
    </div>
  `
})
export class CategoryComponent {
  productStore = inject(ProductStore);
  
  electronicsProducts = this.productStore.getProductsByCategory('electronics');
  clothingProducts = this.productStore.getProductsByCategory('clothing');
}

PERFORMANCES

Les sélecteurs avec paramètres créent de nouveaux signals calculés à chaque appel. Utilisez-les judicieusement et envisagez de les mettre en cache si nécessaire.

Gestion des mises à jour optimistes

Pour une meilleure expérience utilisateur, implémentez des mises à jour optimistes :

ts
updateUser: rxMethod<{ userId: number, user: Partial<User> }>(
  pipe(
    tap(({ userId, user }) => {
      // 1. Sauvegarde de l'état actuel
      const currentUsers = store.users();
      const oldUser = currentUsers.find(u => u.id === userId);
      
      // 2. Mise à jour optimiste
      patchState(store, (state) => ({
        users: state.users.map(u => 
          u.id === userId ? { ...u, ...user } : u
        )
      }));
      
      // 3. Requête API
      return { userId, user, oldUser, currentUsers };
    }),
    
    switchMap(({ userId, user, oldUser, currentUsers }) =>
      http.patch<User>(`/api/users/${userId}`, user).pipe(
        // 4. En cas de succès, utiliser la réponse du serveur
        tap(updatedUser => {
          patchState(store, (state) => ({
            users: state.users.map(u => 
              u.id === userId ? updatedUser : u
            )
          }));
        }),
        
        // 5. En cas d'erreur, restaurer l'état précédent
        catchError(error => {
          console.error('Update failed', error);
          
          // Restaurer l'état précédent
          patchState(store, { users: currentUsers });
          
          // Afficher une notification d'erreur
          notificationService.error('La mise à jour a échoué');
          
          return EMPTY;
        })
      )
    )
  )
)

Stratégies de débogage

Pour faciliter le débogage de vos stores :

  1. Journalisation des changements
ts
import { signalStore, withState, withHooks } from '@ngrx/signals';
import { effect } from '@angular/core';

export const DebugStore = signalStore(
  withState({ /*...*/ }),
  withHooks({
    onInit(store) {
      if (environment.development) {
        effect(() => {
          // Log chaque changement d'état
          console.log('State updated:', {
            property1: store.property1(),
            property2: store.property2(),
            // ...
          });
        });
      }
    }
  })
);
  1. Redux DevTools Integration

Si vous utilisez beaucoup de Signal Stores, vous pouvez créer un wrapper pour les intégrer avec Redux DevTools :

ts
// Utilitaire simpliste pour l'exemple
function withDevTools<T>(name: string, store: T): T {
  if (!environment.production && (window as any).__REDUX_DEVTOOLS_EXTENSION__) {
    const devTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect({
      name: name,
      features: { jump: true }
    });
    
    // Créer un effet qui envoie chaque changement à DevTools
    effect(() => {
      const state = {};
      // Collecter toutes les propriétés du store
      for (const key of Object.keys(store)) {
        if (typeof store[key] === 'function' && key !== 'constructor') {
          try {
            const value = store[key]();
            state[key] = value;
          } catch (e) {
            // Ignorer les méthodes qui ne sont pas des signaux
          }
        }
      }
      
      devTools.send('state_updated', state);
    });
  }
  
  return store;
}

// Utilisation
export const debuggableStore = withDevTools('UserStore', inject(UserStore));