Les best practices NgRx en 2023
TLDR : vous voulez directement avoir accĂšs au code ? Câest par ici : https://github.com/KevTale/ngrx-moderne
Jâutilise NgRx et son Ă©cosystĂšme depuis 2018 et la moindre des choses quâon puisse dire câest que NgRx a Ă©normĂ©ment Ă©voluĂ© ! Si vous nâavez pas suivi les nouvelles fonctionnalitĂ©s de ces deux derniĂšres annĂ©es, cet article est fait pour vous ! En revanche il est tout de quand mĂȘme recommandĂ© de connaĂźtre les bases de NgRx (actions, reducers, selectors) mais je ferai un rĂ©capâ de ces notions lĂ tout de mĂȘme et probablement un article Ă lâavenir.
Jâavoue avoir Ă©tĂ© longtemps gĂȘnĂ© par le grand nombre de fichiers dâun store NgRx, aprĂšs tout câest vrai que pour la moindre fonctionnalitĂ©, on est censĂ© avoir :
- un fichier actions
- un fichier reducer
- un fichier effects
- un fichier selectors
Et chacun de ces fichiers apportent son lot de verbosité.
Mais aujourdâhui, en 2023, cela nâa plus rien Ă voir, notamment grĂące Ă :
createFeature
arrivé en version 12 en septembre 2021createActionGroup
arrivé en version 13 en mai 2022- La fonction
inject
de Angular 14 permettant les functional effects ainsi que dâautres patterns
Toutes ces fonctionnalitĂ©s nous permettent de rĂ©duire grandement la verbositĂ© de NgRx et dâamĂ©liorer la DX de maniĂšre gĂ©nĂ©rale, et peut-ĂȘtre mĂȘme avoir un store avec un seul fichier ?! Voyons tout cela ensemble !
RĂ©capâ de NgRx
Avant de rentrer dans le vif de sujet, faisons un petit rĂ©capâ de ce quâapporte NgRx et des problĂšmes auxquels lâĂ©cosystĂšme rĂ©pond.
En fait, dans la pluspart des applications on aura besoin de gĂ©rer des donnĂ©es et de les faire communiquer entre les composants et services : qui dĂ©tient quelles donnĂ©es, qui les modifient, comment etcâŠ
Il faut trouver une façon de faire ça efficacement, avec des patterns testés et éprouvés, sans réinventer la roue. Sinon on va rapidement tomber dans du spaghetti code.
Et câest lĂ que NgRx entre en jeu !
NgRx est basĂ© sur le modĂšle Redux et est adaptĂ© pour Angular, il offre une structure et une façon de gĂ©rer notre Ă©tat applicatif de façon dĂ©terministique, câest Ă dire quâon doit faire âcomme ça et pas autrementâ !
Cela peut ĂȘtre vu comme un inconvĂ©nient si la structure nâest pas agrĂ©able Ă utiliser mais il y a aussi beaucoup dâavantages : une façon de faire rĂ©pandue qui facilite le debugging, la facilitĂ© Ă comprendre le code de ses collĂšgues, la facilitĂ© Ă lâembauche etc. Et au-delĂ de ça, NgRx propose de grandes performances, un Ă©cosystĂšme complet avec les effects, le router store, les pipes et directives, le store-devtools, component-store et bien dâautres encore.
Nous, on va sâattarder sur le package principal ngrx/store et on va Ă©tudier la façon moderne de lâutiliser.
NgRx Moderne
Je ne vais pas réinventer la roue et je vais créer une todos app car elles ont tous les uses cases intéressants dont on a besoin pour éprouver un store NgRx.
Admettons donc que dans cette application, on a un composant TodosComponent
qui affiche la liste des todos et permet dâen ajouter.
@Component({
standalone: true,
imports: [NgFor, FormsModule],
template: `
<button (click)="todosFeature.loadTodos()">Load all todos</button>
<form (ngSubmit)="addTodo()">
<input name="todoName" [(ngModel)]="todoName" type="text" />
</form>
<ul>
<li *ngFor="let todo of todosFeature.todos()">
{{ todo.name }}
</li>
</ul>
`,
})
export class ProductsComponent {
readonly todosFeature = injectTodosFeature(); // c'est ici que la magie opĂšre
todoName = "";
addTodo() {
this.todosFeature.addTodo(this.todoName);
this.todoName = "";
}
}
Que se cache t-il derriĂšre injectTodosFeature
, telle est la question ! En tout cas, on peut voir quâon a todos
qui semble ĂȘtre un Signal
puisquâon lâexĂ©cute, et on a Ă©galement la mĂ©thode add
qui sâexĂ©cute au submit
du formulaire, cette méthode semble ajouter une nouvelle todo avant de vider le champ todoName
. Enfin, on a un bouton load all todos
.
Bien, essayons ensemble de créer ce store avec ces fonctionnalités dans une toute nouvelle application Angular 16 !
On va commencer par installer NgRx : ng add @ngrx/store
.
Puis on va créer le folder todos
qui contiendra notre composant et un seul fichier store.t
s :
src/
app/
todos/
todos.component.ts
store.ts
app.component.ts
app.config.ts // automatiquement généré depuis Angular 16
app.routes
index.html
main.ts
style.css
Câest ce seul fichier store.ts
qui contiendra notre store. En effet, grĂące aux derniĂšres versions de NgRx, lâAPI a tellement Ă©tĂ© rĂ©duit quâon peut se permettre de faire du single file !
// todos/store.ts
export type Todo = {
id: string;
name: string;
completed: boolean;
}
export type TodosState {
todos: Todo[];
}
export const initialState: TodosState = {
todos: [],
};
La premiĂšre Ă©tape consiste toujours Ă dĂ©finir le contrat dâinterface de notre feature et son Ă©tat initial. Ici on a donc un tableau de todos initialement vide.
Une fois quâon a lâĂ©tat initial, on veut dĂ©finir tous les Ă©vĂšnements qui sâactionneront au sein de notre feature, et on appelle ça les actions ! Une action câest comme un Ă©vĂšnement qui va dĂ©crire quelque chose qui vient dâarriver dans notre application, et ces actions on les dĂ©clenche (dans le jargon NgRx on dit quâon les dispatche), puis une fois dĂ©clenchĂ©e, diffĂ©rentes choses qui Ă©coutent ces actions (reducers et effects) vont rĂ©agir et faire des choses, on va voir ça ensemble.
On peut imaginer que dans notre todos list on a ces actions lĂ :
- Todo créée
- Todo modifiée
- Todo terminée
- Todo supprimée
- Reset des todos (pour supprimer tout et reprendre à zéro notre liste)
Dans LâANCIENNE façon de faire, on procĂ©dait comme ça pour dĂ©finir les actions :
import { createAction, props } from "@ngrx/store";
export const addTodo = createAction(
"[Todos] Add Todo",
props<{ name: string }>()
);
export const editTodo = createAction(
"[Todos] Edit Todo",
props<{ id: string; name: string }>()
);
export const completeTodo = createAction(
"[Todos] Complete Todo",
props<{ id: string }>()
);
export const removeTodo = createAction(
"[Todos] Remove Todo",
props<{ id: string }>()
);
export const resetTodos = createAction("[Todos] Reset Todos");
Chaque action possĂšde sa chaĂźne de caractĂšres uniques, câest ce qui va permettre Ă ceux qui vont Ă©couter sur ces actions de les diffĂ©rencier, ce sont leur identifiants uniques. Les props
câest la donnĂ©e que lâaction embarque avec elle pour que ceux qui Ă©coutent les actions puissent sâen servir. A noter quâune action nâa pas forcĂ©ment besoin de props
(exemple avec todosReset
).
Pour informations, si vous utilisez store-devtools (ce que je vous conseille, jây reviendrai plus bas) et bien lorsque vous dispatchez une action câest bien ces chaĂźnes de caractĂšres que vous verrez dans les logs. Le [Todos]
permet dâidentifier Ă quelle feature appartient lâaction pour nous les devs lorsquâon regarde les logs.
Bon, voici la NOUVELLE façon de faire, attention les yeux :
export const todosActions = createActionGroup({
source: "Todos",
events: {
"Add Todo": props<{ name: string }>(), // [Todos] Add Todo
"Edit Todo": props<{ id: string; name: string }>(), // [Todos] Edit Todo
"Complete Todo": props<{ id: string }>(), // [Todos] Complete Todo
"Remove Todo": props<{ id: string }>(), // [Todos] Remove Todo
"Reset Todos": emptyProps(), // [Todos] Reset Todos
},
});
On a une fonction createActionGroup
qui prend un objet en paramÚtre, cet objet à une clé source
qui a pour valeur le nom de la feature (ce nom sera automatiquement entourĂ© de [] dans les logs) et une clĂ© events qui a pour valeur un objet oĂč on va lister nos diffĂ©rentes actions. Dans cet objet, chaque clĂ© correspond Ă la chaĂźne de caractĂšres dĂ©crivant lâaction, et sa valeur dĂ©crit le props. Si on nâa pas de props il faut mettre emptyProps()
.
Et ce nâest pas fini ! En fait, createActionGroup
renvoie directement les actions ! Sous quelle forme ? Et bien il se base sur les chaĂźnes de caractĂšres des actions pour crĂ©er les actions elles-mĂȘmes !
export const { addTodo, completeTodo, editTodo, removeTodo, resetTodos } =
createActionGroup({
source: "Todos",
events: {
"Add Todo": props<{ name: string }>(),
"Edit Todo": props<{ id: string; name: string }>(),
"Complete Todo": props<{ id: string }>(),
"Remove Todo": props<{ id: string }>(),
"Reset Todos": emptyProps(),
},
});
Ici, jâai dĂ©structurĂ© ce que renvoie createActionGroup
et comme vous le voyez on a une méthode addTodo
, une autre completeTodo
etc. Et câest prĂ©cisĂ©ment parce que jâai appelĂ© mon action Complete Todo
que createActionGroup
a crĂ©e lâaction completeTodo
, en gros ils mettent lâaction en lower camel case.
Vous pouvez aussi directement le faire si vous préférez :
export const todosActions = createActionGroup({
source: "Todos",
events: {
addTodo: props<{ name: string }>(), // [Todos] addTodo
editTodo: props<{ id: string; name: string }>(), // [Todos] editTodo
completeTodo: props<{ id: string }>(),
removeTodo: props<{ id: string }>(),
resetTodos: emptyProps(),
},
});
Je nâai personnellement pas de prĂ©fĂ©rence entre lâun ou lâautre. Peut-ĂȘtre que cette derniĂšre version fait moins magique et plus facilement refactorable, Ă vous de voir !
Parlons maintenant de createFeature
.
Pour faire simple, createFeature
câest Ă la fois notre reducer et nos selectors.
Toujours dans le fichier todos/store.ts
et à la suite du code précédent, on ajoute :
export const todosFeature = createFeature({
name: 'todos',
reducer: createReducer(
initialState,
on(todosActions.addTodo, (state, action) => ({
...state,
todos: [
{ id: uuid(), title: action.title, completed: false },
...state.todos,
],
})),
on(todosActions.completeTodo, (state, action) => ({
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: true } : todo
),
})),
on(todosActions.editTodo, (state, action) => ({
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, title: action.title } : todo
),
})),
on(todosActions.removeTodo, (state, action) => ({
...state,
todos: state.todos.filter((todo) => todo.id !== action.id),
})),
on(todosActions.loadTodosSuccess, (state, action) => ({
...state,
todos: action.todos,
})),
on(todosActions.resetTodos, (state) => ({
...state,
todos: [],
}))
),
createFeature
prend un objet avec deux propriétés : name
qui est le nom de notre feature et reducer
qui est notre reducer classique quâon utilise comme Ă lâaccoutumĂ©.
Ce qui est intĂ©ressant câest ce que return createFeature
:
export const {
name, // le nom de notre feature ("todos" ici)
reducer, // le reducer de notre feature
selectTodosState, // le selector global de notre feature
selectTodos, // le selector de la propriété "todos"
} = createFeature({
name: 'todos',
reducer: createReducer(...)
})
Et oui ! createFeature
va automatiquement créer des selectors en se basant sur les propriétés de notre state ! Pour rappel, mon state est celui-ci :
export type TodosState {
todos: Todo[];
}
Ainsi, createFeature va créer un selector automatiquement pour chacune des propriétés du state sous la forme selectXXX
, par exemple si jâavais une propriĂ©tĂ© loading en plus de todos , ça aurait créé automatiquement selectLoading
. Dingue non ?
Et si on a besoin de selectors en plus, createFeature
a une propriété supplémentaire appelée extraSelectors
et comme son nom lâindique celle-ci nous permet de crĂ©er dâautres selectors Ă notre guise :
export const {
...
selectTodos,
selectHasTodos, // le nouveau selector que je viens de créer !
selectCompletedTodos // le nouveau selector que je viens de créer !
} = createFeature({
name: 'todos',
reducer: createReducer(...),
extraSelectors: ({selectTodos}) => {
return {
selectHasTodos: createSelector(selectTodos, (todos) => todos.length > 0),
selectCompletedTodos: createSelector(selectTodos, (todos) => todos.filter((todo) => todo.completed)),
}
}
})
extraSelectors
prend une fonction en valeur, lâargument de cette fonction est un objet qui contient chacun de nos selectors dĂ©jĂ existant, ici je dĂ©structure selectTodos pour crĂ©er deux autres nouveaux selectors : selectHasTodos
et selectCompletedTodos
! Câest aussi simple que ça !
Personnellement jâadore cette nouvelle façon de faire, je trouve ça trĂšs agrĂ©able Ă utiliser.
Bon, nous avons nos actions, nos selectors, notre reducer⊠Il nous reste les effects !
Je vous en parlais en introduction, grĂące Ă la fonction inject()
arrivĂ©e avec Angular 14, nous avons accĂšs aux functional effects. LâidĂ©e est de pouvoir Ă©crire des effects en dehors de class. Avant nous Ă©tions obligĂ© dâutiliser un Injectable (donc une class) car on injectait Actions de @ngrx/effects
dans son constructor
. Mais ce nâest plus le cas maintenant.
Voyons à quoi ça ressemble :
export const loadTodos$ = createEffect(
(actions$ = inject(Actions)) => {
const http = inject(HttpClient);
return actions$.pipe(
ofType(todosActions.loadTodos),
switchMap(() =>
http.get<Todo[]>("https://jsonplaceholder.typicode.com/todos").pipe(
map((todos) => todosActions.loadTodosSuccess({ todos })),
catchError((error) => of(todosActions.loadTodosFailure({ error })))
)
)
);
},
{ functional: true }
);
Comme vous le voyez, on injecte actions$
grĂące Ă la fonction inject()
, de ce fait nous nâavons pas besoin dâinclure nos effects dans des class, dâoĂč lâappellation functional effects ! On doit simplement ajouter { functional: true }
pour que ça fonctionne.
Si on a besoin dâinjecter un service dans notre effect, on peut le faire comme ça :
export const loadTodos$ = createEffect(
(actions$ = inject(Actions), todosService = inject(TodosService)) => {
return actions$.pipe(
ofType(todosActions.loadTodos),
switchMap(() =>
todosService.getAll().pipe(
map((todos) => todosActions.loadTodosSuccess({ todos })),
catchError((error) => of(todosActions.loadTodosFailure({ error })))
)
)
);
},
{ functional: true }
);
A noter que nous ne sommes pas obligĂ©s dâinjecter dans les arguments de la fonction de lâeffect, voilĂ Ă quoi ça ressemble en injectant directement dans le body de createEffect
:
export const loadTodos = createEffect(
() => {
const todosService = inject(TodosService);
return inject(Actions).pipe(
ofType(todosActions.loadTodos),
switchMap(() =>
todosService.loadAll().pipe(
map((todos) => todosActions.loadTodosSuccess({ todos })),
catchError((error) => of(todosActions.loadTodosFailure({ error })))
)
)
);
},
{ functional: true }
);
Mais il nâest pas recommandĂ© de le faire pour faciliter le testing.
Aussi, dans les faits on peut faire du full single file et mettre nos effects dans le mĂȘme fichier, mais ça peut vite monter Ă 300/400/500 lignes. Personnellement ça ne me dĂ©range pas dâavoir un long fichier si le code est propre. Jâai toujours prĂ©fĂ©rĂ© scroller que cliquer pour changer de fichiers. Mais si ce nâest pas votre cas, nâhĂ©sitez pas Ă faire un autre fichier pour sĂ©parer vos effects !
Ok ! Vous vous rappelez que dans le composant que jâai montrĂ© tout en haut, on a un readonly todosFeature = injectTodosFeature();
? Et bien on va crĂ©er ça, et encore une fois câest grĂące Ă la fonction inject()
. A la fin de notre todos/store.ts
, on ajoute :
export function injectTodosFeature() {
const store = inject(Store);
return {
addTodo: (name: string) => store.dispatch(todosActions.addTodo({ name })),
removeTodo: (id: string) => store.dispatch(todosActions.removeTodo({ id })),
resetTodos: () => store.dispatch(todosActions.resetTodos()),
loadTodos: () => store.dispatch(todosActions.loadTodos()),,
todos: store.selectSignal(todosFeature.selectTodos),
hasTodos: store.selectSignal(todosFeature.selectHasTodos),
completedTodos: store.selectSignal(todosFeature.selectCompletedTodos),
};
}
Câest tout simplement une fonction qui va return un objet avec tous les dispatch dâactions et selectors dont on a besoin. Au final câest comme une façade ! Câest trĂšs pratique car ça nous permet dâexposer Ă lâextĂ©rieur uniquement ce que les consommateurs du store auront besoin.
Vous aurez noter que jâutilise le nouveau store.selectSignal()
, qui prend en argument en selector et qui renvoie le transforme en Signal. Si vous voulez en savoir plus sur les Signals, jâai fait un article complet Ă ce sujet.
Et voilĂ ! Notre store est terminĂ©. Il ne reste plus quâĂ le brancher pour que ça fonctionne. Ici, deux solutions, si vous voulez que votre store soit initialisĂ© au bootstrap de lâapplication, il faudra aller dans votre app.config.ts
(ou main.ts
) :
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
provideStore(), // pour que @ngrx/store fonctionne
provideState(todosFeature), // on initialise notre store
provideEffects({ loadTodos }), // on provide nos effects
],
};
Vous pouvez aussi provide votre store au niveau dâune route comme ça il ne se dĂ©clenchera que lorsque lâutilisateur est sur cette route :
// app.routes.ts
export const routes: Routes = [
{
path: "",
redirectTo: "todos",
pathMatch: "full",
},
{
path: "todos",
loadComponent: () => import("./routes/todos/todos.route"),
providers: [provideState(todosFeature), provideEffects({ loadTodos })],
},
];
Tout est prĂȘt ! Vous pouvez maintenant utiliser votre store dans vos composants !đȘ
Conclusion
Personnellement jâavais pendant longtemps dĂ©laissĂ© ngrx/store
au profit de ngrx/component-store
car je prĂ©fĂ©rais le fait quâil y ait moins de fichiers et de verbositĂ© de maniĂšre gĂ©nĂ©rale. Maintenant lâargument ne tient plus vraiment, avec la version moderne de NgRx je prĂ©fĂšre largement lâutiliser. Sa rigueur, les patterns quâil impose et les devtools (absents de component-store) me plaisent et je fĂ©licite lâĂ©quipe de NgRx dâavoir traitĂ© le plus gros soucis de NgRx : la DX. đ