Skip to content

5. L'asynchrone et l'état partagé

5.1. Exemple 19 : l'asynchrone

Beaucoup de choses arrivent « plus tard » : les tics d'une minuterie, les frappes au clavier, la réponse d'un serveur. [Angular] s'appuie pour cela sur la bibliothèque [RxJS] et ses observables. [React] n'a pas d'outil propre à l'asynchrone : on combine l'état, les effets et leur nettoyage, qu'on range dans des hooks ; et, depuis [React] 19, [use()] et [<Suspense>] permettent d'attendre une promesse.

5.1.1. Un compte à rebours

// =========================================================================
// 19-asynchrone/src/hooks/useCompteARebours.ts - une minuterie dans un hook
// -------------------------------------------------------------------------
// De depart à 0, une valeur par seconde. La minuterie est arrêtée quand le
// compte arrive à 0... ou quand le composant disparaît (nettoyage).
// =========================================================================

import { useEffect, useState } from 'react';

export function useCompteARebours(depart: number): number {
  const [reste, setReste] = useState(depart);

  // [reste > 0] : l'effet est relancé quand cette condition change - donc
  // une fois, quand le compte atteint 0 (la minuterie est alors arrêtée)
  const actif = reste > 0;
  useEffect(() => {
    if (!actif) return;
    const minuterie = setInterval(() => setReste((r) => r - 1), 1000);
    return () => clearInterval(minuterie);
  }, [actif]);

  return reste;
}
  • ligne 11 : le compte ;
  • ligne 15 : la minuterie ne doit tourner que tant que le compte est positif ;
  • lignes 16-20 : l'effet ne dépend que de [actif] : il est exécuté au démarrage (la minuterie démarre), puis une seule autre fois, quand le compte atteint 0 : son nettoyage arrête alors la minuterie, et la nouvelle exécution ne fait rien. [setReste((r) => r - 1)] utilise la forme « fonction » de la mise à jour : la minuterie a été créée une fois pour toutes et ne voit pas les nouvelles valeurs de [reste] ; la fonction reçoit, elle, la valeur actuelle.

5.1.2. L'anti-rebond

// =========================================================================
// 19-asynchrone/src/hooks/useValeurDifferee.ts - "l'anti-rebond" (debounce)
// -------------------------------------------------------------------------
// Renvoie la valeur reçue... mais seulement quand elle n'a plus changé
// depuis delai millisecondes. Pour une recherche au fil de la frappe : on
// attend que l'utilisateur fasse une pause avant d'interroger le serveur,
// au lieu d'envoyer une requête par touche.
// Le principe : chaque nouvelle valeur relance l'effet, dont le nettoyage
// annule la minuterie de la valeur précédente.
// =========================================================================

import { useEffect, useState } from 'react';

export function useValeurDifferee<T>(valeur: T, delai: number): T {
  const [differee, setDifferee] = useState(valeur);

  useEffect(() => {
    const minuterie = setTimeout(() => setDifferee(valeur), delai);
    return () => clearTimeout(minuterie); // une frappe avant la fin : on annule
  }, [valeur, delai]);

  return differee;
}

Pour une recherche « au fil de la frappe », on attend que l'utilisateur fasse une pause avant d'interroger le serveur, au lieu d'envoyer une requête par touche : c'est l'anti-rebond ([debounce]). Le hook renvoie la valeur reçue... mais seulement quand elle n'a plus changé depuis [delai] millisecondes :

  • lignes 17-20 : chaque nouvelle valeur relance l'effet, qui programme une minuterie ; et le nettoyage de l'exécution précédente ANNULE la minuterie de la valeur précédente. Seule la dernière valeur, suivie d'une pause, arrive donc jusqu'à [setDifferee].

Le hook est générique ([<T>]) : il fonctionne pour une valeur de n'importe quel type.

5.1.3. La page

// =========================================================================
// 19-asynchrone/src/App.tsx - minuteries, recherche au fil de la frappe, use()
// -------------------------------------------------------------------------
// Beaucoup de choses arrivent "plus tard" : les tics d'une minuterie, les
// frappes au clavier, la réponse d'un serveur. React n'a pas d'outil
// propre à l'asynchrone (Angular utilise la bibliothèque RxJS) : on combine
// l'état, les effets et leur nettoyage, rangés dans des hooks (hooks/),
// et, depuis React 19, use() + <Suspense> pour attendre une promesse.
// =========================================================================

import { Suspense, use, useEffect, useState } from 'react';
import { useCompteARebours } from './hooks/useCompteARebours';
import { useValeurDifferee } from './hooks/useValeurDifferee';

const MEDECINS = ['Marie PELISSIER', 'Paul TATOU', 'Sophie DUPONT', 'Jacques DURAND', 'Martine PELLETIER'];

// un "serveur" simulé : la réponse arrive après un délai aléatoire
function rechercher(texte: string): Promise<string[]> {
  const trouves = MEDECINS.filter((m) => m.toLowerCase().includes(texte));
  return new Promise((resolve) => setTimeout(() => resolve(trouves), 200 + Math.random() * 600));
}

// 3. une promesse, créée UNE fois (hors du composant) : elle se résout au
// bout d'une seconde
const promesseMessage = new Promise<string>((resolve) =>
  setTimeout(() => resolve('trois (une seconde plus tard)'), 1000),
);

// use(promesse) : la valeur de la promesse. Tant qu'elle n'est pas
// résolue, le composant "suspend" son affichage : le <Suspense> le plus
// proche affiche son contenu d'attente (fallback) à la place
function Message({ promesse }: { promesse: Promise<string> }) {
  const valeur = use(promesse);
  return <p className="mb-0">reçu : {valeur}</p>;
}

export default function App() {
  const [journal, setJournal] = useState<string[]>([]);
  const tracer = (message: string) => setJournal((j) => [...j, `${new Date().toLocaleTimeString()} ${message}`]);

  // 1. un compte à rebours : un hook maison
  const rebours = useCompteARebours(20);

  // 2. une recherche au fil de la frappe
  const [saisie, setSaisie] = useState('');
  const texte = useValeurDifferee(saisie.trim().toLowerCase(), 400); // après 400 ms sans frappe
  const [resultats, setResultats] = useState<string[]>([]);

  useEffect(() => {
    if (texte.length < 2) {
      return; // pas de recherche (et pas de nettoyage)
    }
    // "périmée" : une réponse qui arrive APRÈS qu'on a lancé une autre
    // recherche doit être ignorée (sinon une réponse lente d'une vieille
    // recherche écraserait celle de la nouvelle)
    let perimee = false;
    tracer(`recherche envoyée : « ${texte} »`);
    rechercher(texte).then((trouves) => {
      if (perimee) {
        tracer(`réponse ignorée (périmée) : « ${texte} »`);
        return;
      }
      setResultats(trouves);
    });
    // le nettoyage : exécuté quand texte change (nouvelle recherche) ou au retrait
    return () => {
      perimee = true;
    };
  }, [texte]);

  // moins de deux lettres : aucun résultat, quels que soient ceux de la
  // dernière recherche (une valeur calculée, pas un état de plus)
  const affiches = texte.length < 2 ? [] : resultats;

  return (
    <div className="container py-4" style={{ maxWidth: 720 }}>
      <h1 className="h3 mb-3">Exemple 19 - l'asynchrone</h1>

      <div className="card p-3 mb-3">
        <h2 className="h6">Un compte à rebours (setInterval dans un hook)</h2>
        <p className="display-6 mb-0">{rebours}</p>
      </div>

      <div className="card p-3 mb-3">
        <h2 className="h6">Une recherche au fil de la frappe (anti-rebond, réponses périmées)</h2>
        <input className="form-control mb-2" placeholder="au moins deux lettres : pel, dur..." value={saisie}
               onChange={(e) => setSaisie(e.target.value)} />
        <ul className="mb-0">
          {affiches.map((r) => <li key={r}>{r}</li>)}
          {affiches.length === 0 && <li className="text-muted">aucun résultat</li>}
        </ul>
      </div>

      <div className="card p-3 mb-3">
        <h2 className="h6">Attendre une promesse (use et Suspense)</h2>
        <Suspense fallback={<p className="text-muted mb-0">en attente...</p>}>
          <Message promesse={promesseMessage} />
        </Suspense>
      </div>

      <h2 className="h6">Journal</h2>
      <ol className="small">
        {journal.map((ligne, i) => <li key={i}>{ligne}</li>)}
      </ol>
    </div>
  );
}
  • lignes 18-21 : un « serveur » simulé : la réponse arrive après un délai aléatoire, de 200 à 800 ms ;
  • lignes 25-27 : une promesse, créée UNE fois (hors du composant), résolue au bout d'une seconde ;
  • lignes 32-35 : [use(promesse)] donne la valeur d'une promesse. Tant qu'elle n'est pas résolue, le composant « suspend » son affichage : le [<Suspense>] le plus proche (ligne 96) affiche à sa place son contenu d'attente ([fallback]). Quand la promesse est résolue, [React] rappelle le composant, et [use] renvoie la valeur. La promesse doit être créée HORS du composant (ou par une bibliothèque qui la met en cache) : une promesse créée pendant l'affichage serait recréée à chaque appel, et l'attente ne finirait jamais ;
  • ligne 42 : le hook maison ;
  • ligne 46 : le texte recherché, après 400 ms sans frappe ;
  • lignes 49-69 : la recherche, relancée quand le texte (différé) change :
    • le problème des réponses périmées : on tape « pel », pause, la recherche part ; on tape « du », pause, une seconde recherche part. Si la réponse à « pel » arrive APRÈS celle à « du » (les délais du réseau sont imprévisibles), elle écraserait les bons résultats ;
    • la solution (lignes 56-67) : une variable propre à CHAQUE exécution de l'effet, que son nettoyage passe à [true] quand le texte change. Une réponse qui arrive ensuite est ignorée ;
  • ligne 73 : moins de deux lettres : aucun résultat, quels que soient ceux de la dernière recherche. C'est une valeur calculée, et non un état de plus.

On tape « pel », on attend un peu, puis « du » ; la réponse à « pel » arrive trop tard et est ignorée :

Image

Écrire soi-même la gestion des requêtes (en cours, réussie, en erreur, périmée, déjà en cache...) est fastidieux. Pour dialoguer avec un serveur, on utilise en pratique une bibliothèque qui s'en charge : ce sera [TanStack Query], à l'exemple 24.

5.2. Exemple 20 : un état partagé

L'exemple 18 a écrit un magasin à la main. La bibliothèque [Zustand] fait la même chose en quelques lignes, et c'est l'une des plus utilisées pour l'état partagé d'une application [React] (avec [Redux Toolkit], plus complète et plus lourde).

5.2.1. Le panier

// =========================================================================
// 20-etat-partage/src/stores/panier.ts - un ÉTAT PARTAGÉ, avec Zustand
// -------------------------------------------------------------------------
// Un "magasin" (store) est un état qui vit HORS des composants, en UN seul
// exemplaire, partagé par tous ceux qui le lisent. L'exemple 18 en a écrit
// un à la main (useSyncExternalStore) ; la bibliothèque Zustand fait la
// même chose en quelques lignes :
//
//     const usePanier = create<Panier>()((set, get) => ({ ...état et actions... }));
//
// create renvoie un HOOK. Un composant y lit ce dont il a besoin, par un
// "sélecteur" : usePanier((p) => p.articles). Il n'est rappelé que si CE
// morceau de l'état change. Organisation habituelle :
//   - l'ÉTAT : des données (articles) ;
//   - les ACTIONS : des fonctions, seules à modifier l'état, par set() ;
//   - les valeurs calculées : des fonctions (ou des sélecteurs).
// Le magasin vit tant que l'application vit : il survit au retrait d'un
// composant... mais pas au rechargement de la page (F5), qui relance
// l'application. (Contexte + useReducer peuvent faire le même travail ;
// un magasin a l'avantage d'être lisible aussi HORS des composants.)
// =========================================================================

import { create } from 'zustand';

export interface Article {
  libelle: string;
  prix: number;
}

interface Panier {
  articles: Article[];
  ajouter: (article: Article) => void;
  vider: () => void;
}

export const usePanier = create<Panier>()((set) => ({
  articles: [],
  // set(fonction) : reçoit l'état actuel, renvoie ce qui change (le reste est conservé)
  ajouter: (article) => set((p) => ({ articles: [...p.articles, article] })),
  vider: () => set({ articles: [] }),
}));

// des sélecteurs réutilisables : des valeurs calculées à partir de l'état
export const nombreArticles = (p: Panier) => p.articles.length;
export const totalPanier = (p: Panier) => p.articles.reduce((s, a) => s + a.prix, 0);
  • ligne 23 : la fonction qui crée un magasin ;
  • lignes 30-34 : le type du magasin : l'état ([articles]) et les actions ([ajouter], [vider]), seules à le modifier ;
  • lignes 36-41 : [create<Panier>()(fabrique)] crée le magasin et renvoie un hook. La fabrique reçoit [set], qui modifie l'état : [set(objet)] ou [set((etat) => objet)] ; on ne donne que ce qui change, le reste est conservé. Comme avec [useState], on ne modifie jamais l'état lui-même : on en fabrique un nouveau ;
  • lignes 44-45 : des sélecteurs : des fonctions qui calculent une valeur à partir de l'état.

Le magasin vit tant que l'application vit, en UN seul exemplaire : il survit au retrait d'un composant... mais pas au rechargement de la page (F5), qui relance l'application. Contexte et [useReducer] pourraient faire le même travail ; un magasin a deux avantages : il est lisible aussi HORS des composants (par un garde du routeur, par exemple), et un composant n'est rappelé que si la partie de l'état qu'il lit a changé.

5.2.2. Les préférences

// =========================================================================
// 20-etat-partage/src/stores/preferences.ts - un état qui survit à F5
// -------------------------------------------------------------------------
// Pour qu'une donnée survive au rechargement de la page, ou à la fermeture
// du navigateur, il faut la ranger DANS le navigateur : localStorage (un
// petit dictionnaire clé -> chaîne, propre à chaque site).
// Le "middleware" persist de Zustand s'en charge : il relit localStorage à
// la création du magasin, et le réécrit à chaque changement (clé
// "exemple20.preferences", valeur : le JSON de l'état).
// On n'y range JAMAIS de donnée sensible (mot de passe, jeton) : tout script
// de la page peut lire localStorage.
// =========================================================================

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface Preferences {
  sombre: boolean;
  taille: number; // taille du texte, en px
  modifier: (changement: Partial<Pick<Preferences, 'sombre' | 'taille'>>) => void;
}

export const usePreferences = create<Preferences>()(
  persist(
    (set) => ({
      sombre: false,
      taille: 16,
      modifier: (changement) => set(changement),
    }),
    { name: 'exemple20.preferences' },
  ),
);

Pour qu'une donnée survive au rechargement de la page, ou à la fermeture du navigateur, il faut la ranger DANS le navigateur : [localStorage], un petit dictionnaire clé / chaîne, propre à chaque site. Le « middleware » [persist] de [Zustand] s'en charge (lignes 24-31) : il relit [localStorage] à la création du magasin, et le réécrit à chaque changement, sous la clé [exemple20.preferences] (ligne 30). On n'y range JAMAIS de donnée sensible (mot de passe, jeton) : tout script de la page peut lire [localStorage].

5.2.3. Les composants

// 20-etat-partage/src/composants/Boutique.tsx - AJOUTE au panier partagé
import { usePanier, type Article } from '../stores/panier';

const PRODUITS: Article[] = [
  { libelle: 'Consultation', prix: 30 },
  { libelle: 'Vaccin', prix: 12.5 },
];

export default function Boutique() {
  // on ne lit que l'action : Boutique n'est pas rappelée quand le panier change
  const ajouter = usePanier((p) => p.ajouter);
  return (
    <div className="card p-3 h-100">
      <h2 className="h6">Boutique</h2>
      {PRODUITS.map((p) => (
        <button key={p.libelle} className="btn btn-sm btn-outline-primary mb-2" onClick={() => ajouter(p)}>
          + {p.libelle} ({p.prix} €)
        </button>
      ))}
    </div>
  );
}

[usePanier((p) => p.ajouter)] : un composant lit le magasin par un sélecteur, et n'est rappelé que si CE morceau de l'état change. [Boutique] ne lit que l'action [ajouter], qui ne change jamais : elle n'est jamais rappelée quand le panier change.

// 20-etat-partage/src/composants/ResumePanier.tsx - LIT le même panier
import { nombreArticles, totalPanier, usePanier } from '../stores/panier';

export default function ResumePanier() {
  const nombre = usePanier(nombreArticles);
  const total = usePanier(totalPanier);
  const vider = usePanier((p) => p.vider);
  return (
    <div className="card p-3 h-100">
      <h2 className="h6">Panier</h2>
      <p className="mb-2">{nombre} article(s), {total} €</p>
      <button className="btn btn-sm btn-outline-danger align-self-start" disabled={nombre === 0} onClick={vider}>Vider</button>
    </div>
  );
}

[ResumePanier] lit le nombre d'articles et le total, par les sélecteurs du magasin.

// 20-etat-partage/src/composants/CompteurLocal.tsx - un état LOCAL, qui disparaît avec le composant
import { useState } from 'react';

export default function CompteurLocal({ titre }: { titre: string }) {
  const [clics, setClics] = useState(0);
  return (
    <div className="card p-3 h-100">
      <h2 className="h6">{titre}</h2>
      <button className="btn btn-sm btn-outline-secondary align-self-start" onClick={() => setClics(clics + 1)}>
        {clics} clic(s)
      </button>
    </div>
  );
}

Un état LOCAL, qui disparaît avec le composant.

5.2.4. La page

// =========================================================================
// 20-etat-partage/src/App.tsx - trois "portées" de données côté client
// -------------------------------------------------------------------------
// Même question que l'exemple 23 du cours NestJS (portée application,
// requête, client), transposée dans le navigateur :
//   - l'état LOCAL d'un composant (CompteurLocal) disparaît avec lui ;
//   - l'état d'un MAGASIN (le panier) est partagé par tous les composants
//     et survit à leur retrait - mais pas à F5 ;
//   - l'état rangé dans localStorage (les préférences) survit à F5 et à la
//     fermeture du navigateur.
// Et une nouveauté de React 19.2, <Activity mode="hidden"> : le contenu est
// CACHÉ, mais pas retiré ; son état local est conservé.
// Décochez puis recochez "afficher la zone", puis rechargez la page : comparez.
// =========================================================================

import { Activity, useState } from 'react';
import { usePreferences } from './stores/preferences';
import Boutique from './composants/Boutique';
import ResumePanier from './composants/ResumePanier';
import CompteurLocal from './composants/CompteurLocal';

export default function App() {
  const { sombre, taille, modifier } = usePreferences();
  const [zoneVisible, setZoneVisible] = useState(true);

  return (
    <div className={`min-vh-100 ${sombre ? 'bg-dark text-light' : ''}`} style={{ fontSize: taille }}>
      <div className="container py-4">
        <h1 className="h3 mb-3">Exemple 20 - un état partagé</h1>
        <div className="d-flex flex-wrap gap-4 mb-3">
          <label className="form-check-label">
            <input type="checkbox" className="form-check-input" checked={sombre}
                   onChange={(e) => modifier({ sombre: e.target.checked })} /> thème sombre
          </label>
          <label>
            taille du texte{' '}
            <input type="range" min={12} max={22} value={taille}
                   onChange={(e) => modifier({ taille: e.target.valueAsNumber })} /> {taille} px
          </label>
          <label className="form-check-label">
            <input type="checkbox" className="form-check-input" checked={zoneVisible}
                   onChange={(e) => setZoneVisible(e.target.checked)} /> afficher la zone
          </label>
        </div>
        <div className="row g-3 text-body">
          {/* retirée : ces composants disparaissent, avec leur état local */}
          {zoneVisible && (
            <>
              <div className="col-md-3"><Boutique /></div>
              <div className="col-md-3"><ResumePanier /></div>
              <div className="col-md-3"><CompteurLocal titre="État local" /></div>
            </>
          )}
          {/* cachée : le composant reste en mémoire, son état est conservé */}
          <Activity mode={zoneVisible ? 'visible' : 'hidden'}>
            <div className="col-md-3"><CompteurLocal titre="État local, dans une Activity" /></div>
          </Activity>
        </div>
        <p className="mt-3 mb-2">Hors de la zone, un second résumé du MÊME panier (il reste là quand la zone disparaît) :</p>
        <div className="text-body"><ResumePanier /></div>
      </div>
    </div>
  );
}
  • ligne 23 : sans sélecteur, on lit tout le magasin (et le composant est rappelé à chaque changement) ;
  • lignes 47-53 : la zone est retirée quand on décoche la case : ses composants disparaissent, avec leur état local ;
  • lignes 55-57 : [<Activity mode="hidden">] ([React] 19.2) cache son contenu, sans le retirer : le composant reste en mémoire, avec son état. C'est utile pour des onglets, ou une page qu'on quitte et où l'on reviendra ;
  • ligne 60 : hors de la zone, un second résumé du MÊME panier.

On ajoute deux articles au panier, on clique deux fois sur le premier compteur local et trois fois sur celui de l'[Activity], on décoche puis on recoche « afficher la zone », et l'on coche « thème sombre » :

Image

Le panier est intact (il est dans le magasin) ; le premier compteur est revenu à 0 (son composant a été retiré puis recréé) ; celui de l'[Activity] a gardé sa valeur. En rechargeant la page (F5), le panier est vide, mais le thème et la taille du texte sont conservés ([localStorage]).

C'est la même question que l'exemple 23 du cours [NestJS] (portée application, requête, client), transposée dans le navigateur.