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
- Oublier d'enregistrer le State : Le state doit être dans
NgxsModule.forRoot() - Mutation directe de l'état : Toujours utiliser
setStateoupatchState - Selectors non statiques : Les selectors doivent être des méthodes statiques
- Ne pas retourner l'Observable : Dans les actions async, retourner l'Observable pour que NGXS gère le cycle de vie
- Trop de logique dans les components : La logique métier doit être dans les states
Ressources
- Documentation officielle NGXS
- GitHub NGXS
- NGXS Labs - Plugins communautaires
- Comparaison NGXS vs NgRx
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.