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
etcreateFeature
? 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. đ€©
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 !