Dans le cadre d’un projet web, la gestion des flux de données asynchrones est un enjeu crucial. Les Promises, Observables, et plus récemment les Signals, sont trois approches permettant de traiter ces flux efficacement. Chacun possède des caractéristiques uniques qui le rendent adapté à différents cas d'usage. Dans cet article, nous allons explorer ces trois concepts, leurs avantages, leurs inconvénients ainsi que leur périmètre d’utilisation.
Promise : Une Valeur Unique Asynchrone
Les Promises sont une API native de JavaScript permettant de manipuler des opérations asynchrones.
Caractéristiques :
- Une Promise représente une valeur unique qui sera disponible maintenant ou plus tard.
- Elle a trois états possibles : pending (en attente), fulfilled (résolue) ou rejected (rejetée).
- Elle ne peut être consommée qu'une seule fois.
- Elle est immuable une fois résolue.
Exemple :
const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve("Données chargées"), 2000);
});
promise
.then(data => console.log(data))
.catch(error => console.error(error));
//=> Données chargées (après 2 secondes)
Avantages :
- Simple à utiliser pour les opérations asynchrones uniques.
- Intégration native avec async/await.
- Facile à déboguer.
Inconvénients :
- Ne gère pas facilement des flux de données continus.
- Ne permet pas d'annuler une requête en cours.
- Syntaxe qui devient vite illisible pour de la gestion plus complexe (timeout, retry, enchaînements…)
Observable : Un Flux de Données Asynchrones
Les Observables sont une fonctionnalité issue de la librairie RxJS, très utilisée dans Angular et d'autres environnements réactifs.
Caractéristiques :
- Un Observable peut émettre plusieurs valeurs au fil du temps.
- Il peut être annulé (éviter les fuites de mémoire).
- Il supporte des opérateurs puissants pour manipuler les flux de données (map, filter, merge...).
- Il est "lazy” : il ne s'exécute que lorsqu'il est souscrit (subscribe).
Exemple :
import { Observable } from 'rxjs';
const observable = new Observable(subscriber => {
setInterval(() => subscriber.next("Nouvelle valeur"), 1000);
});
const subscription = observable.subscribe(value => console.log(value));
setTimeout(() => subscription.unsubscribe(), 5000);
//=> Nouvelle valeur (après 1 seconde)
Avantages :
- Idéal pour gérer des flux continus (sockets, événements utilisateur, etc.).
- Offre un contrôle avancé sur la manipulation des données.
- Permet d'annuler une souscription.
Inconvénients :
- Complexité supplémentaire par rapport aux Promises.
- Courbe d'apprentissage plus élevée.
- Non natif dans JavaScript (nécessite RxJS).
Signal : Une Approche Réactive Optimisée
Les Signals sont une approche plus récente permettant une gestion efficace de l'état des données. Les frameworks réactifs les plus populaires ont chacun leur implémentation des Signals (Angular, Vue3 avec les ref, Preact Signal…). Dans cet article, nous utiliserons les Signals Angular. Si vous souhaitez aller plus loin sur ce sujet, un article dédié est disponible sur le blog Ippon. (Angular et les signals)
Caractéristiques :
- Stocke une valeur réactive qui peut être modifiée.
- Fournit un suivi automatique des dépendances.
- Favorise la performance en limitant les recalculs inutiles.
- Peut-être synchrone ou asynchrone selon les besoins.
Exemple :
import { signal, effect } from '@angular/core';
const count = signal(0);
effect(() => console.log(count()));
count.set(1);
count.set(5);
//=> 5 (pas de calcul inutile pour 1)
Avantages :
- Performances optimisées en limitant les recalculs.
- Simple à utiliser pour gérer des états réactifs.
- Intégration native dans Angular.
Inconvénients :
- Encore en phase d'adoption.
- Moins de flexibilité que les Observables pour la gestion des flux asynchrones complexes.
Le Périmètre de Chaque Approche
Les Promises, Observables et Signals sont des mécanismes de gestion des données asynchrones, mais d’un point de vue des bonnes pratiques, chacun possède un périmètre d'utilisation théorique spécifique.
Les Promises sont une abstraction utile pour gérer des opérations asynchrones qui aboutissent à une seule valeur ou erreur. Elles sont idéales pour des cas simples où une seule action asynchrone doit être traitée, comme les appels HTTP ou les opérations sur des fichiers. Une fois résolue ou rejetée, une Promise ne peut pas émettre de nouvelles valeurs, ce qui en fait un bon choix pour des actions qui ne nécessitent pas de flux continu, mais plutôt un résultat unique. Cependant, leur principal inconvénient est qu'elles ne gèrent pas bien les flux multiples ou les valeurs évolutives.
Les Observables, en revanche, sont conçus pour des flux de données continus ou multiples, comme les événements utilisateur, les mises à jour en temps réel, ou les communications WebSocket. Les Observables permettent de manipuler facilement ces flux grâce à une série d'opérateurs puissants. Ils offrent une flexibilité énorme pour combiner, transformer ou filtrer des flux de données, et permettent de gérer des erreurs, des annulations ou des fins de flux de manière plus élégante. C'est un choix idéal pour les applications nécessitant une gestion réactive d'événements ou de données multiples.
Enfin, les Signals représentent une approche plus récente et plus simple, principalement utilisée dans les environnements réactifs pour gérer l'état de l'application. Contrairement aux Promises ou aux Observables, qui se concentrent sur les flux asynchrones, les Signals sont utilisés pour mettre à jour automatiquement l'interface utilisateur ou d'autres composants en réponse à des changements d'état. Ils sont particulièrement adaptés aux applications de type "réactive" où l’état change fréquemment et doit être reflété en temps réel dans l’interface. Les Signals offrent une solution légère et efficace pour les situations dans lesquelles la réactivité et la gestion d’état sont primordiales.
Caractéristique | Promise | Observable | Signal |
---|---|---|---|
Nature | Valeur unique (résolution/rejet) | Flux multiple (émet plusieurs valeurs) | Réactif et continu (état) |
Utilisation | Opérations asynchrones simples (ex : API) | Gestion de flux multiples, événements (ex : WebSocket) | État réactif dans UI ou logique d’application |
Gestion des erreurs | Simple (try/catch ou catch()) | Compliquée, mais plus flexible (gestion d'erreurs avec catchError) | Pas de gestion des erreurs spécifiques |
Flux d'événements | Un seul résultat à la fin | Flux continu ou multiple d'événements | Changements d'état réactifs |
Annulation | Non native, mais peut être gérée via AbortController | Peut-être annulé en se désabonnant | Automatique via propagation du changement |
Simplicité | Très simple à comprendre | Plus complexe, mais très flexible | Très simple et direct, souvent utilisé dans une UI réactive |
Observable : La Solution à Tous nos Problèmes ?
J’ai pu observer que des personnes n'utilisaient jamais les Promises, préférant utiliser les Observables pour gérer l'asynchronisme.
Pour donner un exemple, je cherche à récupérer un utilisateur. Pour cela, j'ai une méthode asynchrone qui va me renvoyer l’utilisateur souhaité.
L’approche Promise est ici la plus adaptée. En effet, je cherche à obtenir une valeur unique et, éventuellement, à gérer les cas où la récupération serait impossible. On peut donc avoir quelque chose comme ceci :
getUser(): Promise<User>;
const user: User = await getUser();
Si je choisissais à la place une approche Observable, j’aurais une méthode comme ceci :
import { Observable} from 'rxjs';
getUser(): Observable<User>;
Ensuite, pour pouvoir récupérer notre utilisateur, j’ai plusieurs techniques à ma disposition. Je peux tout d’abord utiliser la fonction firstValueFrom() de RxJS
import { Observable, firstValueFrom } from 'rxjs';
const user: User = await firstValueFrom(getUser());
console.log(user);
Cette fonction va transformer notre Observable en Promise puis va essayer de le résoudre. Cela mobilise ainsi plus de ressources pour exécuter le même code dans la finalité. De plus, ça rend le code moins lisible et clair.
On pourrait donc utiliser une approche plus classique avec un subscribe() :
import { Observable, firstValueFrom } from 'rxjs';
getUser().subscribe({
next: (user: User) => console.log(user);
});
//console.log(user) impossible ici
L’un des défauts est que ce code peut porter à confusion. Ici, je m’attends à exécuter potentiellement plusieurs fois la fonction next. Hors ici getUser() me renverra une valeur unique. De plus, cette méthode de subscribe() m’oblige à déporter le traitement de mon utilisateur dans un nouveau scope. Je ne vais donc pas pouvoir utiliser sa valeur de manière séquentielle dans la suite de mon code. Cela peut compliquer la structure et la compréhension globale de celui-ci.
Dans un cas où je ne souhaite pas attendre l’exécution de ma méthode, ou bien que je souhaite gérer sa sortie dans un autre scope, l’API Promise fournit la méthode then().
Pour bien comprendre l’utilisation du await et du then(), un petit code illustratif est disponible ici.
startTask()
.then((task: Task) => console.log(`task ${task.id} started`));
Ici, nul besoin d’utiliser l’artillerie lourde avec les Observables quand l’API Promise est amplement suffisante, moins coûteuse en ressource, plus simple à utiliser et plus lisible.
Conclusion
Le choix entre Promises, Observables et Signals dépend des besoins spécifiques de l'application.
Les Promises sont adaptées pour gérer des opérations asynchrones simples avec une valeur unique de retour, comme des requêtes HTTP.
Les Observables, eux, conviennent pour des flux de données continus et complexes. Ils offrent plus de flexibilité, mais avec une courbe d'apprentissage plus élevée.
Enfin, les Signals, plus récents, sont optimisés pour la gestion réactive de l'état, notamment en limitant des recalculs inutiles. Ils sont donc particulièrement intéressants à utiliser dans les interfaces utilisateur nécessitant des mises à jour fréquentes.
Bien que dans certains cas, il soit techniquement possible de substituer une approche par une autre, les Promises, les Observables et les Signals répondent à des cas d’usage différents, souvent distincts, avec des avantages et des limitations spécifiques. Il faut donc veiller à choisir la bonne approche uniquement en fonction de ses besoins et de ses contraintes.
Bien que chaque approche ait des cas d’usage privilégiés, il est important de noter que le choix entre Promises, Observables et Signals relève aussi de choix d’implémentation. Par exemple, le client HTTP d’Angular a choisi d’utiliser les Observables pour des requêtes retournant une seule valeur, alors qu’une Promise aurait pu suffire. Ce choix permet notamment de bénéficier des fonctionnalités avancées des Observables, comme l'annulation, le timeout ou la composition de flux. D’un autre côté, l’API fetch native ou Axios ont fait le choix de partir sur Promise. Il n’existe donc pas une seule et unique bonne manière de faire, mais plutôt des solutions adaptées à des contextes, des contraintes techniques ou des préférences spécifiques.