Fermer

décembre 16, 2025

Services angulaires de gestion d’état + signaux

Services angulaires de gestion d’état + signaux


Découvrez comment Angular Services with Signals peut simplifier l’architecture de votre application et gérer la gestion des états. Faisons-le!

Permettez-moi de commencer par dire que NgRx est excellent et constitue le bon choix dans certaines situations. Mais cela implique également beaucoup de travail de configuration et une courbe d’apprentissage abrupte.

Pendant de nombreuses années, les développeurs Angular ont essayé d’éviter NgRx en utilisant des sujets RxJS et des pipelines observables complexes, mais cela devient également difficile. Vous devez comprendre de nombreux opérateurs, leur fonctionnement et même apprendre des diagrammes en marbre.

Pour la plupart des applications, en particulier celles de petite et moyenne taille, vous n’avez pas besoin de toute cette complexité.

Avec l’introduction de signaux et d’API modernes de récupération de données telles que ressource et httpResourceAngular propose désormais une approche de gestion d’état plus simple et plus intuitive intégrée directement dans le framework.

Dans cet article, nous explorerons comment Services angulaires + signaux peut considérablement simplifier l’architecture de votre application et gérer la gestion de l’état.

Construisons étape par étape une boutique basée sur Signal. Pour commencer, créez une interface pour modéliser la réponse de l’API.

export interface IProduct {
    id: string;
    name: string;
    description: string;
    price: string ;
    category: string; 
}

Ensuite, ajoutez un service appelé ProductStore. Vous pouvez le considérer comme un magasin de signaux qui :

  • Contient l’état de l’application en utilisant signal()
  • Calcule les valeurs dérivées à l’aide de computed() ou linkedSignal()
  • Réagit aux changements en utilisant effect()
  • Expose les méthodes qui mettent à jour l’état
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class Productstrore {
  
}

Nous allons suivre cette approche :
État – Signal. Récupération de données – httpResource. État dérivé – calculé / linkSignal. Changements – effets.

Commençons par récupérer les données de l’API à l’aide de l’API httpResource.

productsResource = httpResource<IProduct[]>(() => ({
  url: this.apiUrl,
  method: 'GET'
}));

Ensuite, créons un état réactif pour contenir la ressource valeur, statut et erreur. Nous utiliserons computed() pour cela car httpResource expose ces champs comme signaux en lecture seule.

products = computed(() => {
    if (this.productsResource.hasValue()) {
      return this.productsResource.value();
    }
    return [];
  });
  
  loading = computed(() => this.productsResource.isLoading());
  error = computed(() => this.productsResource.error());
  status = computed(() => this.productsResource.status());

Ajoutez également une méthode pour actualiser la ressource afin que chaque fois que des données sont ajoutées ou mutées, elle se recharge.

refresh() {
    this.productsResource.reload();
  }

Pour l’instant, nous disposons de données de manière réactive. Maintenant, utilisons ceci sur un composant pour afficher les produits.

@Component({
  selector: 'app-product',
  imports: [],
  templateUrl: './product.html',
  styleUrl: './product.css',
})
export class Product {

    store  = inject(Productstrore);

}

Et dans le modèle, nous pouvons utiliser tous les signaux calculés de l’état, comme indiqué ci-dessous.

@if(store.products()){
<table class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Price</th>
        <th>Cateogry</th>
    </tr>
    @for(product of store.products();track product.id) {
    <tr>
        <td>{{product.id}}</td>
        <td>{{product.name}}</td>
        <td>{{product.price}}</td>
        <td>{{product.category}}</td>
    </tr>
}
</table>
}
@else if(store.loading()){
    <div class="text-center">
        <div class="spinner-border text-primary" role="status">
            <span class="visually-hidden">Loading...</span>
        </div>
    </div>
}
@else if(store.error()){
    <div class="alert alert-danger text-center">
        {{store.error()?.message}}
    </div>
}

À partir de maintenant, vous avez récupéré les données du magasin et les avez affichées dans le composant.

Ajout d’un enregistrement

Maintenant, ajoutons des fonctionnalités au magasin pour créer un nouvel enregistrement à l’aide de l’API. Pour ce faire, nous ajouterons deux signaux et leurs signaux calculés correspondants pour suivre les états de chargement et d’erreur.

  private addProductLoading = signal(false);
  private addProductError = signal<string | null>(null);
  isAddingProduct = computed(() => this.addProductLoading());
  isAddProductError = computed(() => this.addProductError());

Ensuite, ajoutez une fonction qui envoie une requête POST au point de terminaison pour créer un nouveau produit. Puisque nous ajoutons un nouvel enregistrement, nous n’utiliserions pas httpResource. Au lieu de cela, nous utiliserons HttpClient pour effectuer l’opération POST. Dans cette fonction, nous mettrons également à jour les signaux de chargement et d’erreur en fonction de la réponse.

   addProduct = (product: IProduct) => {
    
    this.addProductLoading.set(true);
    this.http.post<IProduct>(this.apiUrl, product).subscribe({
      next: (newProduct) => {
        this.addProductLoading.set(false);
        this.refresh();
      },
      error: (error) => {
        console.error('Error adding product:', error);
        this.addProductLoading.set(false);
        this.addProductError.set('Failed to add product');
      }
    });

  }


Désormais, nous avons ajouté un produit utilisant l’ancien httpClient, mais gérant la réponse de manière réactive en utilisant les signaux. Maintenant, utilisons ceci sur un composant pour ajouter un produit. Sur le composant, pour ajouter un produit, créons une forme de signal.

export class AddProduct {

  store = inject(Productstrore);
  
  productModel = signal<IProduct>({
    id: "",
    name: "",
    description: "",
    price: "",
    category: "",
  })

  productForm = form(this.productModel)

  add(){
    let data = this.productModel();
    console.log('Adding product:', data);
    this.store.addProduct(data);
  }

}

Ensuite, dans le modèle, créez un formulaire et utilisez les signaux de chargement et d’erreur du magasin pour gérer l’interface utilisateur, comme indiqué ci-dessous.

<h2 class="text-center text-info">Add Product</h2>
<hr />
<form class="form form-group">
    <input class="form-control" [field]="productForm.id" placeholder="Enter Id" />
    <br />
    <input class="form-control" [field]="productForm.name" placeholder="Enter Name" />
    <br />
    <input class="form-control" [field]="productForm.price" placeholder="Enter Price" />
    <br />
    <input class="form-control" [field]="productForm.description" placeholder="Enter Description" />
    <br />
    <input class="form-control" [field]="productForm.category" placeholder="Enter Cateogry" />
    <br />
</form>
<button class="btn btn-primary" (click)="add()">Add</button>

@if(store.isAddingProduct()){
<div class="text-center mt-3">
    <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">Adding...</span>
    </div>
</div>
}
@else if(store.isAddProductError()){
<div class="alert alert-danger text-center mt-3">
    {{store.isAddProductError()}}
</div>
}
<div>
</div>

Si vous placez le Ajouter un produit composant et le Produit composant les uns à côté des autres, comme indiqué ci-dessous, puis dès que vous ajoutez un nouveau produit, le composant Produit sera automatiquement mis à jour avec la dernière liste.

<div class="row">
    <div class="col-md-7">
     <app-product></app-product>
    </div>
    <div class="col-md-5">
      <app-add-product></app-add-product>
    </div>
  </div>

Le résultat attendu doit être le suivant : répertorier les produits et ajouter un produit du magasin.

Liste des produits avec identifiant, nom, prix, catégorie. Ajoutez un formulaire de produit avec les champs correspondants.

En mettant le tout ensemble, le Magasin de produits ça ressemble maintenant à ça. Il récupère les données de l’API et ajoute de nouveaux enregistrements à l’aide de l’API. L’ensemble du magasin est entièrement réactif et utilise des signaux et des concepts associés pour toutes les opérations.

import { HttpClient, httpResource } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { IProduct } from './product-model';

@Injectable({
  providedIn: 'root',
})
export class Productstrore {

  private readonly apiUrl = 'http://localhost:3000/product';
  private http = inject(HttpClient);

  productsResource = httpResource<IProduct[]>(() => ({
    url: this.apiUrl,
    method: 'GET'
  }));

  products = computed(() => {
    if (this.productsResource.hasValue()) {
      return this.productsResource.value();
    }
    return [];
  });

  loading = computed(() => this.productsResource.isLoading());
  error = computed(() => this.productsResource.error());
  status = computed(() => this.productsResource.status());

  refresh() {
    this.productsResource.reload();
  }

  private addProductLoading = signal(false);
  private addProductError = signal<string | null>(null);
  isAddingProduct = computed(() => this.addProductLoading());
  isAddProductError = computed(() => this.addProductError());

  addProduct = (product: IProduct) => {

    this.addProductLoading.set(true);
    this.http.post<IProduct>(this.apiUrl, product).subscribe({
      next: (newProduct) => {
        this.addProductLoading.set(false);
        this.refresh();
      },
      error: (error) => {
        console.error('Error adding product:', error);
        this.addProductLoading.set(false);
        this.addProductError.set('Failed to add product');
      }
    });
  }
}

Création d’un magasin de paniers

Construire une charrette est l’un des exemples les plus courants de gestion étatique. Voyons donc comment le créer. Dans le magasin de paniers, l’utilisateur doit pouvoir :

  • Ajouter un produit au panier
  • Augmenter la quantité
  • Diminuer la quantité
  • Vider le panier

Pour commencer, créez une interface pour modéliser le Cart.

import { IProduct } from "./product-model";

export interface ICartItem {
  product: IProduct;
  quantity: number;
  subtotal: number;
}

Ensuite, ajoutez un service appelé Panier.

@Injectable({
  providedIn: 'root',
})
export class Cartstore {

  private _cartItems = signal<ICartItem[]>([]);

  cartItems = computed(() => this._cartItems());

  cartCount = computed(() =>
    this._cartItems().reduce((total, item) => total + item.quantity, 0)
  );

  cartTotal = computed(() =>
    this._cartItems().reduce((total, item) => total + item.subtotal, 0)
  );

  isEmpty = computed(() => this._cartItems().length === 0);

}

Dans le Cartstore, nous avons ajouté :

  • Signal calculé pour contenir les articles du panier
  • Signal calculé pour compter la quantité totale
  • Signal calculé pour calculer le prix total
  • Signal calculé pour vider le chariot

Ensuite, ajoutez une fonction qui ajoute un produit au panier ou met à jour sa quantité si le produit est déjà dans le panier.

addToCart(product: IProduct): void {
    const currentItems = this._cartItems();
    const existingItemIndex = currentItems.findIndex(item => item.product.id === product.id);

    if (existingItemIndex >= 0) {
      const updatedItems = [...currentItems];
      const existingItem = updatedItems[existingItemIndex];
      const newQuantity = existingItem.quantity + 1;
      const price = parseFloat(product.price);

      updatedItems[existingItemIndex] = {
        ...existingItem,
        quantity: newQuantity,
        subtotal: price * newQuantity
      };

      this._cartItems.set(updatedItems);

    } else {
      const price = parseFloat(product.price);
      const quantity = 1;
      const newItem: ICartItem = {
        product,
        quantity,
        subtotal: price * quantity
      };

      this._cartItems.update(items => [...items, newItem]);
    }
  }

Si vous regardez la fonction ci-dessus, la majeure partie de la logique est une simple manipulation de tableau. Après avoir mis à jour le tableau, nous définissons le cartItems signal pour partager l’état mis à jour.
De la même manière, vous pouvez créer une fonction pour supprimer un produit du panier.

removeFromCart(productId: string): void {
    const currentItems = this._cartItems();
    const item = currentItems.find(item => item.product.id === productId);
    if (item) {
      if (item?.quantity == 1) {
        this._cartItems.update(items =>
          items.filter(item => item.product.id !== productId)
        );
        return;
      }
      else {
        const price = parseFloat(item!.product.price);
        const quantity = item!.quantity - 1;
        this._cartItems.update(items =>
          items.map(item => {
            if (item.product.id === productId) {
              return {
                ...item,
                quantity,
                subtotal: price * quantity
              };
            }
            return item;
          })
        );
      }
      return;
    }
  }

Encore une fois, l’essentiel de la logique est une simple manipulation de tableau. Après avoir mis à jour le tableau, nous définissons le cartItems signal pour partager l’état mis à jour.

Pour l’instant, nous avons Cartstore en place. Maintenant, utilisons ceci sur un composant pour afficher les paniers. Pour cela, injectez d’abord le store dans le composant.

@Component({
  selector: 'app-cart',
  imports: [CurrencyPipe],
  templateUrl: './cart.html',
  styleUrl: './cart.css',
})
export class Cart {

  store = inject(Cartstore);
  
}

Sur le modèle, affichez les paniers et le prix total en utilisant les signaux calculés du magasin comme indiqué ci-dessous :

<div>
    <h2>Cart</h2>
    <ul class="list-group">
        @for(item of store.cartItems();track item.product.id){
        <li class="list-group-item">
        {{ item.product.name }} - {{ item.quantity }} x {{ item.product.price | currency}} =  {{ item.subtotal |currency}}
        </li>
    }
    </ul>
    <h3>Total Price = {{store.cartTotal()|currency}}</h3>

</div>

Aussi, dans le Produit composant, injectez le Cartstore et ajoutez des fonctions à ajouter et à supprimer du panier comme indiqué ci-dessous :

 cartstore = inject(Cartstore);

  addToCart(product: IProduct) {
    this.cartstore.addToCart(product);
  }
  removeFromCart(productId: string) {
    this.cartstore.removeFromCart(productId);
  }

Sur le modèle, mettez à jour la table Product avec deux boutons pour ajouter et supprimer comme indiqué ci-dessous :

<table class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Price</th>
        <th>Cateogry</th>
    </tr>
    @for(product of store.products();track product.id) {
    <tr>
        <td>{{product.id}}</td>
        <td>{{product.name}}</td>
        <td>{{product.price}}</td>
        <td>{{product.category}}</td>
        <td>
            <button class="btn btn-primary" (click)="addToCart(product)">+</button>
            <button class="btn btn-danger" (click)="removeFromCart(product.id)">-</button>
        </td>
}
</table>

En mettant tout ensemble, le résultat devrait être :

La liste des produits comprend désormais les options + et -. Le panier affiche le prix total.

Nous utilisons les composants Product, AddProduct et Cart ensemble dans le composant App.

<div class="row">
    <div class="col-md-7">
     <app-product></app-product>
     </div>
    <div class="col-md-5">
      <app-add-product></app-add-product>
      <app-cart></app-cart>
    </div>
  </div>

Résumé

Dans cet article, vous avez appris qu’il est tout à fait possible de créer et de gérer un état dans une application Angular en utilisant signaux, calculé et httpResource. Avec ces fonctionnalités angulaires modernes, vous pouvez éviter la complexité de NgRx tout en créant une gestion d’état propre et réactive pour votre application.

J’espère que vous avez trouvé cela utile. Merci d’avoir lu!




Source link