NgRx Signal Store : state management Angular

Front-end 01/04/2026 09:00:00 angularforall.com
Angular Ngrx Signal Store State Management Signals
NgRx Signal Store : state management Angular

NgRx Signal Store (@ngrx/signals) : signalStore(), withState(), withMethods(), withComputed() — guide complet state management Angular 19+.

Pourquoi Signal Store ?

Depuis Angular 16, les Signals sont le nouveau modèle de réactivité d'Angular. Ils remplacent progressivement les Observables RxJS pour la gestion d'état local et global. NgRx a pris acte de cette évolution avec @ngrx/signals, une bibliothèque de state management entièrement basée sur les Signals — sans Actions, sans Reducers, sans Effects RxJS.

Avant d'entrer dans le vif du sujet, il est utile de comprendre pourquoi NgRx Signal Store existe à côté du NgRx classique (Store + Actions + Reducers + Effects).

Critère NgRx Store classique NgRx Signal Store
Courbe d'apprentissage Elevée (Actions, Reducers, Effects, Selectors) Faible (signalStore, withState, withMethods)
Boilerplate Important (4-5 fichiers par feature) Minimal (1 fichier suffit)
Typage TypeScript Partiel (actions non typées nativement) Complet et inféré automatiquement
Réactivité Observable / RxJS Signals Angular natifs
DevTools Redux DevTools complet Support partiel (en évolution)
Cas d'usage idéal Applications enterprise très complexes Applications modernes Angular 17+
À retenir : NgRx Signal Store ne remplace pas complètement le NgRx classique pour les très grandes applications — il en est le complément moderne. Pour les nouvelles applications Angular 19+, Signal Store est le choix recommandé par l'équipe NgRx.

Comparaison de code : avant et après

Voici un compteur simple implémenté avec les deux approches pour illustrer le gain de concision :

// ❌ AVANT — NgRx classique (3 fichiers, ~60 lignes)
// counter.actions.ts
import { createAction } from '@ngrx/store';
export const increment = createAction('[Counter] Increment');
export const decrement = createAction('[Counter] Decrement');

// counter.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { increment, decrement } from './counter.actions';
export const counterReducer = createReducer(
    { count: 0 },
    on(increment, state => ({ count: state.count + 1 })),
    on(decrement, state => ({ count: state.count - 1 }))
);

// counter.component.ts
this.store.dispatch(increment()); // Dispatcher une action
this.count$ = this.store.select(selectCount); // Observable à gérer
// ✅ APRÈS — NgRx Signal Store (1 fichier, ~15 lignes)
// counter.store.ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';

export const CounterStore = signalStore(
    { providedIn: 'root' }, // Disponible globalement comme un service Angular
    withState({ count: 0 }), // État initial typé automatiquement
    withMethods(store => ({
        // Méthode increment : met à jour l'état avec patchState
        increment() { patchState(store, { count: store.count() + 1 }); },
        // Méthode decrement : décrémente de la même façon
        decrement() { patchState(store, { count: store.count() - 1 }); }
    }))
);

// counter.component.ts
const store = inject(CounterStore);
store.increment(); // Appel direct — aucune action à dispatcher
store.count();     // Signal — pas d'Observable, pas d'async pipe

Le résultat est identique fonctionnellement, mais Signal Store est 4x plus concis et entièrement typé sans configuration supplémentaire.

Installation et configuration

NgRx Signal Store fait partie du package @ngrx/signals, disponible depuis NgRx 17. Il est compatible avec Angular 17+ et fonctionne parfaitement avec Angular 19 et ses Signals stabilisés.

Prérequis de versions

Package Version minimale Version recommandée
Angular 17.0.0 19.x
@ngrx/signals 17.0.0 19.x (compatible Angular 19)
TypeScript 5.2 5.4+
Node.js 18.x 20.x (LTS)

Installation via npm

# Installer @ngrx/signals dans un projet Angular existant
npm install @ngrx/signals

# Ou avec une version précise pour Angular 19
npm install @ngrx/signals@19
Note : Contrairement au NgRx Store classique, @ngrx/signals ne nécessite aucun module NgRx à importer dans app.config.ts. Pas de provideStore(), pas de provideEffects() — le Signal Store s'injecte comme un service Angular ordinaire.

Structure d'un projet avec Signal Store

// Organisation recommandée des stores dans un projet Angular
src/
├── app/
│   ├── stores/
│   │   ├── counter.store.ts      // Store simple (compteur)
│   │   ├── cart.store.ts         // Store panier e-commerce
│   │   └── auth.store.ts         // Store authentification
│   ├── features/
│   │   ├── products/
│   │   │   ├── product.store.ts  // Store feature (scoped)
│   │   │   └── product.component.ts
│   │   └── orders/
│   │       └── order.store.ts
│   └── app.config.ts

Aucune configuration app.config.ts requise

// app.config.ts — configuration minimale pour Signal Store
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideHttpClient(),
        // ✅ Aucun provideStore() ou provideEffects() requis
        // Les Signal Stores s'injectent directement avec inject()
    ]
};

Créer son premier Store

signalStore() est la fonction centrale de @ngrx/signals. Elle crée une classe Angular injectable qui expose des Signals en lecture seule pour chaque propriété de l'état. withState() définit la forme et les valeurs initiales de cet état.

Anatomie d'un Signal Store

// src/app/stores/todo.store.ts
import { signalStore, withState, patchState } from '@ngrx/signals';

// 1. Définir l'interface de l'état (TypeScript strict)
interface TodoState {
    todos: Todo[];          // Liste des tâches
    isLoading: boolean;     // Indicateur de chargement
    filter: 'all' | 'done' | 'pending'; // Filtre actif
    selectedId: number | null;          // ID sélectionné
}

// 2. Définir la structure d'une tâche
interface Todo {
    id: number;
    title: string;
    completed: boolean;
}

// 3. État initial du store
const initialState: TodoState = {
    todos: [],
    isLoading: false,
    filter: 'all',
    selectedId: null
};

// 4. Créer le store global (providedIn: 'root' = singleton)
export const TodoStore = signalStore(
    { providedIn: 'root' }, // Optionnel : omettez pour un store scoped par composant
    withState(initialState) // Injecte l'état initial typé
    // → Angular génère automatiquement les Signals :
    //   store.todos()     → Signal<Todo[]>
    //   store.isLoading() → Signal<boolean>
    //   store.filter()    → Signal<'all' | 'done' | 'pending'>
    //   store.selectedId()→ Signal<number | null>
);

Utiliser le store dans un composant

// src/app/features/todos/todo-list.component.ts
import { Component, inject } from '@angular/core';
import { TodoStore } from '../../stores/todo.store';

@Component({
    selector: 'app-todo-list',
    standalone: true,
    template: `
        <div class="todo-container">
            <!-- Afficher l'indicateur de chargement -->
            @if (store.isLoading()) {
                <p>Chargement...</p>
            }

            <!-- Itérer sur les todos (Signal mis à jour automatiquement) -->
            @for (todo of store.todos(); track todo.id) {
                <div class="todo-item">
                    <span [class.done]="todo.completed">{{ todo.title }}</span>
                </div>
            }

            <!-- Afficher le filtre actif -->
            <p>Filtre actif : {{ store.filter() }}</p>
        </div>
    `
})
export class TodoListComponent {
    // Injecter le store comme n'importe quel service Angular
    protected readonly store = inject(TodoStore);
    // store.todos() est un Signal<Todo[]> — lecture directe, sans async pipe
}

Store scoped par composant (non singleton)

// Store sans { providedIn: 'root' } → doit être fourni par le composant
export const LocalCounterStore = signalStore(
    // Pas de { providedIn: 'root' } → ce store n'est pas global
    withState({ count: 0, step: 1 })
);

// Composant qui fournit son propre store isolé
@Component({
    selector: 'app-counter',
    standalone: true,
    providers: [LocalCounterStore], // ← Le store est créé pour CE composant uniquement
    template: `<p>Compteur : {{ store.count() }}</p>`
})
export class CounterComponent {
    protected readonly store = inject(LocalCounterStore);
    // Chaque instance de CounterComponent a son propre store indépendant
}
À retenir : { providedIn: 'root' } crée un store singleton partagé par toute l'application (comme un service root). Sans cette option, le store est scoped au composant qui le fournit — parfait pour les widgets autonomes ou les composants formulaire complexes.

Méthodes et computed signals

withMethods() et withComputed() sont les deux briques qui donnent vie au store. Les méthodes encapsulent la logique métier qui modifie l'état. Les computed signals dérivent automatiquement des valeurs depuis l'état existant.

withMethods() — définir les actions du store

// src/app/stores/todo.store.ts — version complète avec méthodes
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';

export const TodoStore = signalStore(
    { providedIn: 'root' },
    withState(initialState),
    withMethods(store => ({
        // Ajouter une nouvelle tâche à la liste
        addTodo(title: string): void {
            // Créer un nouvel objet Todo avec un ID unique basé sur timestamp
            const newTodo: Todo = {
                id: Date.now(),
                title: title.trim(),
                completed: false
            };
            // patchState met à jour l'état de façon immutable
            patchState(store, state => ({
                // Spread de la liste existante + ajout du nouvel élément
                todos: [...state.todos, newTodo]
            }));
        },

        // Basculer l'état completed d'une tâche par son ID
        toggleTodo(id: number): void {
            patchState(store, state => ({
                todos: state.todos.map(todo =>
                    todo.id === id
                        ? { ...todo, completed: !todo.completed } // Inverser completed
                        : todo // Laisser les autres tâches inchangées
                )
            }));
        },

        // Supprimer une tâche par son ID
        removeTodo(id: number): void {
            patchState(store, state => ({
                // Filtrer la liste en excluant l'ID ciblé
                todos: state.todos.filter(todo => todo.id !== id)
            }));
        },

        // Changer le filtre actif
        setFilter(filter: 'all' | 'done' | 'pending'): void {
            // Mise à jour simple d'une propriété scalaire
            patchState(store, { filter });
        },

        // Réinitialiser entièrement le store à son état initial
        reset(): void {
            patchState(store, initialState);
        }
    }))
);

withComputed() — dériver des valeurs calculées

// Importer computed depuis @angular/core pour les Signals dérivés
import { computed } from '@angular/core';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';

export const TodoStore = signalStore(
    { providedIn: 'root' },
    withState(initialState),
    withMethods(/* ... méthodes ci-dessus ... */),
    withComputed(store => ({
        // Nombre total de tâches — recalculé automatiquement si todos change
        totalCount: computed(() => store.todos().length),

        // Nombre de tâches complètes
        completedCount: computed(() =>
            store.todos().filter(t => t.completed).length
        ),

        // Nombre de tâches en attente
        pendingCount: computed(() =>
            store.todos().filter(t => !t.completed).length
        ),

        // Pourcentage de complétion (0-100)
        completionPercent: computed(() => {
            const total = store.todos().length;
            if (total === 0) return 0; // Éviter la division par zéro
            const completed = store.todos().filter(t => t.completed).length;
            return Math.round((completed / total) * 100);
        }),

        // Liste filtrée selon le filtre actif — Signal réactif combiné
        filteredTodos: computed(() => {
            const todos = store.todos();      // Dépendance 1
            const filter = store.filter();    // Dépendance 2
            // Angular re-calcule automatiquement si l'un des deux change
            switch (filter) {
                case 'done':    return todos.filter(t => t.completed);
                case 'pending': return todos.filter(t => !t.completed);
                default:        return todos; // 'all' — retourner tout
            }
        }),

        // Indique si la liste est vide
        isEmpty: computed(() => store.todos().length === 0)
    }))
);

Utiliser méthodes et computed dans le composant

// todo-list.component.ts — utilisation complète
@Component({
    selector: 'app-todo-list',
    standalone: true,
    template: `
        <!-- Barre de progression dynamique via computed signal -->
        <div class="progress mb-3">
            <div class="progress-bar"
                 [style.width.%]="store.completionPercent()"
                 role="progressbar"
                 [attr.aria-valuenow]="store.completionPercent()"
                 aria-valuemin="0" aria-valuemax="100">
                {{ store.completionPercent() }}%
            </div>
        </div>

        <!-- Compteurs dérivés automatiquement -->
        <p>{{ store.completedCount() }} / {{ store.totalCount() }} tâches</p>

        <!-- Boutons de filtre -->
        <div class="btn-group" role="group">
            <button (click)="store.setFilter('all')">Tout</button>
            <button (click)="store.setFilter('pending')">En cours</button>
            <button (click)="store.setFilter('done')">Terminé</button>
        </div>

        <!-- Liste filtrée — se met à jour automatiquement -->
        @for (todo of store.filteredTodos(); track todo.id) {
            <div class="todo-item d-flex align-items-center gap-2">
                <input type="checkbox"
                       [checked]="todo.completed"
                       (change)="store.toggleTodo(todo.id)"
                       [attr.aria-label]="'Marquer ' + todo.title + ' comme fait'">
                <span [class.text-decoration-line-through]="todo.completed">
                    {{ todo.title }}
                </span>
                <button (click)="store.removeTodo(todo.id)"
                        class="btn btn-sm btn-danger ms-auto">
                    Supprimer
                </button>
            </div>
        }

        <!-- Message si liste vide -->
        @if (store.isEmpty()) {
            <p class="text-muted text-center">Aucune tâche pour le moment.</p>
        }
    `
})
export class TodoListComponent {
    protected readonly store = inject(TodoStore);
}
Note : Les computed signals sont mémoïsés — Angular ne recalcule la valeur que si une dépendance change réellement. filteredTodos ne se recalcule pas si vous appelez setFilter('all') deux fois de suite avec la même valeur.

Modifier l'état avec patchState()

patchState() est la seule façon de modifier l'état d'un Signal Store. Elle garantit l'immutabilité en créant toujours un nouvel objet d'état. Elle accepte soit un objet partiel (pour des mises à jour simples), soit une fonction de mise à jour (pour les mises à jour basées sur l'état courant).

Les deux formes de patchState()

// Forme 1 — Objet partiel : pour les mises à jour simples sans dépendance à l'état courant
patchState(store, {
    isLoading: true,      // Met à jour isLoading à true
    selectedId: 42        // Met à jour selectedId à 42
    // Toutes les autres propriétés restent inchangées (patch, pas replace)
});

// Forme 2 — Fonction updater : pour les mises à jour qui dépendent de l'état courant
patchState(store, state => ({
    // state contient l'état courant en lecture seule
    todos: [...state.todos, newTodo],   // Ajouter sans muter le tableau
    isLoading: !state.isLoading         // Basculer en lisant la valeur actuelle
}));

patchState() avec des états imbriqués

// Interface avec état imbriqué
interface UserProfileState {
    user: {
        name: string;
        email: string;
        preferences: {
            theme: 'light' | 'dark';
            language: string;
            notifications: boolean;
        };
    };
    isEditing: boolean;
}

// ❌ Mauvais : écraser toutes les préférences avec un seul changement
patchState(store, state => ({
    user: {
        ...state.user,
        preferences: { theme: 'dark' } // Perd language et notifications !
    }
}));

// ✅ Bon : spread imbriqué pour préserver les propriétés non modifiées
patchState(store, state => ({
    user: {
        ...state.user,              // Conserver name, email
        preferences: {
            ...state.user.preferences, // Conserver language et notifications
            theme: 'dark'              // Modifier seulement le thème
        }
    }
}));

Intégration avec Immer pour les états complexes

Pour les états très imbriqués, @ngrx/signals s'intègre avec Immer via le plugin withImmerUpdate (ou en utilisant produce directement) pour écrire des mutations apparentes tout en gardant l'immutabilité :

// Installer immer
// npm install immer

import { produce } from 'immer';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';

export const CartStore = signalStore(
    { providedIn: 'root' },
    withState({ items: [] as CartItem[], total: 0 }),
    withMethods(store => ({
        // Utiliser produce() d'Immer pour les mises à jour complexes
        updateQuantity(productId: number, quantity: number): void {
            patchState(store, state =>
                // produce() reçoit un draft mutable — plus lisible que le spread imbriqué
                produce(state, draft => {
                    const item = draft.items.find(i => i.productId === productId);
                    if (item) {
                        item.quantity = quantity; // Mutation directe sur le draft
                        // Immer gère l'immutabilité en arrière-plan
                    }
                    // Recalculer le total après modification
                    draft.total = draft.items.reduce(
                        (sum, i) => sum + i.price * i.quantity, 0
                    );
                })
            );
        },

        // Vider le panier
        clearCart(): void {
            patchState(store, { items: [], total: 0 });
        }
    }))
);
À retenir : patchState() est une mise à jour partielle — elle ne remplace que les propriétés que vous spécifiez. Pour réinitialiser complètement le store à son état initial, passez l'objet initialState entier : patchState(store, initialState).

Plusieurs patchState() en une seule méthode

// patchState accepte plusieurs arguments — appliqués dans l'ordre
withMethods(store => ({
    submitForm(formData: FormData): void {
        // Étape 1 : passer en mode chargement
        patchState(store, { isLoading: true, error: null });

        // Les deux patches ci-dessous sont appliqués séquentiellement
        // et déclenchent une seule mise à jour de l'UI (batching Angular)
        patchState(store,
            { submittedAt: new Date() },      // Patch 1
            { formData: formData }             // Patch 2
        );
    }
}))

Effets et requêtes HTTP dans le Signal Store

NgRx Signal Store gère les effets asynchrones (appels HTTP, timers, WebSocket) directement dans les méthodes via async/await ou en combinant avec RxJS. Pas besoin d'un système d'Effects séparé comme dans NgRx classique.

Méthodes async avec HttpClient

// src/app/stores/product.store.ts
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { firstValueFrom } from 'rxjs'; // Convertir Observable en Promise

interface Product {
    id: number;
    name: string;
    price: number;
    stock: number;
}

interface ProductState {
    products: Product[];
    isLoading: boolean;
    error: string | null;
    selectedProduct: Product | null;
}

const initialState: ProductState = {
    products: [],
    isLoading: false,
    error: null,
    selectedProduct: null
};

export const ProductStore = signalStore(
    { providedIn: 'root' },
    withState(initialState),
    withMethods((store, http = inject(HttpClient)) => ({
        // Charger tous les produits depuis l'API
        async loadProducts(): Promise<void> {
            // Activer l'indicateur de chargement avant la requête
            patchState(store, { isLoading: true, error: null });

            try {
                // firstValueFrom() convertit l'Observable HttpClient en Promise
                const products = await firstValueFrom(
                    http.get<Product[]>('/api/products')
                );
                // Mettre à jour l'état avec les données reçues
                patchState(store, { products, isLoading: false });
            } catch (err: unknown) {
                // Capturer et stocker le message d'erreur dans l'état
                const message = err instanceof Error ? err.message : 'Erreur inconnue';
                patchState(store, { isLoading: false, error: message });
            }
        },

        // Charger un produit spécifique par son ID
        async loadProductById(id: number): Promise<void> {
            patchState(store, { isLoading: true, error: null });

            try {
                const product = await firstValueFrom(
                    http.get<Product>(`/api/products/${id}`)
                );
                patchState(store, { selectedProduct: product, isLoading: false });
            } catch (err: unknown) {
                const message = err instanceof Error ? err.message : 'Produit introuvable';
                patchState(store, { isLoading: false, error: message });
            }
        },

        // Créer un produit via POST
        async createProduct(data: Omit<Product, 'id'>): Promise<void> {
            patchState(store, { isLoading: true });

            try {
                const created = await firstValueFrom(
                    http.post<Product>('/api/products', data)
                );
                // Ajouter le produit créé à la liste existante (pas de rechargement complet)
                patchState(store, state => ({
                    products: [...state.products, created],
                    isLoading: false
                }));
            } catch (err: unknown) {
                const message = err instanceof Error ? err.message : 'Création échouée';
                patchState(store, { isLoading: false, error: message });
            }
        },

        // Supprimer un produit via DELETE
        async deleteProduct(id: number): Promise<void> {
            try {
                await firstValueFrom(http.delete(`/api/products/${id}`));
                // Supprimer localement sans rechargement API
                patchState(store, state => ({
                    products: state.products.filter(p => p.id !== id)
                }));
            } catch (err: unknown) {
                const message = err instanceof Error ? err.message : 'Suppression échouée';
                patchState(store, { error: message });
            }
        }
    }))
);

Utiliser withHooks() pour charger des données à l'initialisation

// withHooks() permet d'exécuter du code au montage/démontage du store
import { signalStore, withState, withMethods, withHooks, patchState } from '@ngrx/signals';

export const ProductStore = signalStore(
    { providedIn: 'root' },
    withState(initialState),
    withMethods((store, http = inject(HttpClient)) => ({
        async loadProducts(): Promise<void> { /* ... */ }
    })),
    withHooks({
        // onInit est appelé automatiquement quand le store est injecté pour la première fois
        onInit(store) {
            // Charger les produits au démarrage sans intervention du composant
            store.loadProducts();
            console.log('ProductStore initialisé — données chargées');
        },
        // onDestroy est appelé quand le store est détruit (pour les stores scoped)
        onDestroy(store) {
            console.log('ProductStore détruit');
        }
    })
);

Utilisation dans un composant avec gestion d'erreur

// product-list.component.ts
@Component({
    selector: 'app-product-list',
    standalone: true,
    imports: [CurrencyPipe],
    template: `
        <!-- Affichage conditionnel selon l'état du store -->
        @if (store.isLoading()) {
            <div class="d-flex justify-content-center py-4">
                <div class="spinner-border" role="status">
                    <span class="visually-hidden">Chargement...</span>
                </div>
            </div>
        }

        <!-- Afficher l'erreur si présente -->
        @if (store.error()) {
            <div class="alert alert-danger" role="alert">
                Erreur : {{ store.error() }}
                <button (click)="store.loadProducts()" class="btn btn-sm btn-outline-danger ms-2">
                    Réessayer
                </button>
            </div>
        }

        <!-- Liste des produits -->
        @if (!store.isLoading() && !store.error()) {
            <div class="row g-3">
                @for (product of store.products(); track product.id) {
                    <div class="col-md-4">
                        <div class="card h-100">
                            <div class="card-body">
                                <h5 class="card-title">{{ product.name }}</h5>
                                <p class="card-text">{{ product.price | currency:'EUR' }}</p>
                                <button (click)="store.deleteProduct(product.id)"
                                        class="btn btn-danger btn-sm">
                                    Supprimer
                                </button>
                            </div>
                        </div>
                    </div>
                }
            </div>
        }
    `
})
export class ProductListComponent {
    protected readonly store = inject(ProductStore);
    // withHooks onInit a déjà déclenché loadProducts() — aucun appel manuel requis
}
À retenir : Utilisez withHooks({ onInit }) pour les stores globaux (root) et appelez manuellement les méthodes de chargement dans ngOnInit() pour les stores scoped aux composants — cela vous donne plus de contrôle sur le cycle de vie.

Architecture scalable

Pour les applications de taille moyenne à grande, il est recommandé de structurer les stores en feature stores indépendants et de les composer si nécessaire. NgRx Signal Store offre plusieurs patterns avancés pour cela.

Feature stores — un store par domaine métier

// Pattern recommandé : un store par feature/domaine
// src/app/features/auth/auth.store.ts
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
import { firstValueFrom } from 'rxjs';

interface AuthState {
    user: { id: number; email: string; role: string } | null;
    token: string | null;
    isLoading: boolean;
    error: string | null;
}

const initialAuthState: AuthState = {
    user: null,
    token: null,
    isLoading: false,
    error: null
};

export const AuthStore = signalStore(
    { providedIn: 'root' },
    withState(initialAuthState),
    withComputed(store => ({
        // Signal dérivé : l'utilisateur est-il connecté ?
        isAuthenticated: computed(() => store.token() !== null),
        // Signal dérivé : rôle de l'utilisateur (ou 'guest' si non connecté)
        userRole: computed(() => store.user()?.role ?? 'guest'),
        // Signal dérivé : email affiché dans la navbar
        displayName: computed(() => store.user()?.email ?? 'Invité')
    })),
    withMethods((store, http = inject(HttpClient), router = inject(Router)) => ({
        // Connexion — POST /api/auth/login
        async login(email: string, password: string): Promise<void> {
            patchState(store, { isLoading: true, error: null });
            try {
                const response = await firstValueFrom(
                    http.post<{ token: string; user: AuthState['user'] }>(
                        '/api/auth/login',
                        { email, password }
                    )
                );
                // Stocker le token en localStorage pour persistance
                localStorage.setItem('auth_token', response.token!);
                patchState(store, {
                    token: response.token,
                    user: response.user,
                    isLoading: false
                });
                // Rediriger vers le tableau de bord après connexion
                router.navigate(['/dashboard']);
            } catch (err: unknown) {
                const message = err instanceof Error ? err.message : 'Identifiants incorrects';
                patchState(store, { isLoading: false, error: message });
            }
        },

        // Déconnexion — nettoyer l'état et rediriger
        logout(): void {
            localStorage.removeItem('auth_token');
            patchState(store, initialAuthState); // Réinitialiser tout l'état auth
            router.navigate(['/login']);
        },

        // Restaurer la session depuis localStorage au démarrage
        restoreSession(): void {
            const token = localStorage.getItem('auth_token');
            if (token) {
                // Token présent : mettre à jour l'état sans appel API
                patchState(store, { token });
                // Puis valider le token via l'API (optionnel)
            }
        }
    }))
);

Créer des stores personnalisables avec des fonctions factory

// Pattern factory : générer un store configuré dynamiquement
function createPaginatedStore<T>(apiUrl: string) {
    // Retourne un signalStore typé générique
    return signalStore(
        withState({
            items: [] as T[],
            currentPage: 1,
            pageSize: 10,
            totalItems: 0,
            isLoading: false,
            error: null as string | null
        }),
        withComputed(store => ({
            // Calculer le nombre total de pages
            totalPages: computed(() =>
                Math.ceil(store.totalItems() / store.pageSize())
            ),
            // Détecter si on est à la première ou dernière page
            isFirstPage: computed(() => store.currentPage() === 1),
            isLastPage: computed(() =>
                store.currentPage() >= Math.ceil(store.totalItems() / store.pageSize())
            )
        })),
        withMethods((store, http = inject(HttpClient)) => ({
            async loadPage(page: number): Promise<void> {
                patchState(store, { isLoading: true, currentPage: page });
                try {
                    const response = await firstValueFrom(
                        http.get<{ items: T[]; total: number }>(
                            `${apiUrl}?page=${page}&size=${store.pageSize()}`
                        )
                    );
                    patchState(store, {
                        items: response.items,
                        totalItems: response.total,
                        isLoading: false
                    });
                } catch (err: unknown) {
                    const msg = err instanceof Error ? err.message : 'Erreur API';
                    patchState(store, { isLoading: false, error: msg });
                }
            },
            nextPage(): void {
                if (store.currentPage() < Math.ceil(store.totalItems() / store.pageSize())) {
                    this.loadPage(store.currentPage() + 1);
                }
            },
            prevPage(): void {
                if (store.currentPage() > 1) {
                    this.loadPage(store.currentPage() - 1);
                }
            }
        }))
    );
}

// Utilisation : créer des stores paginés typés pour chaque entité
export const ProductListStore = createPaginatedStore<Product>('/api/products');
export const OrderListStore   = createPaginatedStore<Order>('/api/orders');
export const UserListStore    = createPaginatedStore<User>('/api/users');

Bonnes pratiques d'architecture

  • Un store par domaine métier — éviter les stores monolithiques
  • Garder les stores sans référence circulaire — si le store A dépend du store B, injecter B dans les méthodes de A avec inject()
  • Typer l'état initial explicitement — créer une interface TypeScript pour chaque state
  • Ne jamais exposer patchState() en dehors du store — encapsuler toutes les mutations dans des méthodes nommées
  • Utiliser withComputed() pour toute logique de dérivation — jamais dans le template directement
  • Chaque store a une interface TypeScript pour son état
  • L'état initial est exporté comme constante séparée
  • Les méthodes async gèrent les erreurs avec try/catch
  • Les états dérivés sont dans withComputed()
  • Le champ error est réinitialisé avant chaque requête
  • Les stores globaux ont { providedIn: 'root' }
  • Les stores scoped sont dans providers du composant
  • Aucune mutation directe de l'état sans patchState()

Conclusion

NgRx Signal Store représente une évolution majeure dans la gestion d'état Angular. Il conserve les principes fondamentaux de NgRx (état immutable, flux unidirectionnel, encapsulation) tout en éliminant le boilerplate Actions/Reducers/Effects et en adoptant nativement les Signals Angular. Le résultat est un code plus concis, entièrement typé, et parfaitement aligné avec la direction prise par Angular depuis la version 17.

Pour les nouvelles applications Angular 19+, Signal Store est le choix recommandé pour toute gestion d'état qui dépasse le scope local d'un composant. Il est particulièrement efficace pour les feature stores (panier, authentification, données paginées) et les applications qui n'ont pas encore adopté le NgRx classique. Pour les applications enterprise existantes avec NgRx Store, la migration peut se faire progressivement, feature par feature.

À retenir : Commencez par un store simple avec signalStore() + withState() + withMethods(). Ajoutez withComputed() dès que vous avez besoin de valeurs dérivées, et withHooks() pour les chargements automatiques. La courbe d'apprentissage est courte — en moins d'une journée, vous pouvez migrer un composant entier vers Signal Store.

Partager