Design Pattern : Compound Components
← Retour au studio

Design Pattern : Compound Components

Comment construire des composants React flexibles et réutilisables avec le pattern Compound Components — illustré avec des exemples Card et Todo-list.

Introduction

En tant que développeurs, nous savons que les projets évoluent constamment : les besoins changent, les designs se métamorphosent et les spécifications initiales peuvent rapidement devenir obsolètes.

Face à cet environnement mouvant, nos composants traditionnels montrent parfois leurs limites. Ils ne sont pas tous adaptés pour faire face de manière flexible et robuste à cette évolution constante.

Qui n’a jamais été frustré par un composant trop rigide pour s’accommoder d’un changement de maquette ou d’une mise à jour des exigences du projet ?

Examinons ensemble, deux exemples, pour illustrer le Design Pattern : Compound Components.

Exemple d’un composant d’UI simple

Supposons que nous devons créer un composant Card tout ce qu’il y a de plus classique. On a besoin d’afficher un title, une description et un thumbnail.

Voilà une implementation simple de ce que pourrait être ce composant :

// card.tsx

type CardProps = {
  title: string;
  description: string;
  thumbnail: string;
}

function Card({ title, description, thumbnail }: CardProps) {
  return (
    <View>
      <Image source={{ uri: thumbnail }} />
      <Text>{title}</Text>
      <Text>{description}</Text>
    </View>
  )
}

Ainsi que son usage :

// home.tsx

function HomeScreen() {
  return (
    <View>
      <Card
        title="Lorem ipsum"
        description="Quis enim aliqua ad et consectetur laboris reprehenderit ea anim occaecat adipisicing duis exercitation magna cupidatat."
        thumbnail="https://picsum.photos/200/300"
      />
    </View>
  )
}

Jusque là, tout va bien, notre composant est simple à développer, simple à utiliser et facile à relire.

Maintenant, comme dans tous les projets, le besoin évolue et le design de nos composants avec. Admettons, que notre besoin a évolué de façon à ce qu’on ai besoin d’ajouter un bouton sur notre composant Card. Mais, ce bouton ne doit pas apparaître à tous les endroits de mon application.

Ce que l’on va retrouver dans la plupart des projets professionnels aujourd’hui, c’est une surcharge de propriétés sur le composant. Le plus souvent, notre composant serait comme suit :

// card.tsx

type CardProps = {
  title: string;
  description: string;
  thumbnail: string;
  buttonLabel?: string;
  showButton?: boolean;
  onPressButton?: () => void;
}

function Card({ 
  title, 
  description, 
  thumbnail,
  buttonLabel,
  showButton = false,
  onPressButton
}: CardProps) {
  return (
    <View>
      <Image source={{ uri: thumbnail }} />
      <Text>{title}</Text>
      <Text>{description}</Text>
      {showButton && <Button label={buttonLabel} onPress={onPressButton} />}
    </View>
  )
}

NB : c’est volontairement exagéré pour mettre en avant le problème. Même sans Compound Components que l’on verra après, on pourra avoir un composant bien plus propre !

Et l’usage du composant serait comme suit :

// home.tsx

function HomeScreen() {
  return (
    <View>
      <Card
        title="Lorem ipsum"
        description="Quis enim aliqua ad et consectetur laboris reprehenderit ea anim occaecat adipisicing duis exercitation magna cupidatat."
        thumbnail="https://picsum.photos/200/300"
      />
      <Card
        title="Lorem ipsum"
        description="Quis enim aliqua ad et consectetur laboris reprehenderit ea anim occaecat adipisicing duis exercitation magna cupidatat."
        thumbnail="https://picsum.photos/200/300"
        buttonLabel="Lorem"
        onPressButton={() => { /* ... */}}
        showButton
      />
    </View>
  )
}

Observations

Que peux-tu observer sur cet exemple de composant “traditionnel” qui ne représente que de l’UI ?

  1. Structure rigide — Le composant Card a une structure définie, il contient toujours une image, un titre, une description et un bouton. Il n’y a pas de flexibilité pour changer la structure d’un composant en fonction des besoins.
  2. Passage de props — Toutes les données dont le composant Card a besoin sont passées via les props. Cela peut devenir encombrant et difficile à maintenir à mesure que nous ajoutons plus de props au composant.
  3. Moins de réutilisabilité — Les sous-composants ne peuvent pas être réutilisés indépendamment. Par exemple, si nous voulons utiliser seulement le bouton ou l’image de la carte dans un autre composant, cela ne serait pas possible.
  4. Peu extensible — Ajouter de nouvelles fonctionnalités à la carte nécessite une modification de l’implémentation de la carte elle-même, augmentant potentiellement le risque de créer des bugs non liés.
  5. Simplicité — Cependant, dans certains cas, cette approche peut être préférable pour sa simplicité. Si votre composant est très simple et n’a pas besoin des avantages offerts par le pattern de Compound Components, le surcoût en complexité peut ne pas en valoir la peine.

Exemple d’un composant plus complexe

Supposons maintenant que nous devons créer un composant plus complexe, des composants mêmes, qui ont besoin de travailler ensemble pour mettre en oeuvre une fonctionnalité de Todo-list. Pour cela, nous allons avoir les composants TodoList (pour afficher une liste d’item de todo), TodoItem (qui représente un item de todo), TodoForm (qui représente le formulaire d’un item de todo) et TodoStats (qui affiche des statistiques pour une liste de todo donnée).

Voilà une implementation de ce que pourrait être ces composants :

// todo-list.tsx

type TodoListProps = {
  todos: Array<{ id: string; content: string }>;
  onPressDelete: (id: string) => void;
}

function TodoList({ todos, onPressDelete }: TodoListProps) {
  return (
    <View>
      {todos.map((todo) => (
        <TodoItem 
          key={todo.id} 
          id={todo.id} 
          content={todo.content}
          onPressDelete={onPressDelete}
        />
      ))}
    </View>
  )
}

// todo-item.tsx

type TodoItemProps = {
  id: string;
  content: string;
  onPressDelete: (id: string) => void;
}

function TodoItem({ id, content, onPressDelete }: TodoItemProps) {
  return (
    <View>
      <Text>{content}</Text>
      <Button label="Delete" onPress={() => onPressDelete(id)} />
    </View>
  )
}

// todo-form.tsx

type TodoFormProps = {
  onPressSubmit: (content: string) => void;
}

function TodoForm({ onPressSubmit }: TodoFormProps) {
  const [value, setValue] = useState<string>('')
  
  return (
    <View>
      <TextInput value={value} onChangeText={setValue} />
      <Button label="Add" onPress={() => onPressSubmit(value)} />
    </View>
  )
}

// todo-stats.tsx

type TodoStatsProps = {
  todos: Array<{ id: string; content: string }>;
}

function TodoStats({ todos }: TodoStatsProps) {
  return (
    <View>
      <Text>Sum of todos: {todos.length}</Text>
    </View>
  )
}

Ainsi que l’usage de ces composants :

// home.tsx

function HomeScreen() {
  const [todos, setTodos] = useState([])
  
  return (
    <View>
      <TodoList 
        todos={todos} 
        onPressDelete={(id) => 
          setTodos((state) => state.filter((todo) => todo.id !== id))
        }
      />
      <TodoStats todos={todos} />
      <TodoForm 
        onPressSubmit={(content) =>
          setTodos((state) => [...state, { id: uuid(), content }])
        } 
      />
    </View>
  )
}

Observations

Que peux-tu observer sur cet exemple de composant “traditionnel” qui ne représente cette fois une fonctionnalité plus complexe ?

  1. Rigidité — Dans l’état actuel, la structure est assez rigide. Par exemple, si vous voulez une autre variante de TodoItem qui a un bouton pour marquer une tâche comme terminée, ou peut-être une variante de TodoForm qui a des champs supplémentaires, l’adaptation de ces composants à ces scénarios serait plus complexe.
  2. Passage de props — Les fonctions de suppression et d’ajout sont transmises en tant que props aux composants enfants TodoItem et TodoForm depuis le composant parent HomeScreen. Cela peut devenir compliqué à gérer à mesure que l’application s’agrandit, car chaque fois que vous voulez utiliser ces fonctions, vous devez les transmettre à travers tous les composants intermédiaires.
  3. Manque d’encapsulation — Les composants TodoItem et TodoForm exposent trop de détails d’implémentation. Par exemple, TodoItem a besoin de connaître non seulement le contenu de la tâche, mais aussi son id et comment traiter une action de suppression. Cela pourrait être évité avec une version composée qui masquerait ces détails.
  4. Peu extensible et peu lisible — Ce point est suffisamment explicite je pense !

Le Design Pattern : Compound Components

Le Design Pattern : Compound Components s’applique à n’importe quel langage fonctionnant avec des composants et de la gestion d’états. Il s’agit d’une approche qui offre :

  1. Structure — Le terme “Compound Components” décrit une relation “a un” entre les composants. Un composant comporte plusieurs sous-composants qui travaillent ensemble pour former une unité cohérente. Le composant parent sert de composant de mise en page tandis que les sous-composants déterminent le contenu.
  2. Flexibilité — Les Compound Components offrent une grande flexibilité dans l’arrangement des sous-composants. Les utilisateurs de cette API de composant peuvent contrôler l’organisation, la structure et la présentation d’un composant.
  3. Abstraction — Ils permettent une bonne séparation des préoccupations car chaque sous-composant traite une fonctionnalité particulière. Cela permet une meilleure réutilisation des composants et simplifie le test et la maintenance.
  4. Pas de passage de props — Un avantage majeur du modèle de Compound Components est l’évitement du prop-drilling, qui est un problème où des props doivent être passés à travers de nombreux niveaux de composants. Les Compound Components résolvent ce problème en utilisant le contexte React pour partager la valeur entre les composants.
  5. Encapsulation — Avec les Compound Components, nous pouvons exposer ce qui est nécessaire et masquer les détails d’implémentation spécifiques. Cela aide à produire un code plus clair et plus facile à maintenir.

Mise en pratique

Maintenant, voyons ensemble un refactor de nos composants précédents en version Compound Components.

Le composant d’UI simple en Compound Components

Reprenons notre composant d’UI simple et convertissons les props title, description, etc. en sous-composants pour en faire une composition comme suit :

// card.tsx

type CardProps = PropsWithChildren

function Card({ children }: CardProps) {
  return <View>{children}</View>
}

Card.Title = CardTitle
Card.Description = CardDescription
Card.Thumbnail = CardThumbnail
Card.Button = CardButton

// card-title.tsx

type CardTitleProps = { title: string }

function CardTitle({ title }: CardTitleProps) {
  return <Text>{title}</Text>
}

// card-description.tsx

type CardDescriptionProps = { description: string }

function CardDescription({ description }: CardDescriptionProps) {
  return <Text>{description}</Text>
}

// card-thumbnail.tsx

type CardThumbnailProps = { source: string }

function CardThumbnail({ source }: CardThumbnailProps) {
  return <Image source={{ uri: source }} />
}

// card-button.tsx

type CardButtonProps = {
  label: string;
  onPress: () => void;
}

function CardButton({ label, onPress }: CardButtonProps) {
  return <Button label={label} onPress={onPress} />
}

Et maintenant l’usage :

// home.tsx

function HomeScreen() {
  return (
    <View>
      <Card>
        <Card.Thumbnail source="https://picsum.photos/200/300" />
        <Card.Title title="Lorem ipsum" />
        <Card.Description 
          description="Quis enim aliqua ad et consectetur laboris reprehenderit ea anim occaecat adipisicing duis exercitation magna cupidatat."
        />
      </Card>
      <Card>
        <Card.Thumbnail source="https://picsum.photos/200/300" />
        <Card.Title title="Lorem ipsum" />
        <Card.Description 
          description="Quis enim aliqua ad et consectetur laboris reprehenderit ea anim occaecat adipisicing duis exercitation magna cupidatat."
        />
        <Card.Button label="Lorem" onPress={() => { /* ... */}} />
      </Card>
    </View>
  )
}

Observations

Quelles observations peux-tu faire cette fois ci ?

  1. Flexibilité — Le Compound Components donne un plus grand contrôle sur l’organisation des éléments dans le rendu. Dans le deuxième exemple d’utilisation, nous avons de l’information supplémentaire et une absence de bouton, ce qui ne serait pas possible avec une version non composée du composant qui limiterait strictement la structure.
  2. Réutilisabilité — Les sous-composants, comme CardTitle, CardImage, et CardContent peuvent être réutilisés et réarrangés librement. Cette approche réduit la duplication du code et accroît la maintenabilité.
  3. Lisibilité — Le code est plus facile à comprendre. Alors qu’un composant non composé pourrait avoir un grand nombre de props, ce qui pourrait rendre le code plus difficile à suivre, chaque sous-composant sait clairement quel est son rôle dans le composant de carte.
  4. Isolation — Les sous-composants (comme CardButton ou CardImage) peuvent être mis à jour indépendamment des autres sous-composants, évitant ainsi les effets de bord inattendus.
  5. Scalabilité — Les nouveaux sous-composants peuvent être ajoutés facilement en suivant cette approche, permettant au composant de s’adapter et de se développer avec le temps. Par exemple, un sous-composant CardFooter pourrait être ajouté si besoin.

Le composant complexe en Compound Components

Enfin, passons au plus intéressant, le groupe de composant qui représente la fonctionnalité de Todo-list, voilà la version Compound Components :

// todos.tsx

type TodosProps = PropsWithChildren<{
  data: Array<{ id: string; content: string }>;
}>

function Todos({ data, children }: TodosProps) {
  const [todos, setTodos] = useState(data)
  
  const contextValue = useMemo(() => ({
    todos,
    add: (content: string) => setTodos((state) => [...state, { id: uuid(), content }]),
    remove: (id: string) => setTodos((state) => state.filter((todo) => todo.id !== id))
  }), [todos])

  return (
    <TodosContext.Provider value={contextValue}>
      {children}
    </TodosContext.Provider>
  )
}

Todos.List = TodosList
Todos.Form = TodosForm
Todos.Stats = TodosStats

// use-todos-context.tsx

type TodosContextValue = {
  todos: Array<{ id: string; content: string }>;
  add: (content: string) => void;
  remove: (id: string) => void;
}

const TodosContext = createContext<TodosContextValue>({} as TodosContextValue)

const useTodosContext = () => {
  const context = useContext(TodosContext)
  if (!context) {
    throw new Error('useTodosContext should be used within <Todos>')
  }
  return context
}

// todos-list.tsx

function TodosList() {
  const { todos } = useTodosContext()

  return (
    <View>
      {todos.map((todo) => (
        <TodosListItem key={todo.id} id={todo.id} content={todo.content} />
      ))}
    </View>
  )
}

TodosList.Item = TodosListItem

// todos-list-item.tsx

type TodosListItemProps = { id: string; content: string }

function TodosListItem({ id, content }: TodosListItemProps) {
  const { remove } = useTodosContext()

  return (
    <View>
      <Text>{content}</Text>
      <Button label="Delete" onPress={() => remove(id)} />
    </View>
  )
}

// todos-form.tsx

function TodosForm() {
  const { add } = useTodosContext()
  const [value, setValue] = useState<string>('')
  
  return (
    <View>
      <TextInput value={value} onChangeText={setValue} />
      <Button label="Add" onPress={() => add(value)} />
    </View>
  )
}

// todos-stats.tsx

function TodosStats() {
  const { todos } = useTodosContext()

  return (
    <View>
      <Text>Sum of todos: {todos.length}</Text>
    </View>
  )
}

Ainsi que son usage, drastiquement simplifiée :

// home.tsx

function HomeScreen() {
  return (
    <View>
      <Todos data={[]}>
        <Todos.List />
        <Todos.Stats />
        <Todos.Form />
      </Todos>
    </View>
  )
}

Observations

Que peux t-on observer sur cette dernière partie ?

  1. Flexibilité d’affichage — Avec l’approche de Compound Components, la disposition des composants est beaucoup plus flexible. Vous pouvez choisir de rendre Todos.List, Todos.Form, et Todos.Stats dans n’importe quel ordre ou même de ne pas les afficher en fonction des spécificités des spécifications ou des besoins de votre application.
  2. Utilisation du Contexte — Grâce à l’utilisation de React Context (TodosContext), vous pouvez facilement partager des données (todos) et des fonctions (add, remove) entre tous les composants enfants. Cela permet d’éviter le problème de prop-drilling propre à l’approche non compound components.
  3. Hook personnalisé — Ils utilisent un hook personnalisé useTodosContext pour obtenir les valeurs du contexte. Ce hook rend le code plus lisible et plus facile à utiliser.
  4. Réutilisabilité accrue — Les composants sont désormais plus indépendants et peuvent être facilement réutilisés ailleurs dans l’application. Par exemple, Todos.List pourra être utilisé dans un autre écran ou dans une sidebar sans avoir besoin de passer d’informations supplémentaires via les props.
  5. Extensibilité — Avec cette approche, vous pouvez également étendre facilement le composant Todos en ajoutant des sous-composants supplémentaires sans bouleverser l’architecture existante. Par exemple, si vous voulez ajouter une fonctionnalité pour marquer les tâches comme faites, vous pourriez créer un nouveau sous-composant Todos.Checkbox.

Le mot de la fin

De manière générale, le composant “traditionnel” est plus simple, mais il offre moins de flexibilité et de potentiel de réutilisation que le Compound Components. Le choix entre les deux approches dépend des besoins spécifiques du projet. Mais de mon expérience, partir direct sur du Compound Components est rarement une mauvaise idée !

Les inconvénients potentiels de cette approche sont qu’elle est plus complexe et qu’elle nécessite une compréhension plus approfondie des concepts de React (pour le cas de React), tels que le Contexte et les Compound Components eux-mêmes. De plus, il est important de noter que bien que le Context puisse sembler être une solution à tous les problèmes, il doit être utilisé avec parcimonie pour éviter un couplage excessif entre les composants de votre application.

L’adoption du pattern Compound Components dans la conception d’interfaces utilisateur peut sembler déroutante au début, mais les avantages qu’elle offre en termes de modularité, de flexibilité et de réutilisabilité sont indéniables. Ainsi, en décomposant intelligemment les composants en des sous-éléments logiques, nous pouvons produire des systèmes d’UI flexibles, réutilisables et gérables.

Vous pouvez retrouvez cet article au format vidéo sur YouTube en suivant ce lien.