Introduction
En 2026, les applications Angular d'entreprise exigent une gestion d'état robuste pour gérer la complexité croissante : données asynchrones, interactions utilisateur multiples et scalabilité. NgRx, inspiré de Redux, reste le standard pour cela, enrichi par les Signals (Angular 16+) pour une réactivité fine sans zones, et les Standalone Components (Angular 14+) pour une architecture modulaire sans modules NgModule.
Ce tutoriel expert vous guide pas à pas pour créer une app de gestion d'utilisateurs (CRUD async) : fetch API, add/delete avec optimistic updates, loading/error states. Vous obtiendrez une structure production-ready, optimisée pour le lazy-loading et les tests. Pourquoi c'est crucial ? Sans cela, vos apps Angular souffrent de prop drilling, fuites mémoire et debugging infernal. À la fin, vous bookmerez ce tuto pour vos projets critiques. (142 mots)
Prérequis
- Node.js 20+ et npm 10+
- Angular CLI 18+ (
npm install -g @angular/cli@18) - Maîtrise avancée de TypeScript, RxJS (operators comme
switchMap,catchError) - Connaissances en HTTP et state management (Redux-like)
- Éditeur comme VS Code avec extensions Angular Language Service
Création du projet standalone
ng new users-app --standalone --routing --style=scss --package-manager=npm
cd users-app
generate @angular/common/http
ng add @ngrx/store@18
ng add @ngrx/effects@18
ng add @ngrx/store-devtools@18
npm install
ng serveCette commande initialise un projet Angular 18+ avec standalone components activés par défaut, routing et SCSS. On ajoute HttpClient pour les API calls, puis NgRx Store, Effects et DevTools. Le ng serve lance le dev server sur http://localhost:4200. Piège : sans --standalone, vous tombez dans l'ancien paradigme NgModule polluant.
Définition des modèles et interfaces
Avant le store, définissons le domaine métier. Créez un dossier src/app/models pour user.model.ts. Cela centralise les types, évitant la duplication et facilitant les évolutions.
Modèle User
export interface User {
id: number;
name: string;
email: string;
}
export interface UserState {
users: User[];
selectedUser: User | null;
loading: boolean;
error: string | null;
}
export const initialState: UserState = {
users: [],
selectedUser: null,
loading: false,
error: null
};Interfaces User pour les données API et UserState pour le slice NgRx. initialState fournit l'état par défaut immutable. Analogie : comme un blueprint immuable pour vos données, évitant les mutations accidentelles qui cassent la prédictibilité Redux.
Actions NgRx
import { createAction, props } from '@ngrx/store';
import { User } from '../models/user.model';
export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
'[User] Load Users Success',
props<{ users: User[] }>()
);
export const loadUsersFailure = createAction(
'[User] Load Users Failure',
props<{ error: string }>()
);
export const addUser = createAction(
'[User] Add User',
props<{ user: Omit<User, 'id'> }>()
);
export const addUserSuccess = createAction(
'[User] Add User Success',
props<{ user: User }>()
);
export const deleteUser = createAction(
'[User] Delete User',
props<{ id: number }>()
);
export const deleteUserSuccess = createAction(
'[User] Delete User Success',
props<{ id: number }>()
);Actions créées avec createAction et props pour typer payloads. Conventions [Feature] Event pour traçabilité DevTools. Piège : sans props typed, TypeScript perd en sécurité ; toujours nommer explicitement pour debugging.
Configuration du Store principal
Intégrez NgRx au bootstrap via app.config.ts. Utilisez provideStore avec reducers mappés. Les Signals seront utilisés dans les selectors pour réactivité optimale.
Configuration app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import * as userReducer from './store/user.reducer';
import { UserEffects } from './store/user.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideStore({
users: userReducer.userReducer
}),
provideEffects([UserEffects]),
provideStoreDevtools({ maxAge: 25 })
]
};Bootstrap standalone sans main.ts pollué. provideStore mappe le reducer 'users' ; Effects auto-registrés. DevTools limité à 25 actions pour perf. Analogie : injection centralisée comme un hub électrique, scalable pour +10 features.
Reducer User
import { createReducer, on } from '@ngrx/store';
import { initialState } from '../models/user.model';
import * as UserActions from './user.actions';
export const userReducer = createReducer(
initialState,
on(UserActions.loadUsers, (state) => ({ ...state, loading: true, error: null })),
on(UserActions.loadUsersSuccess, (state, { users }) => ({ ...state, users, loading: false })),
on(UserActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error })),
on(UserActions.addUserSuccess, (state, { user }) => ({ ...state, users: [...state.users, user] })),
on(UserActions.deleteUserSuccess, (state, { id }) => ({
...state,
users: state.users.filter(u => u.id !== id)
}))
);
export function reducer(state: any, action: any) {
return userReducer(state, action);
}Reducer pure function via createReducer et on(). Spread immutable { ...state } obligatoire. Optimistic pour add/delete non géré ici (via Effects). Piège : oublier filter immutable mène à mutations cachées.
Effects pour API calls
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import * as UserActions from './user.actions';
@Injectable()
export class UserEffects {
constructor(private actions$: Actions, private http: HttpClient) {}
loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUsers),
switchMap(() =>
this.http.get<User[]>('https://jsonplaceholder.typicode.com/users?_limit=5').pipe(
map(users => UserActions.loadUsersSuccess({ users })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
)
);
addUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.addUser),
mergeMap(({ user }) =>
this.http.post<User>('https://jsonplaceholder.typicode.com/users', user).pipe(
map(newUser => UserActions.addUserSuccess({ user: { ...newUser, id: Date.now() } })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
), { dispatch: true }
);
deleteUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.deleteUser),
mergeMap(({ id }) =>
this.http.delete(`https://jsonplaceholder.typicode.com/users/${id}`).pipe(
map(() => UserActions.deleteUserSuccess({ id })),
catchError(error => of(UserActions.loadUsersFailure({ error: error.message })))
)
)
)
);
}Effects gèrent side-effects HTTP avec createEffect. switchMap annule requests concurrents ; catchError propage erreurs. API JSONPlaceholder mockée (limite 5 users). Piège : sans { dispatch: true } pour add, pas de dispatch success.
Selectors avec Signals
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState } from '../models/user.model';
const getUserState = createFeatureSelector<UserState>('users');
export const selectUsers = createSelector(getUserState, state => state.users);
export const selectLoading = createSelector(getUserState, state => state.loading);
export const selectError = createSelector(getUserState, state => state.error);
export const selectUsersCount = createSelector(selectUsers, users => users.length);Selectors memoïsés évitent recalculs inutiles. createFeatureSelector cible slice 'users'. En 2026, couplez avec toSignal(selectUsers) dans components pour réactivité signal-based. Analogie : cache intelligent, comme un memo TS mais global.
Composant UsersList
Créez le composant principal consommant le store. Utilisez inject(Store) et Signals pour bindings réactifs.
Composant UsersList
import { Component, inject, signal, effect } from '@angular/core';
import { Store } from '@ngrx/store';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import * as UserActions from '../store/user.actions';
import { selectUsers, selectLoading, selectError } from '../store/user.selectors';
import { User } from '../models/user.model';
@Component({
selector: 'app-users-list',
standalone: true,
imports: [CommonModule, HttpClientModule, ReactiveFormsModule],
template: `
<div>
<h2>Liste des Users ({{ usersCount() }})</h2>
<button (click)="loadUsers()" [disabled]="loading()">Charger</button>
@if (loading()) { <p>Chargement...</p> }
@if (error()) { <p style="color:red">Erreur: {{ error() }}</p> }
<form (ngSubmit)="addUser()">
<input [formControl]="nameCtrl" placeholder="Nom">
<input [formControl]="emailCtrl" placeholder="Email">
<button type="submit" [disabled]="form.invalid">Ajouter</button>
</form>
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }} - {{ user.email }}
<button (click)="deleteUser(user.id)">Supprimer</button>
</li>
}
</ul>
</div>
`
})
export class UsersListComponent {
private store = inject(Store);
users = toSignal(this.store.select(selectUsers), { initialValue: [] });
loading = toSignal(this.store.select(selectLoading));
error = toSignal(this.store.select(selectError));
usersCount = signal(0);
nameCtrl = new FormControl('', [Validators.required]);
emailCtrl = new FormControl('', [Validators.required, Validators.email]);
form = new FormGroup({ name: this.nameCtrl, email: this.emailCtrl });
constructor() {
effect(() => {
this.usersCount.set(this.users().length);
});
}
loadUsers() {
this.store.dispatch(UserActions.loadUsers());
}
addUser() {
if (this.form.valid) {
this.store.dispatch(UserActions.addUser({ user: this.form.value as any }));
this.form.reset();
}
}
deleteUser(id: number) {
this.store.dispatch(UserActions.deleteUser({ id }));
}
}Standalone component avec template inline pour simplicité. toSignal convertit selectors RxJS en Signals réactifs (Angular 16+). @for/@if control flow natif. Effect side pour derived signal. Piège : sans initialValue, crash sur premier render.
Routes et App Component
import { Routes } from '@angular/router';
import { UsersListComponent } from './users-list/users-list.component';
export const routes: Routes = [
{ path: '', component: UsersListComponent },
{ path: '**', redirectTo: '' }
];Routes standalone simples. Intégrez UsersListComponent généré via ng g c users-list --standalone. App bootstrappe tout via config.
Bonnes pratiques
- Immutabilité stricte : Toujours
{ ...state, prop: newValue }ouimmerpour complexité. - Feature-based structure : Dossiers
store/user/avec actions/reducer/effects/selectors. - Signals everywhere :
toSignalpour components, éviteasyncpipe perf drain. - Lazy-loading features :
loadChildrenavecprovideStatepar feature. - Testing : Mock Store/Effects avec
provideMockStoredans specs.
Erreurs courantes à éviter
- Mutations directes :
state.users.push()casse time-travel DevTools. - Effects sans switchMap : Requests concurrentes explosent (utilisez
exhaustMappour forms). - Selectors non-memoïsés : Recalculs inutiles tankent perf (profilez avec DevTools).
- Forget initialState :
toSignalsans fallback = erreurs hydration SSR.
Pour aller plus loin
- Docs officielles : NgRx.io
- Angular Signals deep-dive : angular.dev/signals
- Formations expertes : Découvrez nos formations Learni sur Angular avancé
- Repo GitHub exemple : Forkez ce tuto et ajoutez Router Guards + Resolvers NgRx.