Avec les Signals, la plupart des applications n’ont plus besoin d’une bibliothèque de State Management. Un service avec quelques signals privés, des sélecteurs computed() et des méthodes aux noms explicites couvre presque tous les cas. La question importante n’est pas « quelle bibliothèque », mais où vit chaque morceau d’état. Quand un écran atteint sans cesse des combinaisons qui ne devraient pas exister, la réponse est une machine à états.
Les niveaux d’état
Type d’état Exemple Où le garder
────────────────────────────────────────────────────────────────────────────────
UI locale dropdown ouvert, survol, onglet signal() dans le composant
Formulaire champs, validation, dirty Signal Forms (form())
Feature filtres, sélection, liste store @Injectable() sur la route/page
Serveur produits, commandes resource / httpResource (dans le store)
URL recherche, page, filtre partageable query params + input() (router)
Global utilisateur, panier, thème, langue @Service() dans rootGardez l’état au niveau le plus bas où il est nécessaire. Ne le remontez (composant → feature → global) que lorsqu’un nouveau consommateur l’exige.
Le pattern de base : un store basé sur les Signals
@Injectable() // fourni par la page / route de la feature
export class TodoStore {
// 1. ÉTAT — privé, modifiable
private readonly _todos = signal<Todo[]>([]);
private readonly _filter = signal<TodoFilter>('all');
// 2. SÉLECTEURS — publics, en lecture seule
readonly todos = this._todos.asReadonly();
readonly filter = this._filter.asReadonly();
readonly visible = computed(() => {
const f = this._filter();
return this._todos().filter((t) => f === 'all' || (f === 'done' ? t.done : !t.done));
});
readonly remaining = computed(() => this._todos().filter((t) => !t.done).length);
// 3. ACTIONS — méthodes aux noms métier, mises à jour immuables
add(title: string) {
this._todos.update((list) => [...list, { id: Date.now(), title, done: false }]);
}
toggle(id: number) {
this._todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
setFilter(filter: TodoFilter) {
this._filter.set(filter);
}
}Le filtre est aussi dans l’URL (?filtru=…) — rechargez la page : la liste (localStorage) et le filtre (URL) sont conservés.
État du serveur
Les données du serveur ne sont pas « votre état » — c’est un cache de l’état du serveur. Gardez-les dans un httpResource/resource à l’intérieur du store, et après les écritures, rechargez ou mettez à jour localement.
// Un store avec des données serveur : resource pour lire, HttpClient pour écrire
@Service()
export class ProductsStore {
private readonly api = inject(ProductsApi);
readonly query = signal('');
readonly page = linkedSignal({ source: this.query, computation: () => 1 });
readonly products = httpResource<PageDto<Product>>(
() => ({ url: '/api/products', params: { q: this.query(), page: this.page() } }),
{ defaultValue: { items: [], total: 0 } },
);
readonly isEmpty = computed(() => !this.products.isLoading() && this.products.value().items.length === 0);
async remove(id: number) {
await firstValueFrom(this.api.delete(id));
this.products.reload();
}
}L’URL est aussi de l’état
// État qui doit survivre à un rafraîchissement / pouvoir être envoyé en lien → l’URL
// avec withComponentInputBinding(), les query params arrivent directement dans les inputs :
@Component({ /* routé sur /products?q=laptop&page=2 */ })
export class ProductsPage {
readonly q = input<string>(''); // ?q=
readonly page = input(1, { transform: numberAttribute }); // ?page=
private readonly router = inject(Router);
search(q: string) {
this.router.navigate([], { queryParams: { q, page: null }, queryParamsHandling: 'merge' });
}
}Facade
Quand une page combine les données de plusieurs stores, une facade les rassemble en un seul « view model ». Le composant reste léger, et l’orchestration a une place claire.
// Facade : un point d’entrée unique pour la page, qui masque plusieurs sources
@Injectable()
export class CheckoutFacade {
private readonly cart = inject(CartStore);
private readonly user = inject(AuthStore);
private readonly shipping = inject(ShippingStore);
readonly vm = computed(() => ({
items: this.cart.items(),
total: this.cart.total(),
address: this.user.current()?.address,
shippingOptions: this.shipping.options.value(),
canPay: this.cart.items().length > 0 && !!this.user.current(),
}));
pay() { /* orchestration entre stores */ }
}
// le composant : @let vm = facade.vm(); — un seul objet, facile à testerEt NgRx ?
// Quand l’équipe veut des conventions + des plugins : NgRx SignalStore (@ngrx/signals)
export const TodoStore = signalStore(
withState({ todos: [] as Todo[], filter: 'all' as TodoFilter }),
withComputed(({ todos, filter }) => ({
visible: computed(() => todos().filter(/* ... */)),
})),
withMethods((store) => ({
add(title: string) {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
})),
);
// + withEntities, withHooks, devtools, rxMethod pour les effets asynchrones- La plupart des applications, y compris les grandes.
- Les équipes qui préfèrent un code simple, sans boilerplate.
- L’état de feature, lié aux pages.
- SignalStore : vous voulez des conventions imposées, des entités, des hooks, des plugins réutilisables.
- NgRx Store (Redux) : vous avez besoin d’un journal d’actions, de time-travel, d’event sourcing, de nombreuses équipes sur le même état.
- L’application utilise déjà NgRx — gardez la cohérence.
Pièges fréquents
- Tout en global : l’état d’une page dans root → d’anciens filtres réapparaissent au retour, la mémoire grossit. Fournissez le store au niveau de la route/page.
- Des signals modifiables publics dans un store → n’importe qui peut changer l’état de n’importe où. Exposez
asReadonly(). - De l’état dupliqué (la même liste dans deux stores) → désynchronisation. Une seule source, le reste dérivé.
- Des stores qui s’injectent mutuellement de façon circulaire → le signe qu’un concept doit être déplacé (ou qu’il vous faut une facade).