Cours JavaScript et React · Module 15: Patterns et architecture

Patterns avancés - State Machines et Provider Pattern

10 min de lecture
Dans cette leçon8

Le bouton de thème se trouve dans l'en-tête, mais chaque panneau de la station doit connaître les couleurs. La procédure de lancement compte sept étapes et plusieurs drapeaux booléens qui peuvent se contredire. Maintenant que tu connais les patterns React de base, place aux techniques avancées qui résolvent ces deux problèmes : le Provider Pattern pour partager l'état et les machines à états pour maîtriser les processus complexes. Dans la station spatiale, c'est comme installer des systèmes de pilotage avancés.

Provider Pattern - Système de distribution central

Le Provider Pattern est une extension de la Context API. On crée un provider dédié qui encapsule à la fois l'état et la logique, et n'expose aux composants qu'un objet prêt à l'emploi :

1const ThemeContext = createContext();
2
3function ThemeProvider({ children }) {
4  const [theme, setTheme] = useState('dark');
5
6  const toggleTheme = useCallback(() => {
7    setTheme(prev => prev === 'dark' ? 'light' : 'dark');
8  }, []);
9
10  const value = useMemo(() => ({
11    theme, toggleTheme
12  }), [theme, toggleTheme]);
13
14  return (
15    <ThemeContext.Provider value={value}>
16      <div className={`app-${theme}`}>
17        {children}
18      </div>
19    </ThemeContext.Provider>
20  );
21}

useCallback garde la même fonction toggleTheme d'un rendu à l'autre, et useMemo ne recrée l'objet value que lorsque le thème change, donc les consommateurs ne refont pas leur rendu inutilement. La seconde moitié du pattern, c'est le hook par lequel les composants lisent le contexte :

1// Custom hook pour la consommation
2function useTheme() {
3  const context = useContext(ThemeContext);
4  if (!context) {
5    throw new Error('useTheme doit être utilisé dans un ThemeProvider');
6  }
7  return context;
8}

createContext() sans valeur par défaut donne undefined hors du provider, donc le hook lance une erreur lisible au lieu de laisser un composant tourner sur des données vides. La recette est toujours la même : un contexte, un provider avec l'état et la logique, un hook de consommation, l'application enveloppée dans le provider et l'utilisation du hook dans les composants. Dans React 19, tu peux aussi lire un contexte avec la fonction use, et <ThemeContext> lui-même peut servir de provider.

La même recette te donne le centre de notifications de la station. Le provider garde un tableau notifications et trois fonctions qui ne modifient jamais l'ancien tableau, mais en créent un nouveau :

1const NotificationContext = createContext(null);
2let nextId = 0;
3
4function NotificationProvider({ children }) {
5  const [notifications, setNotifications] = useState([]);
6
7  const addNotification = useCallback((message, type = 'info') => {
8    const id = ++nextId;
9    setNotifications(prev => [...prev, { id, message, type }]);
10  }, []);
11
12  const removeNotification = useCallback((id) => {
13    setNotifications(prev => prev.filter(n => n.id !== id));
14  }, []);
15
16  const clearAll = useCallback(() => setNotifications([]), []);
17
18  const value = useMemo(
19    () => ({ notifications, addNotification, removeNotification, clearAll }),
20    [notifications, addNotification, removeNotification, clearAll]
21  );
22
23  return (
24    <NotificationContext.Provider value={value}>
25      {children}
26    </NotificationContext.Provider>
27  );
28}

addNotification ajoute une nouvelle entrée avec un id unique tiré du compteur, removeNotification garde grâce à filter toutes les entrées sauf une, et clearAll met un tableau vide à la place. Les fonctions enveloppées dans useCallback ne changent pas, donc value n'est recréé que lorsque la liste change. Tu écris le hook useNotifications comme useTheme, avec une erreur lisible hors du provider.

Composition de providers

Quand on a beaucoup de providers, l'arbre commence à ressembler à une pyramide, et la composition devient essentielle. L'imbrication classique ressemble à ceci :

1// Au lieu d'imbriquer :
2function App() {
3  return (
4    <ThemeProvider>
5      <AuthProvider>
6        <NotificationProvider>
7          <AppContent />
8        </NotificationProvider>
9      </AuthProvider>
10    </ThemeProvider>
11  );
12}

Le composant ComposeProviders construit la même pyramide à partir d'un tableau. reduceRight commence par le dernier provider, donc le premier du tableau se retrouve à l'extérieur, exactement comme dans la version imbriquée :

1// On peut utiliser le pattern compose :
2function ComposeProviders({ providers, children }) {
3  return providers.reduceRight(
4    (acc, Provider) => <Provider>{acc}</Provider>,
5    children
6  );
7}
8
9function App() {
10  return (
11    <ComposeProviders
12      providers={[ThemeProvider, AuthProvider, NotificationProvider]}
13    >
14      <AppContent />
15    </ComposeProviders>
16  );
17}

L'arbre obtenu est identique, seule l'écriture a changé. Cette astuce ne permet pas de passer des props à un provider précis, alors avec trois providers je garderais l'imbrication et je réserverais ComposeProviders aux longues listes.

State Machines - Transitions d'état contrôlées

Une state machine élimine les « états impossibles » et garantit que l'application se trouve toujours dans l'un des états définis. On commence par une carte : pour chaque état, on liste les événements et l'état auquel chacun mène :

1// États et transitions du processus de lancement
2const launchMachine = {
3  idle: {
4    START_PREFLIGHT: 'preflight'
5  },
6  preflight: {
7    CHECKS_PASSED: 'countdown',
8    CHECKS_FAILED: 'error'
9  },
10  countdown: {
11    COUNTDOWN_DONE: 'launching',
12    ABORT: 'aborted'
13  },
14  launching: {
15    LAUNCH_SUCCESS: 'inFlight',
16    LAUNCH_FAILURE: 'error'
17  },
18  inFlight: {},
19  error: {
20    RETRY: 'idle'
21  },
22  aborted: {
23    RETRY: 'idle'
24  }
25};

On lit idle: { START_PREFLIGHT: 'preflight' } ainsi : dans l'état idle, l'événement START_PREFLIGHT nous mène à preflight. L'état inFlight n'a aucune transition, c'est donc un état final. Voici maintenant le hook qui veille sur cette carte :

1function useMachine(machine, initialState) {
2  const [state, setState] = useState(initialState);
3
4  const send = useCallback((event) => {
5    setState(current => {
6      const nextState = machine[current]?.[event];
7      if (!nextState) {
8        console.warn(`Aucune transition pour ${event} dans l’état ${current}`);
9        return current;
10      }
11      return nextState;
12    });
13  }, [machine]);
14
15  return [state, send];
16}

send cherche la transition dans la carte, et pour un événement inconnu il reste dans l'état actuel et affiche un avertissement dans la console. Le composant se contente d'envoyer des événements et d'afficher les boutons qui correspondent à l'état :

1// Utilisation
2function LaunchControl() {
3  const [state, send] = useMachine(launchMachine, 'idle');
4
5  return (
6    <div className="launch-control">
7      <h2>État : {state}</h2>
8      {state === 'idle' && (
9        <button onClick={() => send('START_PREFLIGHT')}>
10          Lancer les procédures
11        </button>
12      )}
13      {state === 'preflight' && (
14        <>
15          <button onClick={() => send('CHECKS_PASSED')}>
16            Tests réussis
17          </button>
18          <button onClick={() => send('CHECKS_FAILED')}>
19            Tests échoués
20          </button>
21        </>
22      )}
23      {state === 'countdown' && (
24        <button onClick={() => send('ABORT')}>INTERROMPRE</button>
25      )}
26      {state === 'error' && (
27        <button onClick={() => send('RETRY')}>Réessayer</button>
28      )}
29    </div>
30  );
31}

Même si un bouton envoyait un mauvais événement, l'état ne changerait pas, car la carte ne le prévoit pas. Remarque cependant qu'après ABORT, le composant n'affiche aucun bouton RETRY, alors que la carte l'autorise. Ce genre d'oubli disparaît quand tu génères les boutons à partir de la machine elle-même :

1function useMachine(machine, initialState) {
2  // ... état et send comme plus haut
3  const canSend = (event) => Boolean(machine[state]?.[event]);
4  const events = Object.keys(machine[state] ?? {});
5  return { state, send, canSend, events };
6}
7
8function MachineButtons({ events, send }) {
9  return events.map(event => (
10    <button key={event} onClick={() => send(event)}>{event}</button>
11  ));
12}

Le hook renvoie maintenant un objet au lieu d'un tableau, car il y a plus de valeurs. canSend servira à griser les boutons, et la liste events correspond toujours à la carte.

Pourquoi une State Machine est-elle meilleure que des booléens ?

Prenons un scénario typique sans state machine, bien connu des formulaires et des écrans de chargement. On a un composant avec plusieurs états booléens :

1// SANS machine à états - des « états impossibles » sont possibles
2const [isLoading, setIsLoading] = useState(false);
3const [isError, setIsError] = useState(false);
4const [isSuccess, setIsSuccess] = useState(false);
5
6// Que se passe-t-il si isLoading et isError sont TOUS DEUX true en même temps ?
7// C'est un « état impossible », mais rien ne l'empêche !

Trois drapeaux donnent huit combinaisons, et seules quatre ont un sens. Avec une state machine, ce problème disparaît : le composant est TOUJOURS dans exactement un état et ne peut pas être à la fois dans l'état loading et dans l'état error. C'est comme les systèmes de sécurité d'un vaisseau : impossible d'être en mode vol et en mode amarrage en même temps. Pour des processus complexes, tourne-toi vers la bibliothèque XState, qui ajoute entre autres des états imbriqués.

Rappel : useLocalStorage

ThemeProvider a un défaut : après un rafraîchissement de la page, le thème revient à dark. Un custom hook qui fonctionne comme useState mais enregistre la valeur dans le navigateur va nous aider :

1function useLocalStorage(key, initialValue) {
2  const [value, setValue] = useState(() => {
3    const saved = localStorage.getItem(key);
4    return saved !== null ? JSON.parse(saved) : initialValue;
5  });
6
7  useEffect(() => {
8    localStorage.setItem(key, JSON.stringify(value));
9  }, [key, value]);
10
11  return [value, setValue];
12}

La fonction passée à useState est un initialiseur : React ne l'appelle qu'au premier rendu. Dans le provider, il suffit de remplacer une ligne par useLocalStorage('space-theme', 'dark'), et le reste du code ne change pas. N'oublie pas que le localStorage n'existe pas côté serveur, par exemple dans Next.js.

Quand utiliser des patterns spécifiques ?

SituationPattern
Partage de données dans l'arbreProvider Pattern
Processus en plusieurs étapesState Machines
Formulaire avec validationuseForm hook + State Machine
Liste avec filtrageCustom hook + Presentational
Composant avec variantesComposition + Props
Comportements interchangeables, par ex. la méthode de triStrategy : une fonction dans une prop
Communication pub/subObserver : EventBus
Configuration complexe assemblée étape par étapeBuilder
Abstraction de l'accès aux donnéesRepository : un module qui cache fetch

Strategy, Observer et Builder sont des patterns classiques du livre « Design Patterns » paru en 1994, et tu trouveras Repository dans le catalogue de patterns d'architecture d'application de Martin Fowler. Chacun répond à un besoin différent : la composition construit la structure, Observer gère la communication, Strategy permet d'échanger des comportements, Builder assemble une configuration complexe et Repository cache l'accès aux données.

Comment choisir un pattern ?

On ne peut pas classer honnêtement ces patterns du plus simple au plus difficile, car chacun résout un problème différent : Observer n'est pas un « niveau supérieur » de Strategy. Au lieu d'un classement, suis une procédure, comme un pilote suit sa check-list avant le décollage :

  1. Nomme le problème. Qu'est-ce qui se répète, qu'est-ce qui est difficile à changer, qui doit être informé d'un changement ?
  2. Vérifie si les props et children suffisent. La composition simple résout étonnamment beaucoup de choses.
  3. Choisis le pattern le plus simple qui résout précisément ce problème. Le tableau ci-dessus te suggère des candidats.
  4. Introduis-le à un seul endroit et vérifie que le code est vraiment devenu plus simple.
  5. Seulement ensuite, étends-le au reste de l'application.

Si le code n'est pas devenu plus simple à la quatrième étape, annule le changement. Un pattern qui ne résout pas le problème nommé n'est qu'une couche de plus à comprendre.

Résumé

Le Provider Pattern et les State Machines sont des outils avancés qui résolvent de vrais problèmes dans les grandes applications React. Le Provider centralise les données et la logique, en éliminant le « prop drilling ». La State Machine organise les flux d'état complexes et garantit que l'application ne se retrouve jamais dans un état indéfini. Les deux patterns se combinent : un Provider peut exposer une machine à états à tout l'arbre, donnant à chaque vue l'accès à l'état de la mission et aux fonctions de transition. Mon conseil : quand tu te surprends à ajouter un troisième drapeau isSomething, dessine une carte des états. Dans le projet final, tu combineras tous les patterns de ce module.

Retiens : une bonne station a une seule source d'énergie et des modes de fonctionnement clairement décrits.

Code de cette leçon : App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// PROVIDER PATTERN
5// =============================================
6const ThemeContext = createContext();
7
8function ThemeProvider({ children }) {
9  const [theme, setTheme] = useState('dark');
10  const toggleTheme = useCallback(() => setTheme(t => t === 'dark' ? 'light' : 'dark'), []);
11  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
12
13  return (
14    <ThemeContext.Provider value={value}>
15      <div className={`theme-${theme}`}>{children}</div>
16    </ThemeContext.Provider>
17  );
18}
19
20function useTheme() {
21  const ctx = useContext(ThemeContext);
22  if (!ctx) throw new Error('useTheme doit être utilisé dans un ThemeProvider');
23  return ctx;
24}
25
26// =============================================
27// MACHINE À ÉTATS
28// =============================================
29const launchMachine = {
30  idle:       { START_PREFLIGHT: 'preflight' },
31  preflight:  { CHECKS_PASSED: 'countdown', CHECKS_FAILED: 'error' },
32  countdown:  { COUNTDOWN_DONE: 'launching', ABORT: 'aborted' },
33  launching:  { LAUNCH_SUCCESS: 'inFlight', LAUNCH_FAILURE: 'error' },
34  inFlight:   {},
35  error:      { RETRY: 'idle' },
36  aborted:    { RETRY: 'idle' },
37};
38
39// L’état courant est la dernière entrée de l’historique : une seule valeur dans l’état et une fonction de mise à jour pure
40function useMachine(machine, initialState) {
41  const [history, setHistory] = useState([initialState]);
42  const state = history[history.length - 1];
43
44  const send = useCallback((event) => {
45    setHistory(h => {
46      const next = machine[h[h.length - 1]]?.[event];
47      return next ? [...h, next] : h;
48    });
49  }, [machine]);
50
51  // Les boutons sont générés à partir des événements disponibles, comme le conseille la leçon
52  const events = Object.keys(machine[state] ?? {});
53  return { state, send, events, history };
54}
55
56// =============================================
57// COMPOSITION DES PROVIDERS
58// =============================================
59function ComposeProviders({ providers, children }) {
60  return providers.reduceRight(
61    (acc, Provider) => <Provider>{acc}</Provider>,
62    children
63  );
64}
65
66// =============================================
67// COMPOSANTS
68// =============================================
69const themeLabels = { dark: 'sombre', light: 'clair' };
70
71function ThemeToggle() {
72  const { theme, toggleTheme } = useTheme();
73  return (
74    <button className="theme-toggle" onClick={toggleTheme}>
75      Thème : {themeLabels[theme]}
76    </button>
77  );
78}
79
80const stateColors = {
81  idle: '#999', preflight: '#ff9800', countdown: '#ff5722',
82  launching: '#f44336', inFlight: '#4caf50', error: '#f44336', aborted: '#9e9e9e',
83};
84
85const stateLabels = {
86  idle: 'Prêt au lancement', preflight: 'Contrôles avant vol', countdown: 'Compte à rebours',
87  launching: 'Décollage...', inFlight: 'En vol !', error: 'Erreur', aborted: 'Interrompu',
88};
89
90// Libellés et couleurs des boutons pour chaque événement de la machine
91const eventButtons = {
92  START_PREFLIGHT: { label: 'Lancer les procédures', color: 'primary' },
93  CHECKS_PASSED: { label: 'Tests réussis', color: 'success' },
94  CHECKS_FAILED: { label: 'Tests échoués', color: 'danger' },
95  COUNTDOWN_DONE: { label: 'Mise à feu !', color: 'success' },
96  ABORT: { label: 'INTERROMPRE', color: 'danger' },
97  LAUNCH_SUCCESS: { label: 'Succès', color: 'success' },
98  LAUNCH_FAILURE: { label: 'Échec', color: 'danger' },
99  RETRY: { label: 'Réessayer', color: 'primary' },
100};
101
102function LaunchControl() {
103  const { state, send, events, history } = useMachine(launchMachine, 'idle');
104
105  return (
106    <div className="launch-control">
107      <h3>Machine à états : séquence de lancement</h3>
108      <div className="state-display" style={{ borderColor: stateColors[state] }}>
109        <span className="state-label" style={{ color: stateColors[state] }}>
110          {stateLabels[state]}
111        </span>
112        <code className="state-id">{state}</code>
113      </div>
114      <div className="action-btns">
115        {events.map(event => (
116          <button key={event} className={`btn btn-${eventButtons[event].color}`} onClick={() => send(event)}>
117            {eventButtons[event].label}
118          </button>
119        ))}
120        {events.length === 0 && <p className="final-note">État final : la machine n’a plus de transitions.</p>}
121      </div>
122      <div className="history">
123        <h4>Historique des états</h4>
124        <div className="history-flow">
125          {history.map((s, i) => (
126            <span key={i} className="history-item" style={{ color: stateColors[s] }}>
127              {stateLabels[s]}{i < history.length - 1 && ' → '}
128            </span>
129          ))}
130        </div>
131      </div>
132    </div>
133  );
134}
135
136// =============================================
137// APPLICATION PRINCIPALE
138// =============================================
139function AppContent() {
140  return (
141    <div className="app">
142      <div className="top-bar">
143        <h1>Patterns avancés</h1>
144        <ThemeToggle />
145      </div>
146      <p className="subtitle">Provider Pattern + machines à états</p>
147      <LaunchControl />
148    </div>
149  );
150}
151
152export default function App() {
153  // Avec un seul provider, un simple <ThemeProvider> suffirait ; ici, tu vois le mécanisme de ComposeProviders lui-même
154  return (
155    <ComposeProviders providers={[ThemeProvider]}>
156      <AppContent />
157    </ComposeProviders>
158  );
159}

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. Pourquoi un custom hook utilisant un context (ex. useTheme) doit-il vérifier qu'il est utilisé à l'intérieur d'un provider ?

  2. 2. Quel est le principal avantage d'utiliser une machine à états au lieu de plusieurs variables booléennes ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine le centre de notifications construit sur le Provider Pattern. ___BLANK1___ : addNotification renvoie un nouveau tableau avec les notifications existantes et le nouvel objet { id, message, type } à la fin. ___BLANK2___ : removeNotification garde toutes les notifications sauf celle qui a l'id donné. ___BLANK3___ : clearAll met une liste vide. ___BLANK4___ : useNotifications lève une erreur quand il est utilisé hors de NotificationProvider (useContext renvoie alors undefined).

  • Tri vertical

    Range dans le bon ordre les étapes d'implémentation du Provider Pattern :

  • Éditeur de code

    Termine la machine à états du processus d'amarrage. ___BLANK1___ : après l'événement UNDOCK, le vaisseau revient à l'état approaching. ___BLANK2___ : send renvoie l'état suivant de la carte, machine[current]?.[event], et quand l'état actuel n'a pas de transition pour cet événement, il reste dans current. ___BLANK3___ : canSend(event) renvoie true ou false selon que la transition existe dans l'état actuel. ___BLANK4___ : events sont les noms d'événements de l'objet de transitions de l'état actuel (pour un état absent de la carte, un objet vide, donc une liste vide). DockingControl crée des boutons à partir de events.

  • Clique dans l'ordre

    Range la définition de la transition : dans l'état idle, l'événement START fait passer la machine à l'état running.

  • Tri vertical

    Range la syntaxe d'utilisation de ComposeProviders :

  • Éditeur de code

    Crée un hook useLocalStorage(key, initialValue) qui fonctionne comme useState mais garde la valeur dans localStorage. ___BLANK1___ : quand une entrée est enregistrée sous la clé, l'initialiseur la renvoie convertie de JSON en valeur (un nombre revient sous forme de nombre). ___BLANK2___ : l'effet enregistre sous la clé la valeur actuelle convertie en JSON. ___BLANK3___ : l'effet s'exécute à nouveau après un changement de la clé ou de la valeur. Le composant Settings utilise le hook pour le thème, la langue et la taille du texte.

  • Tri vertical

    Range les étapes du choix d'un pattern React dans l'ordre où tu les effectues :

  • Tri horizontal

    Range la syntaxe pour rendre un composant polymorphe avec la prop as :

  • Clique dans l'ordre

    Clique dans l'ordre les étapes que traverse un changement de flag dans une application avec FeatureFlagProvider :

  • Tri vertical

    Range les étapes du cycle de vie d'un listener dans le hook useEventListener :

  • Tri vertical

    Range la syntaxe d'utilisation du composant Feature déclaratif :

  • Clique dans l'ordre

    Clique les éléments qui forment une émission d'événement dans EventBus :

  • Tri vertical

    Range les couches du pattern Container/Presentational à partir de la plus basse :

  • Tri horizontal

    Range la syntaxe pour rendre un composant SpaceCard avec des slots :

  • Tri vertical

    Range les étapes de refactorisation d'un composant monolithique vers l'Atomic Design :

Articles utiles