Aller au contenu

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 2021
  • createActionGroup 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.ts :

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. 👏