Cours JavaScript et React · Module 15: Patterns et architecture

Headless Components - Séparer la logique de la présentation

8 min de lecture
Dans cette leçon7

Le cockpit et le panneau d'ingénierie ont besoin de la même liste déroulante : ouverture, fermeture, choix d'une option, gestion du clavier et attributs d'accessibilité. Mais le cockpit veut un simple bouton, alors que les ingénieurs veulent un menu spatial avec des icônes. Deux composants, ce sont deux copies d'une logique délicate et deux endroits où des bugs peuvent se glisser. Mieux vaut un module de pilotage universel que tu branches sur n'importe quel panneau. C'est exactement l'idée des Headless Components : des composants qui contiennent toute la logique, mais zéro UI.

Que sont les Headless Components ?

Un Headless Component est un composant ou un hook qui :

  1. Gère l'état et la logique (ouverture/fermeture, sélection, navigation)
  2. N'affiche AUCUN JSX, c'est-à-dire qu'il n'a pas d'apparence propre
  3. Confie le contrôle de l'UI au consommateur via des props, des render props ou des hooks

Le plus souvent, c'est tout simplement un custom hook, comme useCrew de la leçon précédente, sauf qu'au lieu de données d'API il gère le comportement de l'interface. Construisons un dropdown headless étape par étape. D'abord, l'état de la liste et trois actions simples :

1// Headless Dropdown : toute la logique, zéro UI
2function useDropdown(options) {
3  const [isOpen, setIsOpen] = useState(false);
4  const [selectedIndex, setSelectedIndex] = useState(-1);
5  const [selectedOption, setSelectedOption] = useState(null);
6
7  const toggle = () => setIsOpen(prev => !prev);
8  const close = () => setIsOpen(false);
9
10  const select = (index) => {
11    setSelectedIndex(index);
12    setSelectedOption(options[index]);
13    setIsOpen(false);
14  };

Rien de tout cela n'apparaît encore à l'écran : c'est de la logique pure, qui fonctionnera avec n'importe quelle apparence. Passons à la gestion du clavier, la partie la plus souvent oubliée quand on écrit un dropdown à partir de zéro :

1  // Navigation au clavier
2  const handleKeyDown = (e) => {
3    if (e.key === 'ArrowDown') {
4      e.preventDefault();
5      setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
6    } else if (e.key === 'ArrowUp') {
7      e.preventDefault();
8      setSelectedIndex(prev => Math.max(prev - 1, 0));
9    } else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) {
10      e.preventDefault();
11      select(selectedIndex);
12    } else if (e.key === 'Escape') {
13      close();
14    }
15  };

Les flèches déplacent l'index dans les limites de la liste, Entrée valide le choix et Échap ferme la liste. e.preventDefault() désactive les réactions par défaut du navigateur : les flèches ne font pas défiler la page, et Entrée n'ajoute pas un clic normal sur le bouton, qui rouvrirait aussitôt la liste. C'est justement ce clic qui ouvre une liste fermée, car sur un bouton, Entrée et Espace agissent comme un clic de souris. Dans cette version simplifiée, un seul index sert à la fois à la mise en surbrillance et à la sélection, alors que les bibliothèques séparent ces deux notions. Enfin, le hook renvoie tout ce dont la vue a besoin, dont deux fonctions qui renvoient des ensembles de props tout prêts :

1  return {
2    isOpen, selectedIndex, selectedOption,
3    toggle, close, select, handleKeyDown,
4    getToggleProps: () => ({
5      onClick: toggle,
6      onKeyDown: handleKeyDown,
7      'aria-expanded': isOpen,
8      'aria-haspopup': 'listbox',
9    }),
10    getOptionProps: (index) => ({
11      onClick: () => select(index),
12      'aria-selected': selectedIndex === index,
13      role: 'option',
14    }),
15  };
16}

getToggleProps() fournit onClick, onKeyDown, aria-expanded et aria-haspopup, tandis que getOptionProps(index) ajoute à chaque option un role et aria-selected. Le consommateur étale ces objets avec l'opérateur spread et n'a pas à penser à l'accessibilité. On appelle ces fonctions des prop getters, et tu les retrouveras par exemple dans la bibliothèque Downshift.

Utiliser le dropdown headless avec n'importe quelle UI

La clé des headless components : le même hook, des apparences différentes. La première version est un simple bouton avec une liste, sans aucune décoration :

1// Version 1 : Dropdown simple
2function SimpleDropdown({ options, label }) {
3  const dropdown = useDropdown(options);
4
5  return (
6    <div className="simple-dropdown">
7      <button {...dropdown.getToggleProps()}>
8        {dropdown.selectedOption || label}
9      </button>
10      {dropdown.isOpen && (
11        <ul role="listbox">
12          {options.map((opt, i) => (
13            <li key={i} {...dropdown.getOptionProps(i)}>
14              {opt}
15            </li>
16          ))}
17        </ul>
18      )}
19    </div>
20  );
21}

La seconde version utilise exactement le même hook, mais construit un menu spatial avec des icônes et une flèche qui indique l'état de la liste :

1// Version 2 : Dropdown spatial avec icônes
2function SpaceDropdown({ options, icons }) {
3  const dropdown = useDropdown(options);
4
5  return (
6    <div className="space-dropdown">
7      <button type="button" {...dropdown.getToggleProps()} className="space-trigger">
8        <span>{dropdown.selectedOption || 'Choisis un système'}</span>
9        <span>{dropdown.isOpen ? '▲' : '▼'}</span>
10      </button>
11      {dropdown.isOpen && (
12        <div className="space-menu">
13          {options.map((opt, i) => (
14            <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
15              <span>{icons[i]}</span>
16              <span>{opt}</span>
17            </div>
18          ))}
19        </div>
20      )}
21    </div>
22  );
23}

Les deux composants ne diffèrent que par leur JSX, et le hook n'a pas changé d'une seule ligne. Tu peux aussi déstructurer le résultat directement : const { isOpen, selectedOption, getToggleProps, getOptionProps } = useDropdown(options). Remarque que le déclencheur est de nouveau un <button>, même s'il ne ressemble pas du tout à celui de la première version. Le hook fournit les attributs ARIA, mais c'est toi qui choisis l'élément : une <div> ne recevrait pas le focus au clavier, et même avec tabIndex={0} et role="button", tu devrais gérer Entrée et Espace toi-même.

Headless Toggle / Disclosure

Autre exemple : un toggle headless pour construire des accordéons, des sections dépliables et des spoilers. Ce hook est court, car il ne surveille qu'une seule valeur booléenne :

1function useToggle(initialState = false) {
2  const [isOpen, setIsOpen] = useState(initialState);
3
4  return {
5    isOpen,
6    toggle: () => setIsOpen(p => !p),
7    open: () => setIsOpen(true),
8    close: () => setIsOpen(false),
9    getToggleProps: () => ({
10      onClick: () => setIsOpen(p => !p),
11      'aria-expanded': isOpen,
12    }),
13    getPanelProps: () => ({
14      role: 'region',
15      hidden: !isOpen,
16    }),
17  };
18}

On va construire deux composants complètement différents sur le même interrupteur. Le premier est un accordéon qui déplie la description d'un système du vaisseau :

1// Utilisation 1 : Accordéon
2function SystemDetails({ title, children }) {
3  const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5  return (
6    <div className="accordion-item">
7      <button {...getToggleProps()}>
8        {title} {isOpen ? '−' : '+'}
9      </button>
10      <div {...getPanelProps()}>
11        {isOpen && children}
12      </div>
13    </div>
14  );
15}

L'infobulle a une autre apparence, mais utilise exactement la même API du hook, donc la logique d'ouverture et de fermeture n'est pas dupliquée :

1// Utilisation 2 : infobulle
2function InfoTooltip({ content, children }) {
3  const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5  return (
6    <span className="tooltip-wrapper">
7      <button type="button" {...getToggleProps()} className="tooltip-trigger">
8        {children}
9      </button>
10      <span {...getPanelProps()} className="tooltip-content">
11        {isOpen && content}
12      </span>
13    </span>
14  );
15}

getPanelProps() définit hidden et role="region" : grâce à hidden, un panneau replié disparaît aussi pour les lecteurs d'écran, et pas seulement visuellement. Le déclencheur est de nouveau un <button>, car aria-expanded n'est autorisé que sur des éléments dotés d'un rôle, comme un bouton, et un <span> ne recevrait pas le focus. Tu retrouveras la même forme d'API dans les bibliothèques headless sous le nom de Disclosure.

Headless Tabs

Les onglets sont le troisième classique. Le hook retient l'onglet actif et attribue les rôles tab et tabpanel, que les lecteurs d'écran comprennent sans description supplémentaire :

1function useTabs(tabCount, defaultTab = 0) {
2  const [activeTab, setActiveTab] = useState(defaultTab);
3
4  return {
5    activeTab,
6    setActiveTab,
7    getTabProps: (index) => ({
8      onClick: () => setActiveTab(index),
9      role: 'tab',
10      'aria-selected': activeTab === index,
11      tabIndex: activeTab === index ? 0 : -1,
12    }),
13    getPanelProps: (index) => ({
14      role: 'tabpanel',
15      hidden: activeTab !== index,
16    }),
17    isActive: (index) => activeTab === index,
18  };
19}

Un tabIndex à 0 uniquement pour l'onglet actif, c'est la technique du roving tabindex : la touche Tab arrive directement sur l'onglet actif. Le pattern d'onglets de WAI-ARIA ajoute aussi la navigation avec les flèches, et c'est là que servira le paramètre tabCount, encore inutilisé pour l'instant.

Pourquoi headless ?

CaractéristiqueComposant classiqueHeadless Component
UIStyles/JSX intégrésZéro, TOI tu décides
RéutilisabilitéLimitée à une seule apparenceIllimitée
AccessibilitéÀ implémenter à la mainAttributs aria intégrés
TestabilitéNécessite un renduTeste le hook seul

Headless UI dans l'écosystème React

La bibliothèque @headlessui/react (des créateurs de Tailwind CSS) fournit des composants headless prêts à l'emploi :

  • Listbox : dropdown avec prise en charge complète du clavier
  • Combobox : autocomplete/recherche
  • Dialog : modale avec focus trap
  • Disclosure : accordéon/section dépliable
  • Menu : menu déroulant
  • Tabs : onglets

Chacun fournit la logique, les attributs aria et la gestion du clavier, mais zéro style. C'est toi qui fournis toute l'apparence. Radix UI Primitives et React Aria d'Adobe fonctionnent de façon similaire.

Quand utiliser des composants headless ?

  • Tu construis un design system : différentes équipes ont besoin des mêmes interactions, mais de styles différents
  • Tu as besoin d'une accessibilité complète (a11y) : les composants headless ont des attributs aria intégrés
  • Tu crées des composants utilisés dans plusieurs projets : même logique, styles différents
  • Tu veux séparer les responsabilités : hook = logique, composant = apparence

Les Headless Components fonctionnent comme le système de navigation universel d'un vaisseau : la logique est la même, mais chaque panneau a une apparence différente, car chaque station a des besoins différents. Mon conseil : écris ton propre hook headless pour des interrupteurs simples comme useToggle. Un dropdown, un combobox ou une boîte de dialogue avec une gestion complète du clavier et du focus demandent beaucoup de travail, alors utilise plutôt une bibliothèque éprouvée. Dans la prochaine leçon, on donnera encore plus de contrôle à l'utilisateur du composant grâce à l'Inversion of Control, et tu verras en quoi les prop getters diffèrent d'une props collection.

Retiens : un hook headless, c'est un moteur sans coque, et la coque, c'est toi qui la dessines.

Code de cette leçon : App.jsx
1import React, { useState } from 'react';
2
3// === HOOK HEADLESS : useDropdown ===
4function useDropdown(options) {
5  const [isOpen, setIsOpen] = useState(false);
6  const [selectedIndex, setSelectedIndex] = useState(-1);
7  const [selectedOption, setSelectedOption] = useState(null);
8
9  const toggle = () => setIsOpen(p => !p);
10  const close = () => setIsOpen(false);
11
12  const select = (index) => {
13    setSelectedIndex(index);
14    setSelectedOption(options[index]);
15    setIsOpen(false);
16  };
17
18  // preventDefault : les flèches ne font pas défiler la page, et Entrée n’ajoute pas de clic qui rouvrirait la liste
19  const handleKeyDown = (e) => {
20    if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, options.length - 1)); }
21    else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)); }
22    else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) { e.preventDefault(); select(selectedIndex); }
23    else if (e.key === 'Escape') close();
24  };
25
26  return {
27    isOpen, selectedIndex, selectedOption, toggle, close, select, handleKeyDown,
28    getToggleProps: () => ({ onClick: toggle, onKeyDown: handleKeyDown, 'aria-expanded': isOpen, 'aria-haspopup': 'listbox' }),
29    getOptionProps: (i) => ({ onClick: () => select(i), 'aria-selected': selectedIndex === i, role: 'option' }),
30  };
31}
32
33// === HOOK HEADLESS : useToggle ===
34function useToggle(initial = false) {
35  const [isOpen, setIsOpen] = useState(initial);
36  return {
37    isOpen,
38    toggle: () => setIsOpen(p => !p),
39    getToggleProps: () => ({ onClick: () => setIsOpen(p => !p), 'aria-expanded': isOpen }),
40    getPanelProps: () => ({ role: 'region', hidden: !isOpen }),
41  };
42}
43
44// === COMPOSANT VISUEL 1 : Simple Dropdown ===
45function SimpleDropdown({ options, label }) {
46  const dd = useDropdown(options);
47  return (
48    <div className="dropdown">
49      <button className="dd-trigger" {...dd.getToggleProps()}>
50        {dd.selectedOption || label} <span>{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
51      </button>
52      {dd.isOpen && (
53        <ul className="dd-menu" role="listbox">
54          {options.map((opt, i) => (
55            <li key={i} className={"dd-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
56              {opt}
57            </li>
58          ))}
59        </ul>
60      )}
61    </div>
62  );
63}
64
65// === COMPOSANT VISUEL 2 : Space Dropdown (apparence différente, même logique) ===
66function SpaceDropdown({ options, icons, label }) {
67  const dd = useDropdown(options);
68  return (
69    <div className="space-dd">
70      <button type="button" className="space-trigger" {...dd.getToggleProps()}>
71        <span>{dd.selectedOption || label}</span>
72        <span className="arrow">{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
73      </button>
74      {dd.isOpen && (
75        <div className="space-menu">
76          {options.map((opt, i) => (
77            <div key={i} className={"space-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
78              <span className="opt-icon">{icons[i]}</span>
79              <span>{opt}</span>
80            </div>
81          ))}
82        </div>
83      )}
84    </div>
85  );
86}
87
88// === Accordéon utilisant useToggle ===
89function AccordionItem({ title, children }) {
90  const { isOpen, getToggleProps, getPanelProps } = useToggle();
91  return (
92    <div className="accordion-item">
93      <button className="accordion-header" {...getToggleProps()}>
94        {title} <span>{isOpen ? '\u2212' : '+'}</span>
95      </button>
96      <div className="accordion-body" {...getPanelProps()}>
97        {isOpen && <div className="accordion-content">{children}</div>}
98      </div>
99    </div>
100  );
101}
102
103// === COMPOSANT PRINCIPAL ===
104export default function App() {
105  return (
106    <div className="app">
107      <h1>Démo des composants headless</h1>
108      <p className="subtitle">Les mêmes hooks de logique, des apparences différentes</p>
109
110      <div className="section">
111        <h3>Dropdown simple (useDropdown)</h3>
112        <SimpleDropdown options={['Mars', 'Jupiter', 'Saturne', 'Europe']} label="Choisis une planète..." />
113      </div>
114
115      <div className="section">
116        <h3>Dropdown spatial (même useDropdown, autre apparence)</h3>
117        <SpaceDropdown
118          options={['Moteurs', 'Boucliers', 'Navigation', 'Armement']}
119          icons={['MOT', 'BOU', 'NAV', 'ARM']}
120          label="Choisis un système..."
121        />
122      </div>
123
124      <div className="section">
125        <h3>Accordéon (useToggle)</h3>
126        <AccordionItem title="État du moteur">
127          <p>Propulsion plasma à 94 % de rendement.</p>
128        </AccordionItem>
129        <AccordionItem title="Rapport des boucliers">
130          <p>Boucliers avant : 78 %. Boucliers arrière : 100 %.</p>
131        </AccordionItem>
132        <AccordionItem title="Navigation">
133          <p>Cap sur Alpha du Centaure. Arrivée prévue dans 4,2 ans.</p>
134        </AccordionItem>
135      </div>
136    </div>
137  );
138}

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. Que sont les Headless Components dans le contexte de React ?

  2. 2. À quoi servent les méthodes getToggleProps() et getOptionProps() dans les hooks headless ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine deux hooks headless et les deux apparences construites dessus. Dans useDropdown ___BLANK1___ : après le choix d'une option, la liste se ferme (utilise l'action close déjà prête). ___BLANK2___ : getToggleProps règle aria-expanded selon que la liste est ouverte ou non. ___BLANK3___ : getOptionProps(index) met aria-selected à true seulement pour l'option choisie (selectedIndex) et à false pour les autres. Dans useToggle ___BLANK4___ : getPanelProps cache le panneau avec l'attribut hidden quand l'interrupteur est fermé. ___BLANK5___ : SimpleDropdown étale sur son bouton les props renvoyées par getToggleProps du hook useDropdown (le résultat de l'appel, pas la fonction elle-même).

  • Tri horizontal

    Range la syntaxe de destructuration du résultat du hook headless useDropdown :

  • Clique dans l'ordre

    Clique dans l'ordre les étapes de création d'un composant headless :

Articles utiles