Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jprivet-dev/14c32852313b310d6e60c3e92b733c26 to your computer and use it in GitHub Desktop.
Save jprivet-dev/14c32852313b310d6e60c3e92b733c26 to your computer and use it in GitHub Desktop.
[Mémo] GDG Toulouse - Faire du Redux avec Angular et NgRx v4 (Bruno Baia)

[Mémo] GDG Toulouse - Faire du Redux avec Angular et NgRx v4 (Bruno Baia)

1. Ressources

Faire du Redux avec Angular et NgRx v4 - Bruno Baia (@brbaia) - GDG Toulouse

📎
Retrouvez le listing de mes mémos, tips et autres sur https://github.com/jprivet-dev/memos

2. Constat

Les applications web (on parle de web apps et non plus de sites web) deviennent de plus en plus compliquées. Il est possible aujourd’hui de faire des clients riches/lourds avec du JavaScript.

Ces applications web deviennent de plus en plus complexes, tout est connecté à tout, et l’état de ces applications change beaucoup.

Ces changements viennent de différents endroits :

  • Etat côté…​ :

    • serveur

    • client

  • Les données…​ :

    • en cache

    • du websocket en live

    • créées en local

    • liées à l’interface graphique

    • de l’URL

Dès que vous touchez quelque chose dans l’application, vous cassez facilement quelque chose ailleur. Le pattern MVC a atteint ses limites pour ce type d’application.

Facebook a proposé Redux, un framework JavaScript qui permet de gérer l’état applicatif de manière plus prédictible.

3. Redux : les différents concepts utilisés

Redux s’inspire et se base sur les concepts suivant.

3.1. L’architecture Flux (unidirectional data flow) :

Flux unidirectionel des données : les composants vont communiquer entre eux toujours dans le même sens (pas de data binding double).

3.2. CQRS (Command Query Responsibility Segregation)

On sépare tout ce qui est écriture de tout ce qui est lecture. Pour l’écriture on a besoin de transaction, de cohérence, d’intégrité et de normalisation alors que pour la lecteur ce n’est pas nécessaire.

3.3. Event Sourcing.

On gère les changements de l’application comme une séquence d’événements. On ne se préocuppe plus de l’état en cours de l’application : on veut simplement connaître la suite des événements qui permettent d’arriver à l’état actuel de l’application.

3.4. L’immutabilité

Les objets ne sont plus modifiables après les avoir créés :

  • + de performance

  • + de cohérence

  • Facilite le debbuging

3.5. Concepts

x concepts
  1. Les composants vont émettre des intentions, des actions.

  2. Les actions vont modifier l’état de l’application via des reducers.

  3. À chaque fois que le store évolue, que l’état de l’application est modifié, cela met à jour les composants qui se sont abonnés.

4. Les 3 principes fondamentaux de Redux

4.1. Single source of truth

"The state of your whole application is stored in an object tree within a single store".

L’état est la seule source de vérité de l’application.

Votre état va être gérée avec un POJO (Plain Old JavaScript Object), un object basique JavaScript (avec des propriétés, des valeurs et des tableaux) pour stocker l’état de manière simple :

{
  "todos": [ // (1)
      {
          "text": "Consider using Redux",
          "completed": true
      },
      {
          "text": "Keep all state in a single tree",
          "completed": false
      }
  ],
  "filter": "SHOW_ALL" // (2)
}
  1. Tableau de tâches, avec un texte et un état par tâche

  2. Filtre que l’on souhaite appliquer pour l’affichage de la liste des tâches (pour afficher tous les todos, uniquement ceux qui sont complétés, …​)

4.2. State is read-only

"The only way to change the state is to emit an action, an object describing what happened".

L’état est en lecture seule (principe d’immutabilité).

Il ne sera pas possible de faire :

todos.completed = false

Pour mettre à jour le store, il sera nécessaire de passer par une action. Exemples de payloads (propriétés d’une action) :

{
  "type": "ADD_TODO", // (1)
  "text": "Visit delair.aero"
}
  1. Ici on souhaite ajouter une nouvelle tâche

{
  "type": "SET_VISIBILITY_FILTER",
  "filter": "SHOW_COMPLETED" // (1)
}
  1. Ici on souhaite changer le filtre et afficher uniquement les todos complétés

4.3. Changes as pure functions (reducers)

"To specify how the state tree is transformed by actions, you write pure functions (reducers)"

Les reducers vont être les seuls éléments qui vont permettre de modifier l’état de l’application.

Les fonctions pures sont des fonctions qui vont toujours renvoyer la même chose si on leur donne en entrée toujours les mêmes infos (cela facilitera les tests).

Si par exemple, une fonction se base sur une variable globale, on ne pourra plus la considérer comme pure car si on l’appelle deux fois de suite avec les mêmes paramètres, elle pourrait renvoyer deux résultats différents.

Exemple de reducer :

function todos(state = {}, action) {
    switch (actions.type) { // (1)
        case 'ADD_TODO':
            return { // (2)
                ...state, // (3)
                todos: [
                    ...state.todos,
                    {
                        text: action.text,
                        completed: false
                    }
                ]
            };
        case 'SET_VISIBILITY_FILTER':
            return {
                ...state,
                visibilityFilter: action.filter // (4)
            };
    };
}
  1. On va parcourir tous les types d’actions.

  2. Ici on met à jour la propriété todos du state, en retournant un nouvel objet auquel est concaténé à state.todos une nouvelle tâche.

  3. Usage de la syntaxe de décomposition …​ (spread syntax).

  4. Ici on met à jour la propriété visibilityFilter du state avec la nouvelle valeur action.filter.

On ne modifie jamais l’état en cours, on renvoie toujour un nouvel object pour l’état.

4.4. Bénéfices

Cela va être plus facile de comprendre l’application :

  • On a un flux de données undirectionnel.

  • On sait d’où vienne les données.

  • On sait où va se produire le problème.

  • Il est plus facile de mentaliser le fonctionnement.

  • Il est plus facile de savoir où placer son code.

  • Avoir des POJO, pour gérer l’état et les actions, facilite le debug et l’inspection.

  • Comme l’état est sérializable et qu’il est possible de le persister, il sera posssible de revenir dans le temps, de supprimer des actions. On va pouvoir débuger, faire du hot module reloading, etc.

5. Pourquoi NgRx ?

  • Ng ⇒ Angular

  • Rx ⇒ RxJS

NgRx s’adapte très bien à la partie programmation fonctionnelle et réactive d’Angular, avec le async pipe, le RxJS, le onpush strategy, …​

6. Démo : @ngrx/store avec une appli todos

@ngrx/store est le module commun, la base pour gérer le state de l’application.

Structure de l’application :

x todos
app
|-- store
|   `-- meta-reducers.ts (1)
|-- todos
|   |-- component
|   |   |-- footer (2)
|   |   |   |-- footer.component.html
|   |   |   `-- footer.component.ts
|   |   |-- new-todo (3)
|   |   |   |-- new-todo.component.html
|   |   |   `-- new-todo.component.ts
|   |   |-- todo (4)
|   |   |   |-- todo.component.html
|   |   |   `-- todo.component.ts
|   |   |-- todo-list
|   |   `-- todo-list-item
|   |-- models
|   |   |-- index.ts
|   |   |-- todo-filter.model.ts
|   |   `-- todo.model.ts
|   `-- store
|       |-- actions.ts     (5)
|       |-- effects.ts     (6)
|       |-- index.ts
|       |-- reducers.ts    (7)
|       `-- selectors.ts   (8)
|-- assets
`-- environments
  1. meta-reducers : le storeFreeze est configurés ici.

  2. footer : c’est un composant basique "dumb" (il ne va faire que de la présentation). Il affiche le nombre de todos restant, les filtres…​

  3. new-todo : composant pour créer des todos.

  4. todo : c’est un composant de type container, c’est à dire qu’il va être pure. Il ne fait que sélectionner des états et dispatcher des actions.

  5. actions : les composants vont émettre des actions. Les actions vont modifier l’état de l’application via des reducers.

  6. effects : les effets vont nous permettre d’écouter les actions.

  7. reducers : ce sont les seuls éléments qui vont permettre de modifier l’état de l’application.

  8. selectors : les sélecteurs vont nous permettre de récupérer des données calculées à chaud, qui ne seront pas stocker dans le store.

Exemple de fichier :

// footer.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';

import { TodoFilter } from '../../models';

@Component({
  selector: 'app-footer',
  templateUrl: './footer.component.html',
})
export class FooterComponent {
  @Input() undoneTodosCount: number;                    // (1)
  @Input() currentFilter: TodoFilter;                   // (2)
  @Output() filter = new EventEmitter<TodoFilter>();    // (3)
  @Output() clearCompleted = new EventEmitter();        // (4)
}
  1. Nombre de todos restants

  2. Le filtre en cours utilisé

  3. Événement lorsque l’on va vouloir filtrer

  4. Événement lorsque l’on va vouloir supprimer tous les todos en complétés

6.1. Actions

Actions are one of the main building blocks in NgRx. Actions express unique events that happen throughout your application. From user interaction with the page, external interaction through network requests, and direct interaction with device APIs, these and more events are described with actions.

— NgRx documentation
https://ngrx.io/guide/store/actions

Dans actions.ts on va retrouver les actions suivantes :

// actions.ts
export const ADD_TODO = '[TODO] add';
export const DELETE_TODO = '[TODO] delete';
export const TOGGLE_TODO = '[TODO] toggle';
export const UPDATE_TODO = '[TODO] update';
export const LOAD_TODOS = '[TODO] load';
export const LOAD_TODOS_COMPLETED = '[TODO] load completed';
export const CLEAR_COMPLETED_TODO = '[TODO] clear completed';
export const SET_TODO_FILTER = '[TODO] Set filter';

// ...

export class SetFilterAction implements Action {
  readonly type = SET_TODO_FILTER;

  constructor(public filter: TodoFilter) {}
}

Avec NgRx on peut créer des actions à partir de classe, ce qui est pratique au niveau du typage. Ici il sera plutôt utilisé des objets POJO.

Grace à l’outil NgRx Store Freeze, on ne peut pas rajouter de propriété à un objet qui n’est pas extensible. On ne peut pas modifier les états ou les actions (ils sont censés être immutables).

Plus tard ce sera le composant "container" todo.component.ts qui dispatchera les actions :

// todo.component.ts

// ...

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
})
export class TodoComponent implements OnInit {
  // ...

  constructor(private store: Store<fromTodos.State>) {
    // ...
  }

  ngOnInit() {
    this.store.dispatch(new fromTodos.LoadAction());
  }

  onAddTodo(text: string) {
    this.store.dispatch(new fromTodos.AddAction(text));
  }

  onToggle(id: number) {
    this.store.dispatch(new fromTodos.ToggleAction(id));
  }

  onUpdate(event: { id: number; text: string }) {
    this.store.dispatch(new fromTodos.UpdateAction(event.id, event.text));
  }

  onDelete(id: number) {
    this.store.dispatch(new fromTodos.DeleteAction(id));
  }

  onFilter(filter: TodoFilter) {
    this.store.dispatch(new fromTodos.SetFilterAction(filter));
  }

  onClearCompleted() {
    this.store.dispatch(new fromTodos.ClearCompletedAction());
  }
}

6.2. Reducers

Reducers in NgRx are responsible for handling transitions from one state to the next state in your application. Reducer functions handle these transitions by determining which actions to handle based on the action’s type.

— NgRx documentation
https://ngrx.io/guide/store/reducers
📎
Comme expliqué précédemment, les reducers vont être les seuls éléments qui vont permettre de modifier l’état de l’application.
// reducers.ts

// ...

export interface State { // (1)
  todos: Todo[];
  filter: TodoFilter;
}

const initialState: State = { // (2)
  todos: [],
  filter: 'SHOW_ALL',
};

export function reducer( // (3)
    state: State = initialState,
    action: fromTodos.TodoActionType,
): State {
    switch (action.type) {
        case fromTodos.ADD_TODO: {
            return { /* ... */ }
        }
        case fromTodos.TOGGLE_TODO: {
            return { /* ... */ }
        }
        case fromTodos.DELETE_TODO: {
            return { /* ... */ }
        }
        case fromTodos.UPDATE_TODO: {
            return { /* ... */ }
        }
        case fromTodos.CLEAR_COMPLETED_TODO: {
            return { /* ... */ }
        }
        case fromTodos.SET_TODO_FILTER: {
            return { /* ... */ }
        }
        default: {
            return state;
        }
    }
}

// ...
  1. L’état de l’application est typé

  2. L’état est initialisé avec une liste de todos vide et avec un filtre qui affiche tout

  3. On retrouve ici le reducer

Ci-après les modèles utilisés :

// todo-filter.model.ts
export type TodoFilter = 'SHOW_ALL' | 'SHOW_ACTIVE' | 'SHOW_COMPLETED';
// todo.model.ts
export interface Todo {
  id: number;
  text: string;
  creationDate: Date;
  completed: boolean;
}

Exemple de reducer avec ADD_TODO :

// reducers.ts
    switch (action.type) {
        case fromTodos.ADD_TODO: {
            return {                      // (1)
                ...state,
                todos: [                  // (2)
                    ...state.todos,       // (3)
                    {
                        id: action.id,
                        text: action.text,
                        completed: false,
                    }
                ],
            }
        }
    }
  1. On retourne un nouvel objet avec un todo supplémentaire

  2. On met à jour la propriété todos du state

  3. On concatène un nouveau todo au tableau state.todos

Exemple qui ne fonctionnerait pas :

// reducers.ts
    switch (action.type) {
        case fromTodos.ADD_TODO: {
            state.todos.push({ // (1)
                id: action.id,
                text: action.text,
                completed: false,
            });
            return state;      // (2)
        }
    }
  1. On rajoute un nouveau todo au tableau state.todos

  2. On retourne l’objet state mis à jour

Dans ce cas on se retrouvera avec une erreur en console TypeError: Cannot add property 0, object is not extensible.

📎
Grâce à l’outils NgRx Store Freeze, on ne peut pas modifier le state car il est immutable.

6.3. Selectors

Selectors are pure functions used for obtaining slices of store state. @ngrx/store provides a few helper functions for optimizing this selection. Selectors provide many features when selecting slices of state: Portability , Memoization, Composition, Testability, Type Safety.

— NgRx documentation
https://ngrx.io/guide/store/selectors

Les selectors vont permettre de limiter le nombre de données à enregistrer dans le store.

Les composants graphiques peuvent s’abonner aux états de l’application. L’idée serait de s’abonner à certaines parties (aux changements du filtre, aux modifications des todos, …​) et non à l’ensemble de l’application. C’est ce que permettent de faire les selectors.

// selectors.ts
export const getTodos = createSelector(/* ... */);
export const getLoading = createSelector(/* ... */);
export const getFilter = createSelector(/* ... */);
export const getFilteredTodos = createSelector(/* ... */);
export const getHasTodos = createSelector(/* ... */);
export const getUndoneTodosCount = createSelector(/* ... */);

Par exemple le getFilteredTodos va retourner directement la liste des todos filtrées sans qu’il soit nécessaire d’enregistrer cette liste dans le store :

// selectors.ts
export const getFilteredTodos = createSelector(
  getAllTodos,
  getFilter,
  (todos, filter) => {
    switch (filter) {
      default:
      case 'SHOW_ALL':
        return todos;
      case 'SHOW_COMPLETED':
        return todos.filter(t => t.completed);
      case 'SHOW_ACTIVE':
        return todos.filter(t => !t.completed);
    }
  },
);

Ou bien, au lieu d’enregistrer dans l’état un attribut qui indique s’il y a des todos dans la liste ou non, il sera possible de récupérer dynamiquement cette information avec getHasTodos :

// selectors.ts
export const getHasTodos = createSelector(getTotalTodos, totalTodos => {
  return totalTodos > 0;
});
💡
Les selectors sont composables : on peut créer des sélecteurs avec plusieurs autres sélecteurs.

Ensuite ces sélecteurs peuvent être utilisés dans un composant. Par exemple, dans todo.component.ts, il va être possible de s’abonner aux 4 événements qui nous intéressent :

// todo.component.ts
export class TodoComponent implements OnInit {
  hasTodos$: Observable<boolean>;
  undoneTodosCount$: Observable<number>;
  currentFilter$: Observable<TodoFilter>;
  filteredTodos$: Observable<Todo[]>;
  loading$: Observable<boolean>;

  constructor(private store: Store<fromTodos.State>) {
    this.hasTodos$ = this.store.select(fromTodos.getHasTodos);
    this.undoneTodosCount$ = this.store.select(fromTodos.getUndoneTodosCount);
    this.currentFilter$ = this.store.select(fromTodos.getFilter);
    this.filteredTodos$ = this.store.select(fromTodos.getFilteredTodos);
    this.loading$ = this.store.select(fromTodos.getLoading);
  }

  // ...
}

De plus dans le template todo.component.html, grace au | async (pipe async), il ne sera pas nécessaire de gérer les subscribe et les unsubscribe :

<header class="header">
  <h1>todos</h1>
  <app-new-todo *ngIf="!(loading$ | async); else loading"
    (addTodo)="onAddTodo($event)"></app-new-todo>
</header>
<app-todo-list [todos]="filteredTodos$ | async"
  (toggle)="onToggle($event)"
  (update)="onUpdate($event)"
  (delete)="onDelete($event)">
</app-todo-list>
<app-footer *ngIf="hasTodos$ | async"
  [undoneTodosCount]="undoneTodosCount$ | async"
  [currentFilter]="currentFilter$ | async"
  (filter)="onFilter($event)"
  (clearCompleted)="onClearCompleted()"></app-footer>

<ng-template #loading>
  <div>loading...</div>
</ng-template>

6.4. Effects

Effects are an RxJS powered side effect model for Store. Effects use streams to provide new sources of actions to reduce state based on external interactions such as network requests, web socket messages and time-based events.

— NgRx documentation
https://ngrx.io/guide/effects

Si l’on souhaite faire un appel réseau, faire un appel à un websocket, il ne sera pas possible de le faire dans un reducer, qui est une fonction pure. Nous voulons aussi éviter de faire cet appel dans le composant graphique.

Les effects vont nous permettre d’écouter les actions et d’appliquer des effets en parallèle.

Exemple :

// effects.ts
// ...

@Injectable()
export class TodosEffects {
  @Effect()
  loadTodos$: Observable<Action> = this.actions$ // (1)
    .ofType(fromTodos.LOAD_TODOS) // (2)
    .switchMap(action =>
      // Simulate network call
      Observable.of(
        new fromTodos.LoadCompletedAction([ // (3)
          {
            id: 0.123456789,
            text: 'Remove before flight!',
            creationDate: new Date(2018, 1, 6),
            completed: false,
          },
        ]),
      ).delay(1000), // (4)
    );

  // ...

  constructor(
    private actions$: Actions,
    private router: Router,
    private todosStore: Store<fromTodos.State>,
  ) {}
}

// ...
  1. this.actions$ : observable qui retourne toutes les actions de l’application

  2. LOAD_TODOS : on s’abonne uniquement à l’action LOAD_TODOS

  3. LoadCompletedAction : on simule ici un appel réseau et on retourne une liste de tâches

  4. delay(1000) : on renvoie le résultat dans un délai d’une seconde

7. L’outils NgRx Store Devtools

Il est possible d’utiliser l’outils NgRx Store Devtools :

On peut suivre et observer l’état de l’application :

x ngrx store devtools

On peut observer les différences entre l’état d’avant et celui d’après :

x ngrx store devtools 2

On peut observer les actions :

x ngrx store devtools 3

Il est possible de simuler une action :

x ngrx store devtools 4

Il est possible de faire du time travel debuging, revenir dans le temps et naviguer dans les différents états :

x ngrx store devtools 5

8. 3 exemples regroupés ici, présentés durant la conférence

8.1. Remettre à jour le filtre après un CLEAR_COMPLETED

// reducers.js
export function reducer(
  state: State = initialState,
  action: fromTodos.TodoActionType,
): State {

  // ...

  switch (action.type) {
    case fromTodos.CLEAR_COMPLETED_TODO: {
      return {
        ...state,
        todos: state.todos.filter(todo => !todo.completed),
        filter: state.filter === 'SHOW_COMPLETED' ? 'SHOW_ALL' : state.filter // (1)
      };
    }
  }

  // ...

}
  1. Après un CLEAR_COMPLETED_TODO, si on est sur le SHOW_COMPLETED alors on revient au filtre SHOW_ALL

8.2. Récupération de la liste des todos au chargement de l’application

On va simuler une réquête réseau pour récupérer la liste des todos. On va pour cela rajouter des actions actions.ts :

// actions.ts

export const LOAD_TODOS = '[TODO] load';                     // (1)
export const LOAD_TODOS_COMPLETED = '[TODO] load completed'; // (2)

export class LoadAction implements Action {
  readonly type = LOAD_TODOS;                                // (3)
}

export class LoadCompletedAction implements Action {
  readonly type = LOAD_TODOS_COMPLETED;

  constructor(public todos: Todo[]) {}                       // (4)
}
  1. LOAD_TODOS peut être déclenchée par un bouton ou au lancement de l’application

  2. LOAD_TODOS_COMPLETED sera a déclencher après la réponse du serveur

  3. LoadAction n’a pas de paramètres d’entrée

  4. LoadCompletedAction va prendre la liste des todos issue de la requête

On va mettre ensuite à jour le store avec la réponse de la requête :

// reducers.ts

// ...

export function reducer(
  state: State = initialState,
  action: fromTodos.TodoActionType,
): State {
  switch (action.type) {
    // ...

    case fromTodos.LOAD_TODOS_COMPLETED: { // (1)
      return {
        ...state,
        todos: action.todos,
      };
    }

    // ...

    default: {
      return state;
    }
  }
}
  1. Mise à jour du store avec la liste des todos retourner par le serveur

Au niveau du composant, on va lancer le chargement à son initialisation, au chargement de l’application :

// todo.component.ts

export class TodoComponent implements OnInit {

  // ...

  ngOnInit() {
    this.store.dispatch(new fromTodos.LoadAction());
  }

  // ...
}

On va ensuite définir l’effet à déclencher à partir de l’action LOAD_TODOS :

// effects.ts

export class TodosEffects {
  @Effect()
  loadTodos$: Observable<Action> = this.actions$ // (1)
    .ofType(fromTodos.LOAD_TODOS)                // (2)
    .switchMap(action =>
      // Simulate network call
      Observable.of(                             // (3)
        new fromTodos.LoadCompletedAction([
          {
            id: 0.123456789,
            text: 'Remove before flight!',
            completed: false,
          },
        ]),
      ).delay(1000),                             // (4)
    );

  // ...
}
  1. On retrouve ici un observable qui va retourner toutes les actions de l’application.

  2. On va ensuite s’abonner uniquement à l’action LOAD_TODOS.

  3. On simule ici, avec RxJs, la liste des todos à récupérer.

  4. On simule un délai de réponse d’une seconde.

8.3. Afficher un loader pendant le chargement de l’application

export interface State {
  todos: todoEntity.State;
  loading: boolean; // (1)
}

const initialState: State = {
  todos: todoEntity.initialState,
  loading: false, // (2)
}
  1. Il faut rajouter à l’état de l’application un attribut loading de type booléen

  2. Il faut initialiser l’état à false

// reducers.ts

// ...

export function reducer(
  state: State = initialState,
  action: fromTodos.TodoActionType,
): State {
  switch (action.type) {
    // ...

    case fromTodos.LOAD_TODOS: {
      return {
        ...state,
        loading: true, // (1)
      };
    }

    case fromTodos.LOAD_TODOS_COMPLETED: {
      return {
        ...state,
        todos: todoEntity.adapter.addAll(action.todos, state.todos),
        loading: false, // (1)
      };
    }

    // ...
  }
}
  1. Dans le reducer, il faut mettre à jour l’attribut loading de l’action LOAD_TODOS et LOAD_TODOS_COMPLETED

Il ne reste plus qu’à utiliser l’observable loading$ dans le template :

// todo.component.ts
export class TodoComponent implements OnInit {
  loading$: Observable<boolean>;

  constructor(private store: Store<fromTodos.State>) {
    this.loading$ = this.store.select(fromTodos.getLoading);
  }

  // ...
}
<!-- todo.component.html -->

<header class="header">
  <h1>todos</h1>
  <!-- <1> -->
  <app-new-todo
    *ngIf="!(loading$ | async); else loading"
    (addTodo)="onAddTodo($event)"></app-new-todo>
</header>

<app-todo-list [todos]="filteredTodos$ | async"
  (toggle)="onToggle($event)"
  (update)="onUpdate($event)"
  (delete)="onDelete($event)">
</app-todo-list>

<app-footer *ngIf="hasTodos$ | async"
  [undoneTodosCount]="undoneTodosCount$ | async"
  [currentFilter]="currentFilter$ | async"
  (filter)="onFilter($event)"
  (clearCompleted)="onClearCompleted()"></app-footer>

<ng-template #loading>
  <div>loading...</div>
</ng-template>
  1. On n’a plus qu’à s’abonner à loading$

9. NgRx Entity

On va avoir plein d’entités à gérer dans notre application. Par exemple dans un blog avec des commentaires, des posts, des users, …​

NgRx Entity va normaliser l’état de l’application et faciliter la manipulation de ces données, ainsi que la mise à jour dans le store de façon à ce que les données restent immutables.

On va éviter la duplication de données, éviter l’imbrication d’objets dans les objets et stocker les données à plat (comme dans une base de donnée). Chaque type de données aura sa propre table. Chaque data table aura un id pour l’identifier. Par exemple pour faire un tri, on pourra n’utiliser que des ids.

Dans l’état on va ainsi retrouver les ids stockés à part et les entités dans un autre objet :

x ngrx entity

On va pouvoir utiliser des adapteurs :

// store/entities/todo.ts
// ...

function sortByCreationDate(a: Todo, b: Todo): number {
  return b.creationDate.getTime() - a.creationDate.getTime(); // (2)
}

export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>({
  selectId: (todo: Todo) => todo.id,    // (1)
  sortComparer: sortByCreationDate,  // (2)
});

// ...
  1. Là on peut récupérer notre entité à partir de l’id

  2. On peut si on le souhaite trier nos données automatiquement (dans l’ordre de création par ex.)

Ainsi on peut simplifier les reducers en supprimant tout ce qui est boilerplate, tout ce qui peut être source d’erreur :

// reducers.ts

// ...

export interface State {
//todos: Todo[];
  todos: todoEntity.State; // (1)
  filter: TodoFilter;
}

const initialState: State = {
//todos: [],
  todos: todoEntity.initialState,
  filter: 'SHOW_ALL',
};

export function reducer(
    state: State = initialState,
    action: fromTodos.TodoActionType,
): State {
    switch (action.type) {
        case 'ADD_TODO':
            return {
                ...state,
//              todos: [
//                  ...state.todos,
//                  {
//                      text: action.text,
//                      completed: false,
//                  }
//              ]
                todos: todoEntity.adapter.addOne( // (1)
                    {
                        text: action.text,
                        creationDate: new Date(),
                        completed: false,
                    },
                    state.todos,
                ),
            };

        // ...

        default: {
            return state;
        }
    }
}

// ...
  1. Mise à jour…​

On manipulera des méthodes plus intuitives que de faire des map ou des filter :

  • todoEntity.adapter.addOne()

  • todoEntity.adapter.addAll()

  • todoEntity.adapter.updateOne()

  • todoEntity.adapter.removeMany()

  • …​

NgRx Entity va nous fournir des selecteurs :

// selectors.ts
// ...

export const {
  selectIds: getTodoIds,           // (1)
  selectEntities: getTodoEntities, // (2)
  selectAll: getAllTodos,          // (3)
  selectTotal: getTotalTodos,      // (4)
} = todoEntity.adapter.getSelectors(
  createSelector(getTodoState, state => state.todos),
);

// ...
  1. getTodoIds : retourne le tableau des ids

  2. getTodoEntities : retourne l’objet qui contient le dictionnaire de nos todos

  3. getAllTodos : retourne le tableau des todos

  4. getTotalTodos : retourne le total des todos

Et les sélecteurs suivants sont mis à jour :

// selectors.ts
// ...

export const getFilteredTodos = createSelector(
//getTodos,
  getAllTodos, // (1)
  getFilter,
  (todos, filter) => {
    switch (filter) {
      // ...
    }
  },
);

export const getHasTodos = createSelector(getTotalTodos, totalTodos => {
//return todos.length > 0;
  return totalTodos > 0; // (1)
});

// ...
  1. Sélecteurs mis à jour…​

10. NgRx Router Store

NgRx Router Store va permettre de gérer l’URL en le "bindant" au store, afin de récupérer l’état de l’URL.

On va pourvoir retrouver dans le store l’état de l’URL (url, path, params, queryParams, …​) :

x ngrx router store

De nouvelles actions sont disponibles (ROUTER_NAVIGATION, …​) auxquelles on peut s’abonner.

L’idée serait de faire évoluer le filtre : on ne passe plus exclusivement par le store pour le choix du filtre, mais par l’URL. On aurait ainsi :

On fait évoluer les routes :

// app.module.js
// ...

const routes: Routes = [
  { path: ':filter', component: TodoComponent }, // (1)
  { path: '**', redirectTo: 'all', pathMatch: 'full' },
];

// ...
  1. On ajoute un paramètre :filter

Et dans le store on n’a plus besoin du filtre parce qu’il est géré dans le state du router :

// reducers.ts

// ...

export interface State {
  todos: Todo[];
//filter: TodoFilter;
}

const initialState: State = {
  todos: [],
//filter: 'SHOW_ALL',
};

En contre-partie on va créer un nouveau calculated selector :

// selectors.ts
// ...

export const getFilter = createSelector(
  fromApp.getRouterState, // (1)
  (routerState): TodoFilter => {
  switch (routerState.params.filter) { // (2)
      case 'active': {
        return 'SHOW_ACTIVE';
      }
      case 'completed': {
        return 'SHOW_COMPLETED';
      }
      default: {
        return 'SHOW_ALL';
      }
    }
  },
);
  1. On va chercher les données dans le routerState

  2. On lit le params.filter et retourne le type TodoFilter correspondant (SHOW_ACTIVE, SHOW_COMPLETED ou SHOW_ALL)

On peut ensuite utiliser ce getFilter dans les effets :

// effects.ts
// ...

  @Effect({ dispatch: false })
  filter$: Observable<Action> = this.actions$
    .ofType(fromTodos.SET_TODO_FILTER) // (1)
    .do((action: fromTodos.SetFilterAction) => {
      switch (action.filter) {
        case 'SHOW_ACTIVE': {
          this.router.navigate(['/', 'active']); // (2)
          break;
        }
        case 'SHOW_COMPLETED': {
          this.router.navigate(['/', 'completed']); // (2)
          break;
        }
        default: {
          this.router.navigate(['/', 'all']); // (2)
          break;
        }
      }
    });

// ...
  1. On écoute l’action SET_TODO_FILTER…​

  2. …​ Et change l’URL en fonction

🔥

En observant les modifications apportées à cette étape (https://github.com/bbaia/gdgtoulouse-ngrx/compare/router-1…​router-2), Bruno Baia fait noter le découplage de l’application.

On a pu passer d’un mode sans URL à un mode avec URL, sans modifications des composants graphiques. Les composants graphiques sont donc vraiment "dumb" et le composant "smart" todo ne fait que des selects et dispatcher des actions.

11. Conclusion

  • Vous n’êtes pas obligé d’utiliser Redux : voir l’article You Might Not Need Redux de Dan Abramov, Co-auteur de Redux, sur https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367.

  • Redux n’est pas nécessaire pour du CRUD, des applications de gestion ou de simple formulaire.

  • Redux devient nécessaire pour des applications collaboratives, interactives, avec des synchronisation avec des états distants, quand on veut du cache, des websockets, …​

  • On sait qu’on peut avoir besoin de Redux quand on arrive à une taille critique de l’application où l’on se perd dans les modifications à apporter.

  • La courbe d’apprentissage est assez longue.

  • En contre partie les bugs sont facilement détectables.

  • On peut avoir une bonne expérience de développement car l’état est un simple objet JavaScript, tout comme les actions. Vous pouvez reproduire facilement les actions pour retrouver un état. Les outils et le timetravel permettent de bien inspecter et de debbuguer.

  • Redux s’applique extrêmement bien à Angular.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment