Retour au blog

Les hooks que tu as oubliés

useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition. Tu as probablement scrollé devant. Tu en as probablement besoin maintenant.

30 janvier 202614 min de lecture
ReactHooksuseIduseTransitionuseDeferredValue
Les hooks que tu as oubliés
Les Pépites Cachées - useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition
Les Pépites Cachées - useId, useDebugValue, useSyncExternalStore, useDeferredValue, useTransition

Il y a une poignée de hooks React que presque personne n'utilise. Ils sont dans la doc, ils sont livrés avec React, mais ils n'apparaissent jamais dans les tutos ou les code reviews.

useId. useDebugValue. useSyncExternalStore. useDeferredValue. useTransition.

Tu as probablement scrollé devant. Peut-être que tu as lu la description et pensé "j'apprendrai ça quand j'en aurai besoin."

Voilà le truc : tu en as probablement besoin maintenant. Tu ne le sais juste pas encore.


useId : le hook d'accessibilité

Tu construis un formulaire. Tu dois connecter les labels aux inputs :

function EmailField() {
  return (
    <div>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" />
    </div>
  );
}

Simple. Jusqu'à ce que tu rendes deux composants EmailField sur la même page. Maintenant tu as deux éléments avec id="email". HTML invalide. Accessibilité cassée.

L'ancien fix :

function EmailField() {
  const [id] = useState(() => `email-${Math.random().toString(36).slice(2)}`);
  
  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </div>
  );
}

Ça marche... jusqu'au server-side rendering. Le serveur génère un ID, le client en génère un autre. Mismatch d'hydratation. React te crie dessus.

useId à la rescousse

function EmailField() {
  const id = useId();
  
  return (
    <div>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </div>
  );
}

useId génère un ID unique qui est stable entre serveur et client. Pas de mismatch d'hydratation. Pas d'IDs dupliqués. Ça marche tout simplement.

Plusieurs IDs depuis un seul appel

Tu as besoin de plusieurs IDs liés ? Utilise un seul useId comme préfixe :

function FormField({ label }) {
  const id = useId();
  
  return (
    <div>
      <label htmlFor={`${id}-input`}>{label}</label>
      <input id={`${id}-input`} aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>Ce champ est requis</p>
    </div>
  );
}

Un appel de hook, trois éléments connectés. Propre et accessible.

Quand utiliser useId

  • Labels de formulaire et inputs
  • Attributs ARIA (aria-labelledby, aria-describedby)
  • Chaque fois que tu as besoin d'un ID unique dans un composant réutilisable

Quand NE PAS utiliser useId

  • Keys dans les listes (utilise les IDs des données)
  • Sélecteurs CSS (ne te fie pas aux IDs générés pour le styling)

useDebugValue : rends React DevTools utile

Tu as construit un custom hook. Il marche super bien. Mais quand tu l'inspectes dans React DevTools, tu vois... rien d'utile.

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  
  return isOnline;
}

Dans DevTools, tu vois : OnlineStatus: true

Pas super utile. C'est bien ? C'est mal ?

Ajoute du contexte avec useDebugValue

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  useDebugValue(isOnline ? '🟢 En ligne' : '🔴 Hors ligne');
  
  // ... reste du hook
  
  return isOnline;
}

Maintenant DevTools affiche : OnlineStatus: "🟢 En ligne"

Instantanément clair.

Diffère le formatage coûteux

Si ta valeur de debug nécessite un calcul coûteux, diffère-le :

function useDataFetch(url) {
  const [data, setData] = useState(null);
  
  // Ne calcule la string de debug que quand DevTools est ouvert
  useDebugValue(data, (data) => {
    if (!data) return 'Chargement...';
    return `${data.items.length} éléments chargés à ${new Date().toLocaleTimeString()}`;
  });
  
  // ... logique de fetch
  
  return data;
}

Le deuxième argument est une fonction de formatage. Elle ne s'exécute que quand tu inspectes vraiment le composant dans DevTools.

Quand utiliser useDebugValue

  • Custom hooks dans des librairies partagées
  • Hooks complexes où le state interne n'est pas évident
  • Debug pendant le développement

Quand NE PAS utiliser useDebugValue

  • Chaque custom hook (overkill)
  • Builds de production (c'est un no-op en prod de toute façon)
  • Hooks simples où la valeur de retour est évidente

useSyncExternalStore : connecte-toi au monde extérieur

React gère son propre state. Mais parfois tu dois te synchroniser avec des données externes :

  • APIs navigateur (statut en ligne, taille de fenêtre, media queries)
  • Librairies de state tierces (Redux, Zustand sans leurs bindings React)
  • Connexions WebSocket
  • LocalStorage

L'approche naïve :

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return width;
}

Ça marche pour les cas simples. Mais ça a des problèmes avec le rendu concurrent. React pourrait "déchirer" ton UI : certains composants voient l'ancienne valeur, d'autres la nouvelle.

useSyncExternalStore fait ça correctement

function useWindowWidth() {
  return useSyncExternalStore(
    // subscribe: comment écouter les changements
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    // getSnapshot: comment obtenir la valeur actuelle
    () => window.innerWidth
  );
}

useSyncExternalStore garantit la consistance. Tous les composants voient la même valeur pendant un render.

Exemple concret : statut en ligne

function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,
    () => true // Snapshot serveur : on assume en ligne
  );
}

Le troisième argument est pour le SSR. Comme il n'y a pas de navigator sur le serveur, on fournit un fallback.

Exemple concret : media queries

function useMediaQuery(query) {
  return useSyncExternalStore(
    (callback) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', callback);
      return () => mql.removeEventListener('change', callback);
    },
    () => window.matchMedia(query).matches,
    () => false // Fallback serveur
  );
}

// Utilisation
function App() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

Quand utiliser useSyncExternalStore

  • APIs navigateur qui changent dans le temps
  • Gestion de state externe sans bindings React
  • Toute source de données basée sur des subscriptions

Quand NE PAS utiliser useSyncExternalStore

  • State React (utilise useState/useReducer)
  • Fetching de données (utilise useEffect ou une librairie comme TanStack Query)
  • Lectures uniques (pas besoin de subscription)

useDeferredValue : garde l'UI réactive

Tu as un input de recherche qui filtre une énorme liste :

function Search() {
  const [query, setQuery] = useState('');
  const filteredItems = filterItems(items, query); // Coûteux !
  
  return (
    <>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
      />
      <List items={filteredItems} />
    </>
  );
}

Chaque frappe déclenche le filtrage. Avec 10 000 items, l'input devient lent.

Diffère la partie coûteuse

function Search() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  
  // Le filtrage utilise la valeur différée
  const filteredItems = useMemo(
    () => filterItems(items, deferredQuery),
    [deferredQuery]
  );
  
  return (
    <>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
      />
      <List items={filteredItems} />
    </>
  );
}

Maintenant l'input se met à jour immédiatement (avec query), tandis que la liste se met à jour avec un léger délai (avec deferredQuery). L'UI reste réactive.

Affiche le contenu périmé pendant la mise à jour

Tu peux détecter quand la valeur différée est périmée :

function Search() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
  
  const filteredItems = useMemo(
    () => filterItems(items, deferredQuery),
    [deferredQuery]
  );
  
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <List items={filteredItems} />
      </div>
    </>
  );
}

La liste devient semi-transparente pendant la mise à jour, donnant un feedback visuel.

Quand utiliser useDeferredValue

  • Renders coûteux basés sur l'input utilisateur
  • Quand tu ne contrôles pas la source de la valeur
  • Afficher du contenu périmé est acceptable

useTransition : prends le contrôle des mises à jour

useDeferredValue diffère une valeur. useTransition diffère une mise à jour.

function TabContainer() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();
  
  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab); // Cette mise à jour est "basse priorité"
    });
  }
  
  return (
    <>
      <TabButtons onSelect={selectTab} isPending={isPending} />
      <TabContent tab={tab} />
    </>
  );
}

Quand tu cliques sur un onglet, React le marque comme une "transition." Si l'utilisateur clique sur un autre onglet avant que le premier rende, React abandonne le premier render et démarre le second.

La différence avec useDeferredValue

useDeferredValueuseTransition
Diffère une valeurDiffère une mise à jour de state
Tu ne contrôles pas la sourceTu contrôles la mise à jour
Retourne la valeur différéeRetourne [isPending, startTransition]

Exemple concret : navigation

function Router() {
  const [page, setPage] = useState('/home');
  const [isPending, startTransition] = useTransition();
  
  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }
  
  return (
    <>
      <Nav onNavigate={navigate} />
      {isPending && <LoadingBar />}
      <Page url={page} />
    </>
  );
}

La nav reste interactive pendant que la page charge. La barre de chargement montre la progression.

Quand utiliser useTransition

  • Changement d'onglets
  • Navigation
  • Toute mise à jour où interactivité > render immédiat

La cheat sheet

HookButUtilise quand
useIdGénère des IDs uniques et stablesAccessibilité formulaires, attributs ARIA
useDebugValueLabellise les hooks dans DevToolsDebug de custom hooks
useSyncExternalStoreS'abonne aux données externesAPIs navigateur, state externe
useDeferredValueDiffère une valeurTu ne contrôles pas la source
useTransitionDiffère une mise à jourTu contrôles la mise à jour du state

La suite ?

Tu connais maintenant tous les hooks livrés avec React 18.

Mais React 19 change la donne. Nouveaux hooks pour les formulaires, les mises à jour optimistes, et le state async. Server components et actions. Un tout nouveau modèle mental.

Dans le dernier article, on explorera les nouveaux hooks de React 19 et ce qu'ils signifient pour le futur.

Continuer vers la partie 5 : La nouvelle ère React 19 →


Des questions ? Trouvé un bug dans mon code ? Contacte-moi sur LinkedIn ou découvre plus sur mon blog.