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.