Cours JavaScript et React · Module 15: Patterns et architecture

Event-Driven Architecture en React - Une communication faiblement couplée

8 min de lecture
Dans cette leçon8

Le panneau moteur change de vitesse, et l'afficheur de la passerelle comme le journal de bord, placés dans d'autres branches de l'arbre de composants, doivent l'apprendre. Des props transmises sur cinq niveaux transforment vite le code en enchevêtrement de câbles. Sur un vaisseau spatial, les systèmes ne se connaissent pas : le moteur, les boucliers et la navigation communiquent par un système d'événements central. C'est exactement l'Event-Driven Architecture : un pattern dans lequel les composants communiquent par des événements plutôt que par des dépendances directes.

Qu'est-ce que l'Event-Driven Architecture ?

L'Event-Driven Architecture (EDA) repose sur :

  1. L'émission d'événements : un composant annonce que quelque chose s'est produit
  2. L'écoute d'événements : d'autres composants réagissent à ces annonces
  3. Le couplage faible : l'émetteur ne sait pas qui écoute, et le récepteur ne sait pas qui émet

C'est le pattern Observer classique, aussi appelé publish/subscribe (pub/sub) : l'émetteur publie, les abonnés réagissent.

EventBus - bus d'événements central

Le cœur du système est l'EventBus, un relais. Il conserve une map dans laquelle chaque nom d'événement possède un ensemble de fonctions qui l'attendent. La méthode on enregistre un écouteur et renvoie aussitôt une fonction qui le désinscrira :

1// EventBus simple avec des événements nommés
2class EventBus {
3  constructor() {
4    this.listeners = new Map();
5  }
6
7  on(event, callback) {
8    if (!this.listeners.has(event)) {
9      this.listeners.set(event, new Set());
10    }
11    this.listeners.get(event).add(callback);
12
13    // Retourne une fonction de cleanup
14    return () => {
15      this.listeners.get(event)?.delete(callback);
16    };
17  }

Un Set garantit que la même fonction ne s'inscrit pas deux fois. Il reste deux méthodes : emit appelle tous les écouteurs d'un événement donné, et off en retire un à la main. À la fin, on crée une seule instance pour toute l'application :

1  emit(event, payload) {
2    if (this.listeners.has(event)) {
3      this.listeners.get(event).forEach(cb => cb(payload));
4    }
5  }
6
7  off(event, callback) {
8    this.listeners.get(event)?.delete(callback);
9  }
10}
11
12// Singleton - un seul bus pour toute l'application
13const eventBus = new EventBus();

Les noms comme engine:speedChanged ne sont qu'une convention : le préfixe indique quel module émet, et le reste ce qui s'est passé. L'EventBus lui-même est une simple classe JavaScript, sans React.

Hooks personnalisés pour la gestion d'événements

Un composant ne devrait pas avoir à penser lui-même à l'inscription et à la désinscription. On cache cela dans le hook useEventListener, qui inscrit l'écouteur après le montage et le retire au démontage :

1// Hook pour écouter les événements
2function useEventListener(event, handler) {
3  const savedHandler = useRef(handler);
4
5  useEffect(() => {
6    savedHandler.current = handler;
7  }, [handler]);
8
9  useEffect(() => {
10    const listener = (payload) => savedHandler.current(payload);
11    const unsubscribe = eventBus.on(event, listener);
12    return unsubscribe; // Cleanup au démontage
13  }, [event]);
14}

Le détail astucieux, c'est useRef : le hook appelle toujours la dernière version de handler, mais ne renouvelle l'abonnement que si le nom de l'événement change. Dans React 19.2, le hook intégré useEffectEvent résout ce même problème. L'émission est plus simple, car elle ne demande aucun nettoyage :

1// Hook pour émettre des événements
2function useEventEmitter() {
3  return useCallback((event, payload) => {
4    eventBus.emit(event, payload);
5  }, []);
6}

Le cycle de vie d'un écouteur est donc le suivant : le composant se monte, useEffect l'inscrit via eventBus.on(), chaque emit() l'appelle, et au démontage la fonction de nettoyage le retire du bus.

Exemple pratique : système d'alerte du vaisseau

Place aux vrais modules. Le panneau moteur est l'émetteur (publisher) : après chaque clic, il émet deux événements, un changement de vitesse et une entrée de journal :

1// Composant moteur - émet des événements
2function EnginePanel() {
3  const emit = useEventEmitter();
4
5  const setSpeed = (speed) => {
6    emit('engine:speedChanged', { speed, timestamp: Date.now() });
7    emit('ship:log', { source: 'Moteur', message: `Vitesse : ${speed}` });
8  };
9
10  return (
11    <div className="panel">
12      <h3>Moteur</h3>
13      <button onClick={() => setSpeed('MAX')}>MAX</button>
14      <button onClick={() => setSpeed('CRUISE')}>CRUISE</button>
15      <button onClick={() => setSpeed('STOP')}>STOP</button>
16    </div>
17  );
18}

L'afficheur de vitesse est un récepteur (subscriber). Il n'écoute qu'un seul événement et garde la vitesse reçue dans son propre état :

1// Composant d'affichage - écoute les événements
2function SpeedDisplay() {
3  const [speed, setSpeed] = useState('IDLE');
4
5  useEventListener('engine:speedChanged', (data) => {
6    setSpeed(data.speed);
7  });
8
9  return <div className="display">Vitesse : {speed}</div>;
10}

Le journal de bord écoute toutes les entrées ship:log, quel que soit le module qui les a envoyées, et garde les dix plus récentes :

1// Composant journal - écoute tous les logs
2let nextEntryId = 0;
3
4function ShipLog() {
5  const [entries, setEntries] = useState([]);
6
7  useEventListener('ship:log', (data) => {
8    const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
9    setEntries(prev => [entry, ...prev].slice(0, 10));
10  });
11
12  return (
13    <ul className="log">
14      {entries.map(entry => (
15        <li key={entry.id}>[{entry.time}] {entry.source}: {entry.message}</li>
16      ))}
17    </ul>
18  );
19}

Chaque entrée reçoit son propre id grâce à un compteur, car les nouvelles entrées arrivent en tête de liste, et une clé basée sur l'index se décalerait à chaque événement. Remarque : EnginePanel n'importe ni SpeedDisplay ni ShipLog. Les composants sont complètement indépendants. Tu peux retirer ShipLog de l'arbre et EnginePanel continue de fonctionner sans changement. Quand tu ajouteras le contrôle des boucliers, il lui suffira d'émettre aussi ship:log, et le journal affichera ses entrées.

API native EventTarget

Au lieu de ton propre EventBus, tu peux utiliser l'API native EventTarget, intégrée au navigateur. Les données de l'événement voyagent dans le champ detail d'un objet CustomEvent :

1// EventTarget - intégré au navigateur
2const shipEvents = new EventTarget();
3
4function useNativeEventListener(eventName, handler) {
5  useEffect(() => {
6    const listener = (e) => handler(e.detail);
7    shipEvents.addEventListener(eventName, listener);
8    return () => shipEvents.removeEventListener(eventName, listener);
9  }, [eventName, handler]);
10}
11
12function emitNativeEvent(eventName, detail) {
13  shipEvents.dispatchEvent(new CustomEvent(eventName, { detail }));
14}

Le résultat est le même, et tu n'as pas à écrire la classe. Ici, en revanche, handler fait partie des dépendances de l'effet, donc une nouvelle fonction à chaque rendu signifie une nouvelle inscription de l'écouteur. Cela fonctionne correctement, mais mieux vaut passer une fonction stable ou utiliser l'astuce du useRef.

EventBus avancé avec middleware

On peut étendre l'EventBus avec des middlewares, des fonctions qui interceptent les événements avant leur livraison. Chacune reçoit le nom et les données, puis renvoie les données plus loin ou null pour arrêter l'événement :

1class AdvancedEventBus extends EventBus {
2  constructor() {
3    super();
4    this.middleware = [];
5  }
6
7  use(middlewareFn) {
8    this.middleware.push(middlewareFn);
9  }
10
11  emit(event, payload) {
12    // Passe à travers le middleware
13    let finalPayload = payload;
14    for (const mw of this.middleware) {
15      finalPayload = mw(event, finalPayload);
16      if (finalPayload === null) return; // Le middleware a bloqué l'événement
17    }
18    super.emit(event, finalPayload);
19  }
20}

La classe ne fait rien tant qu'on ne crée pas d'instance et qu'on n'y branche pas les fonctions. La première journalise chaque événement, la seconde demande une confirmation avant la pleine puissance :

1const advancedBus = new AdvancedEventBus();
2
3// Middleware : journalisation
4advancedBus.use((event, payload) => {
5  console.log(`[Event] ${event}`, payload);
6  return payload;
7});
8
9// Middleware : validation
10advancedBus.use((event, payload) => {
11  if (event === 'engine:speedChanged' && payload.speed === 'MAX') {
12    if (!window.confirm('Pleine puissance, vraiment ?')) return null; // Bloque !
13  }
14  return payload;
15});

Les middlewares s'exécutent dans l'ordre où ils ont été ajoutés, et quand la validation renvoie null, l'événement n'atteint aucun écouteur. Tu retrouveras la même idée dans Redux et Express.

EDA vs Context API vs Props

CaractéristiquePropsContext APIEvent-Driven
DirectionParent -> enfantAncêtre -> descendantsQuelconque
CouplageFortMoyenFaible
Re-rendusDirectsTous les consommateursSeulement les abonnés
Communication entre branchesSeulement via un parent communNécessite un ancêtre communDirecte
DébogageFacileMoyenNécessite des logs

Quand la valeur d'un contexte change, React refait le rendu de chaque composant qui la lit. Avec un EventBus, seuls les abonnés qui modifient leur propre état en réaction refont leur rendu.

Quand utiliser l'Event-Driven Architecture ?

  • Communication entre composants éloignés : tu ne veux pas transmettre des props sur 5 niveaux
  • Système de notifications/alertes : de nombreuses sources génèrent des événements, un composant les affiche
  • Journalisation et analytics : des événements utilisateur émis une fois, écoutés par plusieurs systèmes
  • Mises à jour en temps réel : des données actualisées en temps réel (WebSocket -> EventBus -> composants)

Mon conseil : un EventBus sert à annoncer que quelque chose s'est produit, pas à stocker des données. Garde l'état qu'un écran doit afficher dans React, dans un contexte ou dans une bibliothèque d'état, où chaque changement est facile à retracer. L'EDA est le système de communication central du vaisseau, et la clé du succès, c'est le cleanup au démontage, sinon tu auras des fuites de mémoire, comme un relais endommagé qui continue de consommer de l'énergie. Dans la prochaine leçon, on organisera les modules eux-mêmes avec l'Atomic Design.

Retiens : l'émetteur annonce ce qui s'est passé et ne se soucie pas de savoir qui écoute.

Code de cette leçon : App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// === BUS D’ÉVÉNEMENTS (EVENT BUS) ===
4class EventBus {
5  constructor() { this.listeners = new Map(); }
6
7  on(event, callback) {
8    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
9    this.listeners.get(event).add(callback);
10    return () => this.listeners.get(event)?.delete(callback);
11  }
12
13  emit(event, payload) {
14    if (this.listeners.has(event)) {
15      this.listeners.get(event).forEach(cb => cb(payload));
16    }
17  }
18}
19
20const eventBus = new EventBus();
21
22// === HOOKS PERSONNALISÉS ===
23function useEventListener(event, handler) {
24  const saved = useRef(handler);
25  useEffect(() => { saved.current = handler; }, [handler]);
26  useEffect(() => {
27    const listener = (p) => saved.current(p);
28    return eventBus.on(event, listener);
29  }, [event]);
30}
31
32function useEventEmitter() {
33  return useCallback((event, payload) => eventBus.emit(event, payload), []);
34}
35
36// === ÉMETTEUR (PUBLISHER) : panneau moteur ===
37function EnginePanel() {
38  const emit = useEventEmitter();
39  const setSpeed = (speed) => {
40    emit('engine:speedChanged', { speed });
41    emit('ship:log', { source: 'Moteur', message: 'Vitesse : ' + speed });
42  };
43  return (
44    <div className="panel publisher">
45      <h3>Commande du moteur</h3>
46      <span className="tag">Émetteur</span>
47      <div className="btn-group">
48        <button className="btn btn-danger" onClick={() => setSpeed('MAX')}>MAX</button>
49        <button className="btn btn-primary" onClick={() => setSpeed('CRUISE')}>CRUISE</button>
50        <button className="btn btn-default" onClick={() => setSpeed('STOP')}>STOP</button>
51      </div>
52    </div>
53  );
54}
55
56// === ÉMETTEUR (PUBLISHER) : panneau des boucliers ===
57function ShieldPanel() {
58  const emit = useEventEmitter();
59  const [level, setLevel] = useState(100);
60  const update = (v) => {
61    setLevel(v);
62    emit('shields:level', { level: v });
63    emit('ship:log', { source: 'Boucliers', message: v + ' %' });
64  };
65  return (
66    <div className="panel publisher">
67      <h3>Commande des boucliers</h3>
68      <span className="tag">Émetteur</span>
69      <input type="range" min="0" max="100" value={level} onChange={e => update(Number(e.target.value))} />
70      <span className="value">{level}%</span>
71    </div>
72  );
73}
74
75// === RÉCEPTEUR (SUBSCRIBER) : afficheur de vitesse ===
76function SpeedDisplay() {
77  const [speed, setSpeed] = useState('IDLE');
78  useEventListener('engine:speedChanged', (data) => setSpeed(data.speed));
79  const colors = { MAX: '#f44336', CRUISE: '#4fc3f7', STOP: '#666', IDLE: '#999' };
80  return (
81    <div className="panel subscriber">
82      <h3>Vitesse</h3>
83      <span className="tag sub-tag">Récepteur</span>
84      <div className="big-value" style={{ color: colors[speed] }}>{speed}</div>
85    </div>
86  );
87}
88
89// === RÉCEPTEUR (SUBSCRIBER) : afficheur des boucliers ===
90function ShieldDisplay() {
91  const [level, setLevel] = useState(100);
92  useEventListener('shields:level', (data) => setLevel(data.level));
93  const color = level > 60 ? '#4caf50' : level > 30 ? '#ff9800' : '#f44336';
94  return (
95    <div className="panel subscriber">
96      <h3>Boucliers</h3>
97      <span className="tag sub-tag">Récepteur</span>
98      <div className="bar"><div className="bar-fill" style={{ width: level + '%', background: color }} /></div>
99      <span className="value">{level}%</span>
100    </div>
101  );
102}
103
104// === RÉCEPTEUR (SUBSCRIBER) : journal de bord ===
105let nextEntryId = 0;
106
107function ShipLog() {
108  const [entries, setEntries] = useState([]);
109  useEventListener('ship:log', (data) => {
110    const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
111    setEntries(prev => [entry, ...prev].slice(0, 10));
112  });
113  return (
114    <div className="panel subscriber log-panel">
115      <h3>Journal de bord</h3>
116      <span className="tag sub-tag">Récepteur (tous les événements)</span>
117      {entries.length === 0 ? <p className="empty">Aucun événement pour l’instant</p> : (
118        <ul>{entries.map(e => <li key={e.id}>[{e.time}] {e.source}: {e.message}</li>)}</ul>
119      )}
120    </div>
121  );
122}
123
124// === COMPOSANT PRINCIPAL ===
125export default function App() {
126  return (
127    <div className="app">
128      <h1>Event-Driven Architecture</h1>
129      <p className="subtitle">Les émetteurs publient des événements, les récepteurs réagissent chacun de leur côté</p>
130      <div className="grid">
131        <div className="col">
132          <EnginePanel />
133          <ShieldPanel />
134        </div>
135        <div className="col">
136          <SpeedDisplay />
137          <ShieldDisplay />
138        </div>
139      </div>
140      <ShipLog />
141    </div>
142  );
143}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel est l'objectif principal de l'Event-Driven Architecture dans une application React ?

  2. 2. Quelle caractéristique DISTINGUE l'Event-Driven Architecture de la Context API en React ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine le bus d'événements et les hooks par lesquels deux composants indépendants communiquent. ___BLANK1___ : la fonction renvoyée par on retire des écouteurs de l'événement event seulement ce callback. ___BLANK2___ : emit appelle chaque écouteur avec les données payload. ___BLANK3___ : dans useEffect, abonne l'écouteur listener à eventBus et renvoie la fonction de désabonnement comme cleanup de l'effet. ___BLANK4___ : au clic, EnginePanel émet l'événement engine:speedChanged avec l'objet { speed }, et SpeedDisplay affiche la vitesse reçue.

  • Tri vertical

    Range les étapes d'implémentation de l'Event-Driven Architecture en React :

  • Éditeur de code

    L'EventBus et les hooks sont prêts, termine deux émetteurs et deux abonnés. ___BLANK1___ : quand le curseur bouge, ShieldControl émet shields:level avec le nouveau niveau sous forme de nombre (next, pas l'ancienne valeur de l'état). ___BLANK2___ : au clic, AlertSystem émet alert:status avec le niveau d'alerte (alert.level : green, yellow ou red). ___BLANK3___ : StatusDashboard écoute l'événement qui change l'état d'alerte. ___BLANK4___ : ShipLog ajoute une nouvelle entrée au début de la liste et garde au plus les 8 entrées les plus récentes.

  • Tri vertical

    Range les couches de l'Event-Driven Architecture de la plus basse à la plus haute :

Articles utiles