Créer des Services Angular - Jipmaa/WE4B GitHub Wiki
Ce guide explique comment créer des services Angular robustes et évolutifs en utilisant la gestion d'état moderne basée sur les signaux, en suivant les modèles établis dans notre application.
Les Signaux sont la nouvelle primitive réactive d'Angular introduite dans Angular 16+. Pensez à un signal comme une "variable intelligente" qui peut notifier d'autres parties de votre application lorsque sa valeur change.
Approche Traditionnelle (sans signaux) :
export class OldService {
private users: User[] = [];
private usersSubject = new BehaviorSubject<User[]>([]);
users$ = this.usersSubject.asObservable();
setUsers(users: User[]) {
this.users = users;
this.usersSubject.next(users); // Notification manuelle
}
}
Approche par Signaux :
export class NewService {
private _users = signal<User[]>([]);
readonly users = this._users.asReadonly();
setUsers(users: User[]) {
this._users.set(users); // Notification automatique
}
}
- Détection automatique des changements : Pas besoin de notifier manuellement les abonnés
- Type-safe : Support complet de TypeScript
- Performance : Plus efficace que RxJS pour un état simple
- Syntaxe plus simple : Moins de code répétitif
- Valeurs calculées : Mises à jour automatiques d'état dérivé
import { signal, computed, effect } from '@angular/core';
export class ExampleService {
// Créer un signal avec valeur initiale
private _counter = signal<number>(0);
private _name = signal<string>('');
private _isLoading = signal<boolean>(false);
// Exposer des versions en lecture seule
readonly counter = this._counter.asReadonly();
readonly name = this._name.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
// Lire les valeurs des signaux (dans les méthodes)
getCurrentCounter(): number {
return this._counter(); // Appeler comme une fonction
}
// Mettre à jour les valeurs des signaux
incrementCounter(): void {
this._counter.set(this._counter() + 1); // Définir nouvelle valeur
}
updateName(newName: string): void {
this._name.set(newName);
}
// Mettre à jour basé sur la valeur actuelle
doubleCounter(): void {
this._counter.update(current => current * 2);
}
}
Set : Remplacer toute la valeur
this._users.set([user1, user2, user3]);
this._isLoading.set(true);
Update : Modifier basé sur la valeur actuelle
this._users.update(currentUsers => [...currentUsers, newUser]);
this._counter.update(current => current + 1);
Get : Lire la valeur actuelle
const currentUsers = this._users(); // Appeler comme fonction
const userCount = this._users().length;
Les signaux calculés se mettent à jour automatiquement quand leurs dépendances changent :
export class ExampleService {
private _users = signal<User[]>([]);
private _filter = signal<string>('');
readonly users = this._users.asReadonly();
readonly filter = this._filter.asReadonly();
// Les signaux calculés se mettent à jour automatiquement
readonly filteredUsers = computed(() => {
const users = this._users();
const filter = this._filter();
if (!filter) return users;
return users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
});
readonly userCount = computed(() => this._users().length);
readonly hasUsers = computed(() => this.userCount() > 0);
readonly activeUserCount = computed(() =>
this._users().filter(user => user.isActive).length
);
}
Les effets s'exécutent quand les dépendances des signaux changent :
import { effect } from '@angular/core';
export class ExampleService {
private _users = signal<User[]>([]);
constructor() {
// L'effet s'exécute chaque fois que _users change
effect(() => {
const users = this._users();
console.log(`Le nombre d'utilisateurs a changé à : ${users.length}`);
// Pourrait sauvegarder dans localStorage, envoyer analytics, etc.
localStorage.setItem('userCount', users.length.toString());
});
}
}
@Component({
template: `
<div>
<!-- Accès direct aux signaux dans les templates -->
<h2>Utilisateurs ({{ userService.userCount() }})</h2>
<!-- État de chargement -->
@if (userService.isLoading()) {
<div>Chargement...</div>
}
<!-- Liste des utilisateurs -->
@for (user of userService.filteredUsers(); track user.id) {
<div>{{ user.name }}</div>
}
<!-- Valeurs calculées -->
@if (userService.hasUsers()) {
<p>Utilisateurs actifs : {{ userService.activeUserCount() }}</p>
} @else {
<p>Aucun utilisateur trouvé</p>
}
</div>
`
})
export class UserComponent {
protected readonly userService = inject(UserService);
// Vous pouvez aussi créer des signaux calculés dans les composants
readonly displayMessage = computed(() => {
const count = this.userService.userCount();
const loading = this.userService.isLoading();
if (loading) return 'Chargement des utilisateurs...';
if (count === 0) return 'Aucun utilisateur trouvé';
return `Trouvé ${count} utilisateur${count === 1 ? '' : 's'}`;
});
}
Note : Ce guide utilise la nouvelle syntaxe de flux de contrôle d'Angular 17+ (
@if
,@for
,@else
) qui offre de meilleures performances et une syntaxe plus simple que les directives structurelles traditionnelles (*ngIf
,*ngFor
).
@Component({...})
export class UserComponent {
protected readonly userService = inject(UserService);
constructor() {
// Réagir aux changements d'état du service
effect(() => {
const error = this.userService.error();
if (error) {
this.notificationService.showError(error);
}
});
// Charger les données quand le composant s'initialise
effect(() => {
if (!this.userService.hasUsers()) {
this.userService.loadUsers();
}
});
}
}
Maintenant que vous comprenez les signaux, nos services suivent une architecture cohérente qui combine :
- Gestion d'état basée sur les signaux pour les données réactives
- Opérations client HTTP pour la communication API
- Signaux calculés pour l'état dérivé
- Gestion d'erreurs avec messages conviviaux
- Méthodes utilitaires pour les opérations communes
Chaque service suit cette structure fondamentale :
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, catchError, tap, throwError } from 'rxjs';
import { environment } from '../../../environments/environment';
import { YourModel, YourFilters, YourResponse } from '../models/your-model.models';
import { ApiResponse } from '../models/_shared.models';
@Injectable({
providedIn: 'root'
})
export class YourService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/your-endpoint`;
// Signaux privés pour l'état interne
private readonly _items = signal<YourModel[]>([]);
private readonly _selectedItem = signal<YourModel | null>(null);
private readonly _isLoading = signal<boolean>(false);
private readonly _error = signal<string | null>(null);
private readonly _currentFilters = signal<YourFilters>({});
private readonly _pagination = signal<YourResponse['pagination'] | null>(null);
// Signaux publics en lecture seule
readonly items = this._items.asReadonly();
readonly selectedItem = this._selectedItem.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly error = this._error.asReadonly();
readonly currentFilters = this._currentFilters.asReadonly();
readonly pagination = this._pagination.asReadonly();
// Signaux calculés pour l'état dérivé
readonly totalItems = computed(() => this._pagination()?.totalItems || 0);
readonly hasItems = computed(() => this._items().length > 0);
// Les méthodes iront ici...
}
Angular Core
import { Injectable, signal, computed, inject } from '@angular/core';
Opérations HTTP
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
RxJS pour la Programmation Réactive
import { Observable, catchError, tap, throwError } from 'rxjs';
Environnement et Modèles
import { environment } from '../../../environments/environment';
import { YourModels } from '../models/your-models';
import { ApiResponse } from '../models/_shared.models';
Utilisez des signaux privés avec le préfixe _
pour la gestion d'état interne :
// Signaux de données principales
private readonly _items = signal<Item[]>([]);
private readonly _selectedItem = signal<Item | null>(null);
private readonly _stats = signal<ItemStats | null>(null);
// Signaux d'état UI
private readonly _isLoading = signal<boolean>(false);
private readonly _error = signal<string | null>(null);
// Signaux de filtres et pagination
private readonly _currentFilters = signal<ItemFilters>({});
private readonly _pagination = signal<ItemResponse['pagination'] | null>(null);
Exposez l'état comme signaux en lecture seule pour empêcher la modification externe :
// Accès public en lecture seule à l'état
readonly items = this._items.asReadonly();
readonly selectedItem = this._selectedItem.asReadonly();
readonly stats = this._stats.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly error = this._error.asReadonly();
readonly currentFilters = this._currentFilters.asReadonly();
readonly pagination = this._pagination.asReadonly();
Créez des signaux calculés pour les données qui dérivent d'autres signaux :
// Signaux calculés de base
readonly totalItems = computed(() => this._pagination()?.totalItems || 0);
readonly hasItems = computed(() => this._items().length > 0);
readonly isEmpty = computed(() => !this.hasItems() && !this.isLoading());
// Signaux calculés avancés
readonly activeItems = computed(() =>
this._items().filter(item => item.isActive)
);
readonly itemsByCategory = computed(() => {
const items = this._items();
const categories = new Map<string, Item[]>();
items.forEach(item => {
if (item.category) {
if (!categories.has(item.category)) {
categories.set(item.category, []);
}
categories.get(item.category)!.push(item);
}
});
return categories;
});
// Signaux calculés statistiques
readonly averageCapacity = computed(() => {
const items = this._items();
return items.length > 0
? Math.round(items.reduce((sum, item) => sum + item.capacity, 0) / items.length)
: 0;
});
GET de Base avec Paramètres
getItems(filters: ItemFilters = {}): Observable<ApiResponse<ItemResponse>> {
this._isLoading.set(true);
this._error.set(null);
this._currentFilters.set(filters);
let params = new HttpParams();
// Construire les paramètres de requête
if (filters.page) params = params.set('page', filters.page.toString());
if (filters.limit) params = params.set('limit', filters.limit.toString());
if (filters.search) params = params.set('search', filters.search);
if (filters.category) params = params.set('category', filters.category);
if (filters.isActive !== undefined) {
params = params.set('isActive', filters.isActive.toString());
}
return this.http.get<ApiResponse<ItemResponse>>(this.baseUrl, { params })
.pipe(
tap(response => {
if (response.success) {
this._items.set(response.data.items);
this._pagination.set(response.data.pagination);
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
GET Élément Unique par ID
getItemById(id: string): Observable<ApiResponse<{ item: Item }>> {
this._isLoading.set(true);
return this.http.get<ApiResponse<{ item: Item }>>(`${this.baseUrl}/${id}`)
.pipe(
tap(response => {
if (response.success) {
this._selectedItem.set(response.data.item);
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
GET avec Points de Terminaison Personnalisés
getItemStats(): Observable<ApiResponse<ItemStats>> {
this._isLoading.set(true);
return this.http.get<ApiResponse<ItemStats>>(`${this.baseUrl}/stats`)
.pipe(
tap(response => {
if (response.success) {
this._stats.set(response.data);
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
searchItems(term: string, limit: number = 10): Observable<ApiResponse<ItemSearchResult>> {
this._isLoading.set(true);
let params = new HttpParams();
if (limit) params = params.set('limit', limit.toString());
return this.http.get<ApiResponse<ItemSearchResult>>(`${this.baseUrl}/search/${term}`, { params })
.pipe(
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
createItem(itemData: CreateItemRequest): Observable<ApiResponse<{ item: Item }>> {
this._isLoading.set(true);
this._error.set(null);
return this.http.post<ApiResponse<{ item: Item }>>(this.baseUrl, itemData)
.pipe(
tap(response => {
if (response.success) {
// Ajouter le nouvel élément au début de la liste actuelle
const currentItems = this._items();
this._items.set([response.data.item, ...currentItems]);
// Mettre à jour la pagination si disponible
const currentPagination = this._pagination();
if (currentPagination) {
this._pagination.set({
...currentPagination,
totalItems: currentPagination.totalItems + 1
});
}
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
updateItem(id: string, itemData: UpdateItemRequest): Observable<ApiResponse<{ item: Item }>> {
this._isLoading.set(true);
this._error.set(null);
return this.http.put<ApiResponse<{ item: Item }>>(`${this.baseUrl}/${id}`, itemData)
.pipe(
tap(response => {
if (response.success) {
// Mettre à jour l'élément dans la liste actuelle
const currentItems = this._items();
const updatedItems = currentItems.map(item =>
item._id === id ? response.data.item : item
);
this._items.set(updatedItems);
// Mettre à jour l'élément sélectionné si c'est le même élément
if (this._selectedItem()?._id === id) {
this._selectedItem.set(response.data.item);
}
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
// Opérations de mise à jour spécialisées
toggleItemStatus(id: string): Observable<ApiResponse<{ item: Item }>> {
this._isLoading.set(true);
this._error.set(null);
return this.http.put<ApiResponse<{ item: Item }>>(`${this.baseUrl}/${id}/toggle-status`, {})
.pipe(
tap(response => {
if (response.success) {
this.updateItemInState(id, response.data.item);
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
deleteItem(id: string): Observable<ApiResponse<{ deletedItem: Partial<Item> }>> {
this._isLoading.set(true);
return this.http.delete<ApiResponse<{ deletedItem: Partial<Item> }>>(`${this.baseUrl}/${id}`)
.pipe(
tap(response => {
if (response.success) {
// Retirer l'élément de la liste actuelle
const currentItems = this._items();
const updatedItems = currentItems.filter(item => item._id !== id);
this._items.set(updatedItems);
// Effacer l'élément sélectionné si c'est l'élément supprimé
if (this._selectedItem()?._id === id) {
this._selectedItem.set(null);
}
// Mettre à jour la pagination
const currentPagination = this._pagination();
if (currentPagination) {
this._pagination.set({
...currentPagination,
totalItems: Math.max(0, currentPagination.totalItems - 1)
});
}
}
}),
catchError(error => this.handleError(error)),
tap(() => this._isLoading.set(false))
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'Une erreur inconnue s\'est produite';
// Extraire le message d'erreur des différents formats de réponse
if (error.error?.error?.message) {
errorMessage = error.error.error.message;
} else if (error.error?.message) {
errorMessage = error.error.message;
} else if (error.message) {
errorMessage = error.message;
}
// Gérer les codes de statut HTTP spécifiques
switch (error.status) {
case 0:
errorMessage = 'Erreur réseau. Veuillez vérifier votre connexion.';
break;
case 401:
errorMessage = 'Authentification requise. Veuillez vous connecter.';
break;
case 403:
errorMessage = 'Accès refusé. Vous n\'avez pas l\'autorisation pour cette action.';
break;
case 404:
errorMessage = 'La ressource demandée n\'a pas été trouvée.';
break;
case 500:
errorMessage = 'Erreur interne du serveur. Veuillez réessayer plus tard.';
break;
}
this._error.set(errorMessage);
return throwError(() => error);
}
// Effacer l'état d'erreur
clearError(): void {
this._error.set(null);
}
// Vérifier s'il y a une erreur
readonly hasError = computed(() => this._error() !== null);
// Obtenir l'erreur pour affichage
readonly errorMessage = computed(() => this._error() || '');
// Actualiser les données actuelles
refreshItems(): void {
const currentFilters = this._currentFilters();
this.getItems(currentFilters).subscribe();
}
// Effacer tout l'état
clearState(): void {
this._items.set([]);
this._selectedItem.set(null);
this._stats.set(null);
this._pagination.set(null);
this._error.set(null);
}
// Effacer un état spécifique
clearSelectedItem(): void {
this._selectedItem.set(null);
}
clearItems(): void {
this._items.set([]);
this._pagination.set(null);
}
// Méthode d'aide pour mettre à jour un élément dans l'état actuel
private updateItemInState(id: string, updatedItem: Item): void {
const currentItems = this._items();
const updatedItems = currentItems.map(item =>
item._id === id ? updatedItem : item
);
this._items.set(updatedItems);
// Mettre à jour l'élément sélectionné s'il correspond
if (this._selectedItem()?._id === id) {
this._selectedItem.set(updatedItem);
}
}
// Trouver un élément par ID dans l'état actuel
findItemById(id: string): Item | undefined {
return this._items().find(item => item._id === id);
}
// Vérifier si l'élément existe dans l'état actuel
hasItemWithId(id: string): boolean {
return this._items().some(item => item._id === id);
}
// Aides à la validation
validateItemData(data: CreateItemRequest | UpdateItemRequest): string[] {
const errors: string[] = [];
if ('name' in data && data.name) {
if (data.name.length > 50) {
errors.push('Le nom doit faire moins de 50 caractères');
}
}
if ('email' in data && data.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.email)) {
errors.push('Veuillez fournir une adresse email valide');
}
}
return errors;
}
// Aides de logique métier
canEditItem(item: Item, currentUserRoles: string[]): boolean {
if (currentUserRoles.includes('admin')) return true;
if (currentUserRoles.includes('teacher') && item.type === 'student') return true;
return false;
}
// Aides de formatage
getItemInitials(item: Item): string {
return `${item.firstName?.charAt(0) || ''}${item.lastName?.charAt(0) || ''}`;
}
formatItemDisplay(item: Item): string {
return `${item.name} (${item.code})`;
}
// Filtrage côté client
filterItems(items: Item[], filters: ItemFilters): Item[] {
let filtered = [...items];
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(searchTerm) ||
item.code.toLowerCase().includes(searchTerm)
);
}
if (filters.category) {
filtered = filtered.filter(item => item.category === filters.category);
}
if (filters.isActive !== undefined) {
filtered = filtered.filter(item => item.isActive === filters.isActive);
}
return filtered;
}
// Tri côté client
sortItems(items: Item[], sortBy: keyof Item, sortOrder: 'asc' | 'desc'): Item[] {
const sorted = [...items];
sorted.sort((a, b) => {
let aValue: any = a[sortBy];
let bValue: any = b[sortBy];
// Gérer les champs de date
if (sortBy === 'createdAt' || sortBy === 'updatedAt') {
aValue = new Date(aValue).getTime();
bValue = new Date(bValue).getTime();
}
// Gérer les champs de chaîne
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}
// Charger les données liées quand un élément est sélectionné
readonly selectedItemDetails = computed(() => {
const selected = this._selectedItem();
if (!selected) return null;
// Ceci pourrait déclencher des appels API supplémentaires pour les données liées
return {
item: selected,
relatedItems: this._items().filter(item =>
item.category === selected.category && item._id !== selected._id
)
};
});
// Charger des données avec dépendances
loadItemWithDetails(id: string): Observable<any> {
this._isLoading.set(true);
// Charger l'élément principal d'abord, puis les données liées
return this.getItemById(id).pipe(
switchMap(() => this.loadRelatedData(id)),
tap(() => this._isLoading.set(false))
);
}
updateItemOptimistic(id: string, updates: Partial<Item>): Observable<ApiResponse<{ item: Item }>> {
// Mettre à jour immédiatement l'UI
const currentItems = this._items();
const optimisticItems = currentItems.map(item =>
item._id === id ? { ...item, ...updates } : item
);
this._items.set(optimisticItems);
// Envoyer la requête au serveur
return this.http.put<ApiResponse<{ item: Item }>>(`${this.baseUrl}/${id}`, updates)
.pipe(
tap(response => {
if (response.success) {
// Remplacer la mise à jour optimiste par la réponse du serveur
this.updateItemInState(id, response.data.item);
}
}),
catchError(error => {
// Annuler la mise à jour optimiste en cas d'erreur
this.refreshItems();
return this.handleError(error);
})
);
}
private readonly _cache = new Map<string, { data: any; timestamp: number }>();
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
getCachedItemById(id: string): Observable<ApiResponse<{ item: Item }>> {
const cached = this._cache.get(id);
const now = Date.now();
// Retourner les données mises en cache si elles sont encore fraîches
if (cached && (now - cached.timestamp) < this.CACHE_DURATION) {
return of(cached.data);
}
// Récupérer des données fraîches
return this.getItemById(id).pipe(
tap(response => {
if (response.success) {
this._cache.set(id, {
data: response,
timestamp: now
});
}
})
);
}
clearCache(): void {
this._cache.clear();
}
import { Component, inject, OnInit, computed, effect } from '@angular/core';
import { ItemsService } from '../services/items.service';
@Component({
selector: 'app-items',
template: `
<div class="items-container">
<!-- Champ de recherche -->
<input
type="text"
placeholder="Rechercher des éléments..."
(input)="onSearch($event.target.value)"
class="search-input">
<!-- Afficher le message calculé -->
<div class="status-message">{{ displayMessage() }}</div>
<!-- État de chargement -->
@if (itemsService.isLoading()) {
<div class="loading-spinner">
<div class="spinner"></div>
Chargement des éléments...
</div>
}
<!-- État d'erreur -->
@if (itemsService.error()) {
<div class="error-alert">
<span>{{ itemsService.error() }}</span>
<button (click)="itemsService.clearError()" class="btn-dismiss">
Ignorer
</button>
</div>
}
<!-- Liste des éléments -->
@if (itemsService.hasItems()) {
<div class="items-grid">
@for (item of itemsService.items(); track item._id) {
<div class="item-card">
<h3>{{ item.name }}</h3>
<p>Code : {{ item.code }}</p>
<p>Capacité : {{ item.capacity }}</p>
<button (click)="selectItem(item)" class="btn-select">
Sélectionner
</button>
</div>
}
</div>
}
<!-- État vide -->
@if (isEmpty()) {
<div class="empty-state">
<h3>Aucun élément trouvé</h3>
<p>Essayez d'ajuster vos critères de recherche ou créez un nouvel élément.</p>
<button (click)="createNewItem()" class="btn-primary">
Créer un Nouvel Élément
</button>
</div>
}
<!-- Pagination -->
@if (showPagination()) {
<div class="pagination">
<button
(click)="onPageChange(currentPage() - 1)"
[disabled]="!itemsService.pagination()?.hasPrevPage"
class="btn-page">
Précédent
</button>
<span class="page-info">
Page {{ currentPage() }} sur {{ totalPages() }}
({{ itemsService.totalItems() }} éléments au total)
</span>
<button
(click)="onPageChange(currentPage() + 1)"
[disabled]="!itemsService.pagination()?.hasNextPage"
class="btn-page">
Suivant
</button>
</div>
}
</div>
`
})
export class ItemsComponent implements OnInit {
protected readonly itemsService = inject(ItemsService);
// Signaux calculés pour la logique du composant
readonly isEmpty = computed(() =>
!this.itemsService.hasItems() &&
!this.itemsService.isLoading() &&
!this.itemsService.error()
);
readonly displayMessage = computed(() => {
const loading = this.itemsService.isLoading();
const error = this.itemsService.error();
const itemCount = this.itemsService.totalItems();
if (loading) return 'Chargement des éléments...';
if (error) return 'Erreur lors du chargement des éléments';
if (itemCount === 0) return 'Aucun élément trouvé';
return `Trouvé ${itemCount} élément${itemCount === 1 ? '' : 's'}`;
});
readonly currentPage = computed(() =>
this.itemsService.pagination()?.currentPage || 1
);
readonly totalPages = computed(() =>
this.itemsService.pagination()?.totalPages || 1
);
readonly showPagination = computed(() =>
this.itemsService.pagination() && this.totalPages() > 1
);
// Effets pour le comportement réactif
constructor() {
// Réagir aux erreurs
effect(() => {
const error = this.itemsService.error();
if (error) {
console.error('Erreur du service des éléments:', error);
// Pourrait s'intégrer avec un service de notification toast
this.notificationService?.showError(error);
}
});
// Auto-actualiser les données toutes les 5 minutes
effect(() => {
const interval = setInterval(() => {
if (!this.itemsService.isLoading()) {
this.itemsService.refreshItems();
}
}, 5 * 60 * 1000);
// Nettoyage à la destruction du composant
return () => clearInterval(interval);
});
}
ngOnInit(): void {
// Charger les données initiales
this.loadItems();
}
// Gestionnaires d'événements
loadItems(): void {
this.itemsService.getItems().subscribe();
}
onSearch(term: string): void {
this.itemsService.getItems({ search: term }).subscribe();
}
onPageChange(page: number): void {
const currentFilters = this.itemsService.currentFilters();
this.itemsService.getItems({ ...currentFilters, page }).subscribe();
}
selectItem(item: Item): void {
// Ceci pourrait déclencher une navigation ou ouvrir une modale
this.router.navigate(['/items', item._id]);
}
createNewItem(): void {
this.router.navigate(['/items/new']);
}
}
@Component({
selector: 'app-item-form',
template: `
<form [formGroup]="itemForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Nom</label>
<input
id="name"
type="text"
formControlName="name"
[class.error]="isFieldInvalid('name')">
@if (isFieldInvalid('name')) {
<div class="error-message">
Le nom est requis
</div>
}
</div>
<div class="form-group">
<label for="code">Code</label>
<input
id="code"
type="text"
formControlName="code"
[class.error]="isFieldInvalid('code')">
@if (isFieldInvalid('code')) {
<div class="error-message">
Le code est requis
</div>
}
</div>
<!-- Afficher les erreurs de validation du service -->
@if (validationErrors().length > 0) {
<div class="validation-errors">
<h4>Veuillez corriger les erreurs suivantes :</h4>
<ul>
@for (error of validationErrors(); track $index) {
<li>{{ error }}</li>
}
</ul>
</div>
}
<!-- Bouton de soumission avec état de chargement -->
<button
type="submit"
[disabled]="itemForm.invalid || itemsService.isLoading()"
class="btn-submit">
@if (itemsService.isLoading()) {
<span>Sauvegarde...</span>
} @else {
<span>Sauvegarder l'Élément</span>
}
</button>
</form>
`
})
export class ItemFormComponent {
protected readonly itemsService = inject(ItemsService);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
itemForm = this.fb.group({
name: ['', Validators.required],
code: ['', Validators.required],
capacity: [1, [Validators.required, Validators.min(1)]]
});
// Erreurs de validation calculées
readonly validationErrors = computed(() => {
const formData = this.itemForm.value;
if (!formData.name || !formData.code) return [];
return this.itemsService.validateItemData(formData as CreateItemRequest);
});
constructor() {
// Réagir à la création réussie
effect(() => {
const error = this.itemsService.error();
if (!error && this.itemForm.dirty) {
// Succès - naviguer ailleurs
this.router.navigate(['/items']);
}
});
}
onSubmit(): void {
if (this.itemForm.valid) {
const formData = this.itemForm.value as CreateItemRequest;
this.itemsService.createItem(formData).subscribe();
}
}
isFieldInvalid(fieldName: string): boolean {
const field = this.itemForm.get(fieldName);
return !!(field && field.invalid && field.touched);
}
}
@Component({
selector: 'app-item-dashboard',
template: `
<div class="dashboard">
<!-- Statistiques en temps réel -->
<div class="stats-grid">
<div class="stat-card">
<h3>Total des Éléments</h3>
<p class="stat-number">{{ itemsService.totalItems() }}</p>
</div>
<div class="stat-card">
<h3>Éléments Actifs</h3>
<p class="stat-number">{{ itemsService.activeItems().length }}</p>
</div>
<div class="stat-card">
<h3>Capacité Moyenne</h3>
<p class="stat-number">{{ itemsService.averageCapacity() }}</p>
</div>
</div>
<!-- Recherche en direct -->
<div class="search-section">
<input
#searchInput
type="text"
placeholder="Rechercher des éléments..."
(input)="onSearchChange(searchInput.value)">
<p class="search-results">
{{ searchResultsMessage() }}
</p>
</div>
<!-- Résultats filtrés -->
<div class="items-section">
@for (item of displayedItems(); track item._id) {
<div class="item-row">
{{ item.name }} - {{ item.capacity }} capacité
</div>
}
</div>
</div>
`
})
export class ItemDashboardComponent {
protected readonly itemsService = inject(ItemsService);
// État local du composant
private readonly _searchTerm = signal<string>('');
readonly searchTerm = this._searchTerm.asReadonly();
// Signaux calculés combinant l'état du service et du composant
readonly displayedItems = computed(() => {
const items = this.itemsService.items();
const search = this._searchTerm();
if (!search) return items;
return items.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
});
readonly searchResultsMessage = computed(() => {
const total = this.itemsService.totalItems();
const displayed = this.displayedItems().length;
const search = this._searchTerm();
if (!search) return `Affichage de tous les ${total} éléments`;
return `Trouvé ${displayed} sur ${total} éléments correspondant à "${search}"`;
});
constructor() {
// Auto-charger les données
effect(() => {
this.itemsService.getItems().subscribe();
});
// Effet de débogage
effect(() => {
console.log('Éléments mis à jour:', this.itemsService.items().length);
console.log('Terme de recherche:', this._searchTerm());
console.log('Éléments affichés:', this.displayedItems().length);
});
}
onSearchChange(term: string): void {
this._searchTerm.set(term);
}
}
export class ItemsService {
private readonly _items = signal<Item[]>([]);
constructor() {
// Effet de débogage pour suivre tous les changements
if (!environment.production) {
effect(() => {
const items = this._items();
console.log('🔄 Signal items mis à jour:', {
count: items.length,
items: items.map(i => ({ id: i._id, name: i.name }))
});
});
}
}
}
// Ajouter ceci à votre service pour le développement
private debugSignal<T>(signal: Signal<T>, name: string): Signal<T> {
if (!environment.production) {
effect(() => {
console.log(`📊 Signal [${name}]:`, signal());
});
}
return signal;
}
// Utilisation
readonly users = this.debugSignal(this._users.asReadonly(), 'users');
❌ Ne faites pas ceci :
// Muter les tableaux directement
this._items().push(newItem); // Faux !
// Lire les signaux dans les constructeurs sans effets
constructor() {
console.log(this._items()); // Peut ne pas suivre les dépendances
}
✅ Faites ceci à la place :
// Créer de nouveaux tableaux
this._items.update(current => [...current, newItem]);
// Utiliser des effets pour l'initialisation
constructor() {
effect(() => {
console.log('Éléments:', this._items());
});
}
- Responsabilité unique : Chaque service devrait gérer un domaine/entité
- État immutable : Toujours créer de nouveaux tableaux/objets lors de la mise à jour des signaux
- Mises à jour prévisibles : Utiliser des modèles cohérents pour les mises à jour d'état
- Récupération d'erreur : Fournir des états d'erreur clairs et des mécanismes de récupération
- Utiliser des signaux calculés pour les données dérivées au lieu de recalculer dans les templates
-
Utiliser la nouvelle syntaxe de flux de contrôle (
@if
,@for
) pour de meilleures performances que les directives structurelles - Implémenter la mise en cache pour les données fréquemment consultées
- Utiliser la pagination pour les grands ensembles de données
- Minimiser les requêtes HTTP en exploitant l'état local
- Mocker les requêtes HTTP en utilisant HttpClientTestingModule d'Angular
- Tester les changements d'état des signaux en s'abonnant aux valeurs des signaux
- Tester les scénarios d'erreur pour assurer une gestion d'erreur appropriée
- Tester les signaux calculés pour vérifier l'état dérivé correct
- Garder les signaux privés et exposer des versions en lecture seule
- Utiliser des noms de signaux significatifs qui décrivent les données qu'ils contiennent
- Grouper les signaux liés logiquement dans le service
- Nettoyer l'état quand approprié pour éviter les fuites de mémoire
Cette approche complète de la création de services Angular fournit une base solide pour construire des applications maintenables et évolutives avec une gestion d'état prévisible et une gestion d'erreur robuste.