Skip to content
Learni
Voir tous les tutoriels
Développement Frontend

Comment structurer une app Angular scalable avec NgRx en 2026

Read in English

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

terminal
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 serve

Cette 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

src/app/models/user.model.ts
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

src/app/store/user.actions.ts
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

src/app/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

src/app/store/user.reducer.ts
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

src/app/store/user.effects.ts
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

src/app/store/user.selectors.ts
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

src/app/users-list/users-list.component.ts
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

src/app/app.routes.ts
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 } ou immer pour complexité.
  • Feature-based structure : Dossiers store/user/ avec actions/reducer/effects/selectors.
  • Signals everywhere : toSignal pour components, évite async pipe perf drain.
  • Lazy-loading features : loadChildren avec provideState par feature.
  • Testing : Mock Store/Effects avec provideMockStore dans specs.

Erreurs courantes à éviter

  • Mutations directes : state.users.push() casse time-travel DevTools.
  • Effects sans switchMap : Requests concurrentes explosent (utilisez exhaustMap pour forms).
  • Selectors non-memoïsés : Recalculs inutiles tankent perf (profilez avec DevTools).
  • Forget initialState : toSignal sans fallback = erreurs hydration SSR.

Pour aller plus loin