Créer un composant polymorphe avec "asChild"

Voir la vidéo

Aujourd’hui, je vous propose de découvrir un motif de développement avec React pour rendre vos composants plus flexibles : l’utilisation d'une propriété asChild. Ce pattern, utilisé notamment dans la librairie Radix UI, permet de déléguer le rendu et les props à un enfant, tout en gardant la logique du composant parent.

Le problème avec les composants polymorphiques

Prenons un exemple simple : un composant Button. Dans une application, on a souvent besoin de créer des éléments qui ressemblent à des boutons, mais qui ne sont pas forcément des <button> (comme par exemple un lien <a>).

La première approche consisterait à ajouter une prop as à notre composant, qui permettrait d’indiquer si l’on souhaite rendre un <a>, un <div>, etc.

<Button as="a" href="/mon-lien">
  Voir le site
</Button>

Le problème de cette approche, c’est qu’elle complexifie énormément la gestion des props et leur typage dans TypeScript. Il faut en effet gérer toutes les props que l’élément choisi pourrait recevoir (comme href dans le cas d’un lien), ce qui peut rapidement devenir lourd.

L’approche asChild

Une autre solution, beaucoup plus simple à maintenir, consiste à introduire une prop asChild qui permettra d'indiquer que l'on souhaite transférer les attributs à l'élément enfant.

<Button asChild>
  <a href="/mon-lien">Voir le site</a>
</Button>

Implémentation d’un composant Slot

Pour implémenter ce comportement, on va créer un composant générique qui servira à transférer les propriétés reçues à l'élément enfant.

import {
  Children,
  cloneElement,
  isValidElement,
  type HTMLAttributes,
  type PropsWithChildren,
  type ReactElement,
  type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";

// Type utilitaire pour simplifier le typage des composant supportant asChild
export type AsChildProps<BaseProps, SecondaryProps> =
  | ({ asChild: true; children: ReactNode } & BaseProps & {
        [k in keyof SecondaryProps]: never;
      })
  | ({ asChild?: false } & BaseProps & SecondaryProps);

export function Slot(props: PropsWithChildren<HTMLAttributes<HTMLElement>>) {
  const children = Children.toArray(props.children).filter((c) =>
    isValidElement(c)
  );

  if (children.length !== 1) {
    throw new Error("Slot must have exactly one child element");
  }

  const child = children[0] as ReactElement<HTMLAttributes<HTMLElement>>;

  return cloneElement(child, {
    ...props,
    ...child.props,
    style:
      props.style || child.props.style
        ? {
            ...props.style,
            ...child.props.style,
          }
        : undefined,
    className:
      props.className || child.props.className
        ? twMerge(props.className, child.props.className)
        : undefined,
  });
}

Ici, on clone l’enfant en lui injectant toutes les props reçues par le composant parent. Attention cependant à bien fusionner certaines props comme className ou style pour éviter d’écraser celles de l’enfant.

Intégration dans notre Button

Dans notre composant Button, il suffit ensuite d’utiliser ce Slot :

import type { ButtonHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import { Slot, type AsChildProps } from "./Slot";

type ButtonVariant = "primary" | "secondary" | "danger";

type Props = AsChildProps<
  {
    variant?: ButtonVariant;
    children: ReactNode;
  },
  ButtonHTMLAttributes<HTMLButtonElement>
>;

export function Button({
  variant = "primary",
  children,
  className,
  asChild,
  ...props
}: Props) {
  const Component = asChild ? Slot : "button";
  return (
    <Component className={`btn btn-${variant}`} {...props}>
      {children}
    </Component>
  );
}

Des cas concrets

Cette approche fonctionne très bien dans des cas un peu complexes comme par exemple la création d'un bouton d’upload de fichier (pour lequel on va utiliser un label comme bouton).

<Button asChild>
  <label>
    Choisir un fichier
    <input type="file" hidden />
  </label>
</Button>

Le style sera appliqué au label, et le comportement d’ouverture de fichier sera conservé.

Limites de cette approche

Malheureusement tout n'est pas parfait avec cette approche et il y a quelques petit défaut à savoir avant de l'adopter :

  1. Il n'est pas possible de vérifier le type de l'enfant de manière statique. Dans notre implémentation si un composant avec asChild reçoit plusieurs enfant on autre une erreur à l'éxécution seulement.
  2. L'enfant doit accepter les props qui lui seront envoyées par le composant utilisant asChild au risque d'avoir un comportement partiel.

Avec un peu de rigueur, ces limites ne posent généralement pas de problème.

Publié
Technologies utilisées
Auteur :
Grafikart
Partager