NGXS

Introduction

NGXS est une bibliothèque de gestion d'état pour Angular qui s'inspire du pattern CQRS (Command Query Responsibility Segregation). Créée comme une alternative à NgRx, NGXS offre une approche plus simple et moins verbeuse pour gérer l'état global d'une application Angular, tout en conservant les avantages d'une architecture prévisible et testable.

Pourquoi NGXS ?

Avantages

  • Moins de boilerplate : Moins de code répétitif comparé à NgRx
  • Décorateurs TypeScript : Utilisation intensive des décorateurs pour une syntaxe claire
  • Immutabilité : Gestion automatique de l'immutabilité de l'état
  • DevTools : Intégration avec Redux DevTools pour le débogage
  • Plugins : Écosystème riche de plugins officiels
  • TypeScript first : Conçu spécifiquement pour TypeScript

Comparaison avec NgRx

  • NGXS : Orienté classes, moins de code, courbe d'apprentissage plus douce
  • NgRx : Orienté fonctions, plus verbeux, suit strictement Redux

Installation

npm install @ngxs/store --save
# ou
yarn add @ngxs/store

Plugins optionnels

npm install @ngxs/logger-plugin @ngxs/devtools-plugin @ngxs/storage-plugin --save

Concepts Fondamentaux

1. State (État)

Le State représente un conteneur pour une partie de l'état global de l'application.

export interface TodoStateModel {
  todos: Todo[];
  filter: string;
}

@State<TodoStateModel>({
  name: 'todos',
  defaults: {
    todos: [],
    filter: 'all'
  }
})
@Injectable()
export class TodoState {
  // Actions handlers ici
}

Caractéristiques : - Décorateur @State pour définir le state - Interface TypeScript pour le modèle de données - Valeurs par défaut définies dans defaults - Nom unique pour identifier le state dans le store global

2. Actions

Les Actions sont des commandes qui déclenchent des changements d'état.

export class AddTodo {
  static readonly type = '[Todo] Add';
  constructor(public payload: string) {}
}

export class RemoveTodo {
  static readonly type = '[Todo] Remove';
  constructor(public id: number) {}
}

export class ToggleTodo {
  static readonly type = '[Todo] Toggle';
  constructor(public id: number) {}
}

export class LoadTodos {
  static readonly type = '[Todo] Load';
}

Bonnes pratiques : - Nommer les actions avec un préfixe entre crochets : [Source] Action - Une classe par action - Propriété statique type obligatoire - Payload dans le constructeur si nécessaire

3. Action Handlers

Les Action Handlers sont des méthodes qui réagissent aux actions et modifient l'état.

@State<TodoStateModel>({
  name: 'todos',
  defaults: {
    todos: [],
    filter: 'all'
  }
})
@Injectable()
export class TodoState {

  @Action(AddTodo)
  addTodo(ctx: StateContext<TodoStateModel>, action: AddTodo) {
    const state = ctx.getState();
    const newTodo: Todo = {
      id: state.todos.length + 1,
      title: action.payload,
      completed: false
    };
    ctx.setState({
      ...state,
      todos: [...state.todos, newTodo]
    });
  }

  @Action(RemoveTodo)
  removeTodo(ctx: StateContext<TodoStateModel>, action: RemoveTodo) {
    const state = ctx.getState();
    ctx.setState({
      ...state,
      todos: state.todos.filter(todo => todo.id !== action.id)
    });
  }

  @Action(ToggleTodo)
  toggleTodo(ctx: StateContext<TodoStateModel>, action: ToggleTodo) {
    const state = ctx.getState();
    ctx.patchState({
      todos: state.todos.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      )
    });
  }
}

Méthodes du StateContext : - getState() : Récupère l'état actuel - setState(newState) : Remplace tout l'état - patchState(partialState) : Met à jour partiellement l'état - dispatch(actions) : Dispatch d'autres actions

4. Selectors

Les Selectors permettent de récupérer et transformer des données du state.

@State<TodoStateModel>({
  name: 'todos',
  defaults: {
    todos: [],
    filter: 'all'
  }
})
@Injectable()
export class TodoState {

  @Selector()
  static getTodos(state: TodoStateModel) {
    return state.todos;
  }

  @Selector()
  static getCompletedTodos(state: TodoStateModel) {
    return state.todos.filter(todo => todo.completed);
  }

  @Selector()
  static getActiveTodos(state: TodoStateModel) {
    return state.todos.filter(todo => !todo.completed);
  }

  @Selector()
  static getTodoCount(state: TodoStateModel) {
    return state.todos.length;
  }
}

Selectors composés :

export class TodoState {
  @Selector([TodoState.getTodos, TodoState.getFilter])
  static getFilteredTodos(todos: Todo[], filter: string) {
    switch (filter) {
      case 'completed':
        return todos.filter(t => t.completed);
      case 'active':
        return todos.filter(t => !t.completed);
      default:
        return todos;
    }
  }
}

Utilisation dans les Components

Configuration du module

import { NgxsModule } from '@ngxs/store';
import { TodoState } from './state/todo.state';

@NgModule({
  imports: [
    NgxsModule.forRoot([TodoState], {
      developmentMode: !environment.production
    })
  ]
})
export class AppModule {}

Dans un Component

import { Component } from '@angular/core';
import { Store, Select } from '@ngxs/store';
import { Observable } from 'rxjs';
import { TodoState } from './state/todo.state';
import { AddTodo, RemoveTodo, ToggleTodo } from './state/todo.actions';

@Component({
  selector: 'app-todo-list',
  template: `
    <div>
      <input #input (keyup.enter)="addTodo(input.value); input.value = ''">
      <ul>
        <li *ngFor="let todo of todos$ | async">
          <input type="checkbox" 
                 [checked]="todo.completed" 
                 (change)="toggleTodo(todo.id)">
          {{ todo.title }}
          <button (click)="removeTodo(todo.id)">X</button>
        </li>
      </ul>
      <p>Total: {{ todoCount$ | async }}</p>
    </div>
  `
})
export class TodoListComponent {
  @Select(TodoState.getTodos) todos$: Observable<Todo[]>;
  @Select(TodoState.getTodoCount) todoCount$: Observable<number>;

  constructor(private store: Store) {}

  addTodo(title: string) {
    this.store.dispatch(new AddTodo(title));
  }

  removeTodo(id: number) {
    this.store.dispatch(new RemoveTodo(id));
  }

  toggleTodo(id: number) {
    this.store.dispatch(new ToggleTodo(id));
  }
}

Méthodes de sélection :

// Avec @Select décorateur
@Select(TodoState.getTodos) todos$: Observable<Todo[]>;

// Avec store.select()
todos$ = this.store.select(TodoState.getTodos);

// Avec store.selectSnapshot() (valeur synchrone)
const todos = this.store.selectSnapshot(TodoState.getTodos);

Actions Asynchrones

Avec Observables

@State<TodoStateModel>({
  name: 'todos',
  defaults: { todos: [], loading: false }
})
@Injectable()
export class TodoState {

  constructor(private todoService: TodoService) {}

  @Action(LoadTodos)
  loadTodos(ctx: StateContext<TodoStateModel>) {
    ctx.patchState({ loading: true });

    return this.todoService.getTodos().pipe(
      tap(todos => {
        ctx.patchState({
          todos: todos,
          loading: false
        });
      }),
      catchError(error => {
        ctx.patchState({ loading: false });
        return throwError(error);
      })
    );
  }
}

Avec Async/Await

@Action(LoadTodos)
async loadTodos(ctx: StateContext<TodoStateModel>) {
  ctx.patchState({ loading: true });

  try {
    const todos = await this.todoService.getTodos().toPromise();
    ctx.patchState({
      todos: todos,
      loading: false
    });
  } catch (error) {
    ctx.patchState({ loading: false });
    throw error;
  }
}

Plugins Officiels

Logger Plugin

Affiche les actions et les changements d'état dans la console.

import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';

@NgModule({
  imports: [
    NgxsModule.forRoot([TodoState]),
    NgxsLoggerPluginModule.forRoot()
  ]
})
export class AppModule {}

DevTools Plugin

Intégration avec Redux DevTools.

import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';

@NgModule({
  imports: [
    NgxsModule.forRoot([TodoState]),
    NgxsReduxDevtoolsPluginModule.forRoot({
      disabled: environment.production
    })
  ]
})
export class AppModule {}

Storage Plugin

Persiste l'état dans le localStorage ou sessionStorage.

import { NgxsStoragePluginModule } from '@ngxs/storage-plugin';

@NgModule({
  imports: [
    NgxsModule.forRoot([TodoState]),
    NgxsStoragePluginModule.forRoot({
      key: ['todos', 'auth']
    })
  ]
})
export class AppModule {}

Form Plugin

Synchronisation bidirectionnelle entre les formulaires Angular et le state.

import { NgxsFormPluginModule } from '@ngxs/form-plugin';

@NgModule({
  imports: [
    NgxsModule.forRoot([TodoState]),
    NgxsFormPluginModule.forRoot()
  ]
})
export class AppModule {}

Patterns Avancés

Sub-States

Organisation hiérarchique des states.

@State<UserProfileModel>({
  name: 'profile',
  defaults: { name: '', email: '' }
})
export class UserProfileState {}

@State<UserModel>({
  name: 'user',
  defaults: { id: null },
  children: [UserProfileState]
})
export class UserState {}

State Operators

Simplification des mises à jour d'état avec des opérateurs.

import { patch, append, removeItem, updateItem } from '@ngxs/store/operators';

@Action(AddTodo)
addTodo(ctx: StateContext<TodoStateModel>, action: AddTodo) {
  ctx.setState(
    patch({
      todos: append([action.payload])
    })
  );
}

@Action(RemoveTodo)
removeTodo(ctx: StateContext<TodoStateModel>, action: RemoveTodo) {
  ctx.setState(
    patch({
      todos: removeItem<Todo>(todo => todo.id === action.id)
    })
  );
}

@Action(UpdateTodo)
updateTodo(ctx: StateContext<TodoStateModel>, action: UpdateTodo) {
  ctx.setState(
    patch({
      todos: updateItem<Todo>(
        todo => todo.id === action.id,
        patch({ completed: action.completed })
      )
    })
  );
}

Lifecycle Hooks

@State<TodoStateModel>({
  name: 'todos',
  defaults: { todos: [] }
})
@Injectable()
export class TodoState implements NgxsOnInit, NgxsAfterBootstrap {

  ngxsOnInit(ctx: StateContext<TodoStateModel>) {
    console.log('State initialized');
  }

  ngxsAfterBootstrap(ctx: StateContext<TodoStateModel>) {
    console.log('State bootstrapped');
    ctx.dispatch(new LoadTodos());
  }
}

Tests

Test d'un State

import { TestBed } from '@angular/core/testing';
import { NgxsModule, Store } from '@ngxs/store';
import { TodoState } from './todo.state';
import { AddTodo, RemoveTodo } from './todo.actions';

describe('TodoState', () => {
  let store: Store;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [NgxsModule.forRoot([TodoState])]
    });

    store = TestBed.inject(Store);
  });

  it('should add a todo', () => {
    store.dispatch(new AddTodo('Test todo'));
    const todos = store.selectSnapshot(TodoState.getTodos);
    expect(todos.length).toBe(1);
    expect(todos[0].title).toBe('Test todo');
  });

  it('should remove a todo', () => {
    store.dispatch(new AddTodo('Test todo'));
    const todos = store.selectSnapshot(TodoState.getTodos);
    store.dispatch(new RemoveTodo(todos[0].id));
    const updatedTodos = store.selectSnapshot(TodoState.getTodos);
    expect(updatedTodos.length).toBe(0);
  });
});

Test d'un Selector

it('should select completed todos', () => {
  store.dispatch(new AddTodo('Todo 1'));
  store.dispatch(new AddTodo('Todo 2'));
  const todos = store.selectSnapshot(TodoState.getTodos);
  store.dispatch(new ToggleTodo(todos[0].id));

  const completedTodos = store.selectSnapshot(TodoState.getCompletedTodos);
  expect(completedTodos.length).toBe(1);
});

Bonnes Pratiques

Organisation des fichiers

src/app/state/
├── todo/
│   ├── todo.actions.ts
│   ├── todo.state.ts
│   └── todo.model.ts
├── auth/
│   ├── auth.actions.ts
│   ├── auth.state.ts
│   └── auth.model.ts
└── index.ts

Conventions de nommage

  • Actions : [Source] Action (ex: [Todo] Add, [Auth] Login)
  • States : Nom au singulier (ex: TodoState, AuthState)
  • Selectors : Préfixe get (ex: getTodos, getUser)

Immutabilité

Toujours créer de nouveaux objets plutôt que de modifier l'état existant :

// ✅ Bon
ctx.patchState({ todos: [...state.todos, newTodo] });

// ❌ Mauvais
state.todos.push(newTodo);
ctx.patchState({ todos: state.todos });

Gestion des erreurs

@Action(LoadTodos)
loadTodos(ctx: StateContext<TodoStateModel>) {
  return this.todoService.getTodos().pipe(
    tap(todos => ctx.patchState({ todos })),
    catchError(error => {
      console.error('Error loading todos:', error);
      // Dispatch une action d'erreur
      return ctx.dispatch(new LoadTodosError(error));
    })
  );
}

Éviter les side effects dans les selectors

// ❌ Mauvais - side effect
@Selector()
static getTodos(state: TodoStateModel) {
  console.log('Getting todos'); // Side effect!
  return state.todos;
}

// ✅ Bon - pure function
@Selector()
static getTodos(state: TodoStateModel) {
  return state.todos;
}

Erreurs Courantes

  1. Oublier d'enregistrer le State : Le state doit être dans NgxsModule.forRoot()
  2. Mutation directe de l'état : Toujours utiliser setState ou patchState
  3. Selectors non statiques : Les selectors doivent être des méthodes statiques
  4. Ne pas retourner l'Observable : Dans les actions async, retourner l'Observable pour que NGXS gère le cycle de vie
  5. Trop de logique dans les components : La logique métier doit être dans les states

Ressources

Conclusion

NGXS offre une solution élégante et moins verbeuse pour la gestion d'état dans Angular. Son approche orientée classes et l'utilisation intensive des décorateurs TypeScript en font un choix populaire pour les équipes qui recherchent une alternative plus simple à NgRx, tout en conservant une architecture robuste et testable.