Design Pattern
Patterns front-end

Compound Components

Concevoir des APIs de composants flexibles et composables

Problème

Un composant monolithique avec beaucoup d'options finit avec une interface ingérable :

// Trop de props, trop de cas à gérer
<Select
  options={options}
  value={value}
  onChange={onChange}
  placeholder="Choisir..."
  isSearchable
  isMulti
  renderOption={(opt) => <CustomOption {...opt} />}
  renderValue={(val) => <CustomValue {...val} />}
  groupBy="category"
  maxMenuHeight={300}
/>

Chaque nouvelle fonctionnalité ajoute une prop. L'API devient difficile à mémoriser et le composant difficile à maintenir.

Le pattern

Compound Components expose plusieurs sous-composants qui fonctionnent ensemble. L'état partagé passe par un Context interne.

// Contrat interne via Context
interface SelectContext {
  value: string | null;
  onChange: (value: string) => void;
  isOpen: boolean;
  toggle: () => void;
}

const SelectCtx = React.createContext<SelectContext | null>(null);

function useSelectContext() {
  const ctx = React.useContext(SelectCtx);
  if (!ctx) throw new Error('useSelectContext doit être utilisé dans <Select>');
  return ctx;
}
// Composant racine — gère l'état
function Select({ children, value, onChange }: {
  children: React.ReactNode;
  value: string | null;
  onChange: (value: string) => void;
}) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <SelectCtx.Provider value={{ value, onChange, isOpen, toggle: () => setIsOpen(o => !o) }}>
      <div className="relative">{children}</div>
    </SelectCtx.Provider>
  );
}

// Sous-composants
Select.Trigger = function Trigger({ children }: { children: React.ReactNode }) {
  const { toggle, isOpen } = useSelectContext();
  return (
    <button onClick={toggle} aria-expanded={isOpen}>
      {children}
    </button>
  );
};

Select.Option = function Option({ value, children }: { value: string; children: React.ReactNode }) {
  const { onChange, toggle } = useSelectContext();
  return (
    <li onClick={() => { onChange(value); toggle(); }}>
      {children}
    </li>
  );
};

Select.Menu = function Menu({ children }: { children: React.ReactNode }) {
  const { isOpen } = useSelectContext();
  if (!isOpen) return null;
  return <ul className="absolute top-full w-full border bg-white">{children}</ul>;
};
// À l'usage : l'API est composable, pas une liste de props
<Select value={value} onChange={setValue}>
  <Select.Trigger>{value ?? 'Choisir...'}</Select.Trigger>
  <Select.Menu>
    <Select.Option value="fr">France</Select.Option>
    <Select.Option value="be">Belgique</Select.Option>
    <Select.Option value="ch">Suisse</Select.Option>
  </Select.Menu>
</Select>

Exemples dans l'écosystème

Les librairies UI modernes utilisent ce pattern :

// Radix UI
<Dialog.Root>
  <Dialog.Trigger>Ouvrir</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Titre</Dialog.Title>
      <Dialog.Close>Fermer</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

// Headless UI
<Disclosure>
  <Disclosure.Button>FAQ</Disclosure.Button>
  <Disclosure.Panel>Réponse...</Disclosure.Panel>
</Disclosure>

Quand l'utiliser

Le pattern est pertinent quand :

  • Le composant a plusieurs zones configurables (trigger, contenu, footer...)
  • L'ordre ou la présence des sous-parties doit être flexible
  • Les utilisateurs du composant ont besoin d'injecter du JSX arbitraire

Il est inutile pour un composant simple avec 2-3 props. La complexité se justifie quand l'API à base de props devient un frein.