Aller au contenu

Avez-vous besoin de NgRx ?

“On a pas besoin de NgRx dans notre projet, on fait la mĂȘme chose avec des BehaviorSubject ou des Signals !”
“NgRx est trop verbeux, il y a trop de fichiers !”
“NgRx c’est bien mais uniquement pour les gros projets !”

Ca vous parle ça ? Moi oui. C’est le discours que j’entends depuis 5 ans que j’utilise NgRx. Certains personnes peuvent ĂȘtre rĂ©fractaire Ă  l’idĂ©e d’utiliser cet outil car il est parfois vu comme une usine Ă  gaz difficile Ă  utiliser pour peu de bĂ©nĂ©fice Ă  la fin.

Mais est-ce vrai ?

Est-ce que les derniĂšres versions d’Angular avec les Signals peuvent rendre NgRx superflue ? Est-ce que les derniĂšres versions de NgRx viennent suffisamment rĂ©duire la complexitĂ© de l’outil pour justifier son utilisation ?

Dans cette article, nous allons voir si vous avez besoin de NgRx mais Ă©galement si vous avez intĂ©rĂȘt Ă  l’utiliser.

Note : il existe d’autres solutions tout aussi intĂ©ressantes que NgRx (Akita, Elf, NgXs, StateAdapt
) mais ici on va s’intĂ©resser Ă  NgRx car c’est la plus souvent utilisĂ©e.

Autre note : mĂȘme si je fais un rĂ©cap’ dans l’article, il est recommandĂ© de connaĂźtre le principe de NgRx pour pleinement apprĂ©cier cette article (actions, reducer, effects, selectors). Je prĂ©vois un article et une formation sur le sujet !


Je dois bien avouer que quand on voit ça :

@Injectable({ providedIn: "root" })
export class TodosService {
  readonly #http = inject(HttpClient);

  readonly todos = signal<Todo[]>([]);
  readonly error = signal<string | null>(null);
  readonly hasTodos = computed(() => this.todos().length > 0);

  loadTodos() {
    this.#http
      .get<Todo[]>("api/todos")
      .pipe(takeUntilDestroyed())
      .subscribe({
        next: (todos) => this.todos.set(todos),
        error: (error) => this.error.set(error.message),
      });
  }

  removeTodo(id: number) {
    this.todos.update((todos) => todos.filter((todo) => todo.id !== id));
  }
}
@Component({
  template: `
    <p *ngIf="service.error()">
      Error: {{ service.error() }}
    </p>

    <ul *ngIf="service.hasTodos()">
      <li *ngFor="let todo of service.todos()">
        {{todo.title}}
        <button (click)="service.removeTodo(todo.id)">remove</button>
      </li>
    </ul>
  `,
})
export class TodosComponent {
  readonly service = inject(TodosService);

  constructor() {
    this.service.loadTodos();
  }
}

On peut se demander pourquoi on s’embĂȘterait Ă  faire ça :

export interface State {
  todos: Todo[];
  error: string | null;
}

export const initialState: State = {
  todos: [],
  error: null,
};

// actions
export const todosActions = createActionGroup({
  source: "Todos Page",
  events: {
    todosPageInitialized: emptyProps(),
    loadTodosSucceeded: props<{ todos: Todo[] }>(),
    loadTodosFailed: props<{ error: string }>(),
    removeTodoRequested: props<{ id: string }>(),
  },
});

// reducer et selectors
export const todosFeature = createFeature({
  name: "todos",
  reducer: createReducer(
    initialState,
    on(TodoActions.loadTodosSucceeded, (state, { todos }) => ({
      ...state,
      todos,
    })),
    on(TodoActions.loadTodosFailure, (state, { error }) => ({
      ...state,
      error,
    })),
    on(TodoActions.removeTodoRequested, (state, { todoId }) => ({
      ...state,
      todos: state.todos.filter((todo) => todo.id !== todoId),
    }))
  ),
  extraSelectors: ({ selectTodos }) => ({
    selectHasTodos: createSelector(selectTodos, (todos) => todos.length > 0),
  }),
});

// effects
export const loadTodos = createEffect(
  (actions$ = inject(Actions), http = inject(HttpClient)) =>
    actions$.pipe(
      ofType(todosActions.todosPageInitialized),
      switchMap(() =>
        http.get<Todo[]>("api/todos").pipe(
          map((todos) => todosActions.loadTodosSucceeded({ todos })),
          catchError((error) => of(todosActions.loadTodosFailed({ error })))
        )
      )
    ),
  { functional: true }
);

// facade
export function injectTodosStore() {
  const store = inject(Store);

  return {
    removeTodoRequested: (id: number) =>
      store.dispatch(todosActions.removeTodoRequested({ id })),
    todosPageInitialized: () =>
      store.dispatch(todosActions.todosPageInitialized()),
    todos: store.selectSignal(todosFeature.selectTodos),
    error: store.selectSignal(todosFeature.selectError),
    hasTodos: store.selectSignal(todosFeature.selectHasTodos),
  };
}
@Component({
  template: `
    <p *ngIf="todosStore.error()">Error: {{ todosStore.error() }}</p>

    <ul *ngIf="todosStore.hasTodos()">
      <li *ngFor="let todo of todosStore.todos()">
        {{ todo.title }}
        <button (click)="todosStore.removeTodoRequested(todo.id)">
          remove
        </button>
      </li>
    </ul>
  `,
})
export class TodosListComponent {
  readonly todosStore = injectTodosStore();

  constructor() {
    this.todosStore.todosPageInitialized();
  }
}

La version NgRx est clairement plus verbeuse :

  • 23 lignes de code sans NgRx
  • 54 lignes de code avec NgRx (en comptant l’interface du state et la facade, sinon on est Ă  36)

Et le tout pour un rĂ©sultat fonctionnellement identique. Alors pourquoi s’embĂȘter Ă  utiliser NgRx ?

RĂ©ponse : parce que l’approche de NgRx propose des avantages qui rendront vos codebases plus maintenables.

Vous vous demandez peut-ĂȘtre ce qu’est cette syntaxe NgRx, notamment createActionGroup et createFeature ? C’est la syntaxe moderne de NgRx !
J’en parle plus longuement dans mon article “NgRx en 2023 : les bonnes pratiques”.

La diffĂ©rence fondamentale entre l’approche de NgRx et l’approche “classique”

NgRx se repose sur le pattern Flux proposĂ© par Facebook et utilisĂ© maintenant par bons nombres de solutions de State Management. Ce pattern repose sur la gestion d’un store qui contient un state, des actions, des reducers, des effects et des selectors.

Voilà en quelques mots comment ça fonctionne :

  • A l’état initial, mon application Ă©coute toutes les actions en mĂȘme temps
  • Lorsqu’un Ă©vĂšnement survient dans un endroit de mon application (le clique sur le bouton “remove todo”, une requĂȘte HTTP qui part
) alors on “dispatch” (dĂ©clenche) une action qui dit “il vient de se passer cet Ă©vĂšnement”.
  • Un ou plusieurs endroits (reducers et/ou effects) rĂ©agissent Ă  cette action en modifiant le state ou en dispatchant une nouvelle action
  • Mon state est mis Ă  jour, je peux l’utiliser dans mes composants et/ou services

C’est exactement ce que je fais dans mon store NgRx plus haut :

// todos-list.component.ts

constructor() {
  // cette action décrit l'évÚnement qui vient de se passer
  this.todosStore.todosPageInitialized();
}
// todos.store.ts

export const loadTodos = createEffect(
  (
    actions$ = inject(Actions), // 👈 le bus d'actions de mon app
    http = inject(HttpClient)
  ) =>
    actions$.pipe(
      ofType(todosActions.todosPageInitialized), // 👈 j'Ă©coute l'action qui m'intĂ©resse
      switchMap(() =>
        http.get<Todo[]>('api/todos').pipe(
          // 👇 je dispatch l'action correspondante selon le success ou fail
          map((todos) => todosActions.loadTodosSucceeded({ todos })),
          catchError((error) => of(todosActions.loadTodosFailed({ error })))
        )
      )
    ),
  { functional: true }
);

export const todosFeature = createFeature({
  name: 'todos',
  reducer: createReducer(
    initialState,
    // 👇je modifie mon state ici
    on(TodoActions.loadTodosSucceeded, (state, { todos }) => ({ ...state, todos })),
    on(TodoActions.loadTodosFailed, (state, { error }) => ({ ...state, error })),
  )
});

Tandis que sur l’approche classique oĂč j’exĂ©cute une fonction, on passe par moins d’étapes :

// todos-list.component.ts

constructor() {
  // le composant exécute la fonction dans le constructor
  this.todosService.loadTodos();
}
@Injectable({ providedIn: 'root' })
export class TodosService {
  readonly todos = signal<Todo[]>([]);
  readonly error = signal<string | null>(null);

  loadTodos() {
    this.#http
      .get<Todo[]>('api/todos')
      .pipe(
        takeUntilDestroyed()
      )
      // 👇je modifie mes signals ici
      .subscribe({
        next: (todos) => this.todos.set(todos),
        error: (error) => this.error.set(error.message),
      });
  }
}

En terme de lisibilité une approche classique gagne à plat de couture. Et vous commencez à me connaßtre si vous me lisez souvent, je suis un amoureux de la simplicité et de la DX agréable.

Mais il y a dĂ©savantage clair Ă  l’approche classique : si j’ai besoin de rĂ©agir Ă  loadTodos Ă  un autre endroit de mon application alors les ennuis commencent.

Je m’explique.

Admettons que mon PO me demande une Ă©volution. DĂ©sormais quand les todos sont chargĂ©s je dois dĂ©clencher 3 autres requĂȘtes HTTP et/ou changer une donnĂ©e d’un autre state de mon app.

Comment faire Ă©voluer mon code en ce sens ?

Sans NgRx

Est-ce la fonction loadTodos de mon service qui doit porter cette logique ?

@Injectable({ providedIn: 'root' })
export class TodosService {
  // j'injecte mes autres services 👇
  readonly #service1 = inject(Service1);
  readonly #service2 = inject(Service2);
  readonly #service3 = inject(Service3);

  readonly todos = signal<Todo[]>([]);
  readonly error = signal<string | null>(null);

  loadTodos() {
    this.#http
      .get<Todo[]>('api/todos')
      .pipe(
        takeUntilDestroyed()
      )
      .subscribe({
        next: (todos) => {
          this.todos.set(todos);
          // j'appelle le load des autres services
          this.#service1.load();
          this.#service2.load();
          this.#service3.load();
        },
        error: (error) => this.error.set(error.message),
      });
  }
}

Mais dans ce cas-là ma separation of concerns est complÚtement brisée, mon loadTodos fait bien plus que ce qui prétend faire, mon code devient complÚtement impératif et plus lourd à tester car je dois mock plusieurs services.

Alors si ce n’est pas le service qui doit porter cette logique, c’est peut-ĂȘtre le composant ?

@Component({...})
export class TodosComponent {

  readonly #todosService = inject(TodosService);
  readonly #service1 = inject(Service1);
  readonly #service2 = inject(Service2);
  readonly #service3 = inject(Service3);

  readonly todos = this.#todosService.todos;

  constructor() {
    this.todosService.loadTodos()
      .subscribe({
        next: (todos) => {
          this.#service1.load();
          this.#service2.load();
          this.#service3.load();
        },
      })
  }
}
@Injectable({ providedIn: 'root' })
export class TodosService {
  
  readonly todos = signal<Todo[]>([]);
  readonly error = signal<string | null>(null);

  // j'ai du modifier mon ancien code pour qu'il return l'observable
  // et qu'il set les 'signals' dans l'opérateur 'tap'
  loadTodos() {
    return this.#http
      .get<Todo[]>('api/todos')
      .pipe(
        tap({
          next: (todos) => this.todos.set(todos),
          error: (error) => this.error.set(error.message),
        }),
        takeUntilDestroyed()
      )
  }
}

Ce n’est pas spĂ©cialement mieux. Le composant Ă  maintenant trop de logique impĂ©rative, la separation of concerns est Ă©galement brisĂ©e.

Bref, dans les deux cas on sent venir le spaghetti code et faire des TUs devient complexe. Mais Ă©galement : on a du replonger dans les features qu’on avait dĂ©jĂ  codĂ© pour les modifier.

Mais avec NgRx ?

Avec NgRx

// le composant ET l'effect restent inchangés !
// AUCUNE modification n'est nécessaire sur l'ancien code !

// Dans mes autres stores j'Ă©coute sur le loadTodosSucceeded que
// j'avais dispatché au retour API dans todos.store.ts
export const doSomething = createEffect(
  (actions$ = inject(Actions)) =>
    actions$.pipe(
      // 👇 ici
      ofType(TodosActions.loadTodosSucceeded),
      ...
    )
  ,
  { functional: true }
);


export const someStoreFeature = createFeature({
  name: 'someStore',
  reducer: createReducer(
    initialState,
    // 👇 et là
    on(TodosActions.loadTodosSucceeded, (state) => ...),
  )
});

Vous voyez ce que je viens de faire ? Avec NgRx les autres stores de mon application écoutent également les actions des stores qui les intéressent !

Et ça change TOUT !

Les paradigmes sont complĂštements diffĂ©rents, avec l’approche classique, qu’on appelle “Command Pattern”, j’appelle une fonction qui appelle une fonction qui appelle une fonction tandis qu’avec NgRx je dispatch une action qui peut ĂȘtre Ă©coutĂ©e par tout le monde et rĂ©agir de maniĂšre diffĂ©rente selon qui l’écoute !

C’est une approche impĂ©rative versus une approche dĂ©clarative et rĂ©active.

  • Avec une approche classique, vous donnez des ordres : “charge les todos, puis charge cela, puis fais ceci !” et dans chaque “ceci ou cela” des ordres subsĂ©quents peuvent ĂȘtre Ă©galement donnĂ©s.
  • Avec NgRx (Flux Pattern), quand un Ă©vĂšnement survient (ex: “le composant vient de se charger”) vous le poussez dans un flux d’actions et ceux qui Ă©coutent dessus peuvent rĂ©agir comme bon leur semble.

En rĂ©sumĂ©, avec le Flux Pattern, n’importe quel endroit de l’application peut rĂ©agir Ă  cet Ă©vĂšnement alors que le Command Pattern exige que l’on exĂ©cute explicitement les fonctions.

On peut mĂȘme faire ce genre de choses avec NgRx :

export const someEffect = createEffect(
  (actions$ = inject(Actions)) =>
    $actions.pipe(
      ofType(
        someAction1,
        someAction2,
        someAction3,
       ),
      ...
    )
);

export const someStoreFeature = createFeature({
  name: 'someStore',
  reducer: createReducer(
    initialState,
    on(
      someAction1,
      someAction2,
      someAction3,
      (state) => ...
    ),
  )
});

En gros, un effect ou un reducer peuvent écouter sur plusieurs actions. Dans une approche classique le résultat serait beaucoup moins agréable à gérer car on devrait :

  • Replonger dans l’ancienne feature pour ajouter l’exĂ©cution de fonctions d’autres services
  • Adapter le TU en consĂ©quence
  • Serrer les fesses pour espĂ©rer ne pas avoir introduit de rĂ©gressions

Et lĂ  mon exemple est simple, une simple todos list avec une feature de load et de remove. Imaginez une application avec des dizaines de pages, des centaines de features et des dizaines de personnes qui l’ont maintenu pendant des annĂ©es. L’effet se multiplierait de plus en plus ! đŸ€Ż

Avec NgRx correctement appliquĂ©, on minimise cet impact car chaque partie logique de l’app est isolĂ©. On a pas de relation direct entre les features, on a des Ă©vĂšnements et des rĂ©actions.

Nos composants ne servent que la UI, ils vont uniquement dispatch des actions qui décrivent les évÚnements, par exemple pageInitialized, et des morceaux de codes vont catch cet évÚnement pour faire des calls HTTP ou autres.

Voyez-vous maintenant la puissance du Flux Pattern sur lequel se base NgRx et pourquoi cela peut rendre vos applications plus maintenable ?

Et ce n’est pas tout ! L’écosystĂšme NgRx possĂšde beaucoup de fonctionnalitĂ©s lĂ  pour vous aider.

L’écosystĂšme NgRx

NgRx est bien plus qu’une librairie, c’est un Ă©cosystĂšme disposant d’une multitude d‘extensions pour vous aider dans vos besoins quotidiens. Par exemple effects est l’une de ces extensions. Mais il en existe d’autres.

Router-Store

On y trouve “@ngrx/router-store” qui nous renvoie un tas de selectors trùs utile :

import { getRouterSelectors, RouterReducerState } from "@ngrx/router-store";

export const {
  selectCurrentRoute, // select the current route
  selectFragment, // select the current route fragment
  selectQueryParams, // select the current route query params
  selectQueryParam, // factory function to select a query param
  selectRouteParams, // select the current route params
  selectRouteParam, // factory function to select a route param
  selectRouteData, // select the current route data
  selectRouteDataParam, // factory function to select a route data param
  selectUrl, // select the current url
  selectTitle, // select the title if available
} = getRouterSelectors();

C’est trùs utile pour faire de la composition de selectors ou pour utiliser dans vos effects.

Developer Tools

C’est le gros plus de NgRx/store ! On a accĂšs au state global de notre application dans le Redux Devtools Extension. Ainsi, on peut voir Ă  tout moment chacun de nos states, l’historique des actions dispatchĂ©es avec leurs payload et mĂȘme rejouer ces derniĂšres !

C’est vraiment un must-have et c’est une merveille pour le debugging. đŸ€©

Plus d’infos ici.

Component Store

Voici une super extension que j’ai beaucoup utilisĂ©. Elle vous permet de gĂ©rer un store local (pour votre composant) sans les actions, reducers etc mais tout en gardant de bonnes pratiques et performances.

On peut voir l’équivalent de notre applications todos sous Component Store :

export interface State {
  todos: Todo[];
  error: string | null;
}

export const initialState: State = {
  todos: [],
  error: null,
};

@Injectable()
export class TodosListStore extends ComponentStore<State> {
  readonly #todosService = inject(TodosService);

  readonly todos = this.selectSignal(state => state.todos);
  readonly error = this.selectSignal(state => state.error);
  readonly hasTodos= this.selectSignal(state => state.todos > 0);

  constructor() {
    super(initialState);
  }

  readonly loadTodos= this.effect<void>(
     (trigger$) => trigger$.pipe(
      switchMap(() =>
        this.#todosService.loadTodos().pipe(
          tapResponse({
            next: (todos) => this.patchState({ todos },
            error: (error: HttpErrorResponse) => this.patchState({ error},
          })
        )
      )
    )
  );

  removeTodo(id: number) {
    this.todos.setState(state => ({...state, todos: todos.filter(todo => todo.id !== id)}))
  }
}

C’est une bonne alternative si vous voulez une approche simple du State Management. Je vous conseille cette extension plutĂŽt que d’implĂ©menter une solution de State Management faite maison.

En revanche il est Ă  noter que :

  • Le Developer Tool ne fonctionne pas avec Component Store
  • Cela reste du Command Pattern

D’autres bonnes raisons d’utiliser NgRx

Si vous utilisez correctement NgRx, vous pourrez ĂȘtre certains que mĂȘme une nouvelle personne qui rejoint l’équipe prendra en mains rapidement votre application.

Aussi, NgRx est constamment mis Ă  jour et ils travaillent avec la team Angular pour toujours avancer dans la mĂȘme direction. C’est pour cela que NgRx est trĂšs performants et les features trĂšs adaptĂ©s au framework Angular.

Vous avez peut-ĂȘtre envie de crĂ©er votre propre solution basĂ©e sur les principes de Redux comme certains le font et il serait intĂ©ressant de le faire car cela vous aidera Ă  comprendre parfaitement les avantages de cette approche. Mais ayez en tĂȘte qu’en faisant cela, il y a de fortes chances que vous ne fassiez que recrĂ©er la roue (en probablement moins bien). Vous devrez la maintenir, la faire Ă©voluer, la documenter
 Bref c’est un boulot Ă  pleins temps ! Et croyez moi, des projets qui utilisent correctement NgRx ça ne court pas les rues, alors leurs propres solutions faites maison
 😬

Sachez Ă©galement que connaĂźtre NgRx aide trĂšs largement Ă  l’embauche car beaucoup de projets l’utilisent. MaĂźtriser cette outil et l’indiquer sur son CV est un gros plus.

Enfin, des travaux sont en cours pour proposer un ngrx/signals. Vous pouvez dĂ©couvrir la doc ici. Cela semble trĂšs prometteur, le boilerplate est encore plus rĂ©duit que pour component-store ! J’ai l’impression qu’on reste sur du Command Pattern mais j’ai hĂąte de voir ça. Ce n’est franchement pas impossible qu’à terme cela devienne ma solution par dĂ©faut. A voir quand ça sortira ! Je ferai un article dessus of course !

Des raisons de ne pas utiliser NgRx

Si votre Ă©quipe n’arrive pas Ă  prendre NgRx/store en mains, alors ne l’utilisez pas. AprĂšs tout le but est d’ĂȘtre productif, et NgRx/store Ă  un coĂ»t non nĂ©gligeable en terme d’apprentissage.

Donc si vous ĂȘtes dans un rush, que vous avez des deadline serrĂ©es et que le rapport risque/coĂ»t n’est pas bon, utilisez plutĂŽt NgRx/component-store voire les Signals en “vanilla”.

Ce n’est Ă©videmment pas non plus garanti que votre Ă©quipe fasse du travail propre avec component-store ou les Signals, mais au moins il n’y a pas tout une mĂ©canique Ă  apprendre, c’est dĂ©jĂ  ça !

Mais c’est indĂ©niable : NgRx est verbeux et je comprends que ça puisse en rebuter certains. Cependant Ă  mon sens cette verbositĂ© vaut le coup sur le long terme car vous aurez une application parfaitement rĂ©active, dĂ©clarative et qui permet une meilleure separation of concerns.

Conclusion

A la question “avez-vous besoin de NgRx” je rĂ©ponds : non, vous pouvez faire des applications qualitatives sans.

Mais Ă  la question “avez-vous intĂ©rĂȘt Ă  utiliser NgRx”, je rĂ©ponds : OUI ! Votre application n’en sera que plus qualitative, maintenable et suivra de meilleures pratiques qui se reposent sur la programmation dĂ©claratives et rĂ©actives !