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 ?
- 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.
- 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.
- 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.
- 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.
- 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 ?
- Rigidité — Dans l’état actuel, la structure est assez rigide. Par exemple, si vous voulez une autre variante de
TodoItemqui a un bouton pour marquer une tâche comme terminée, ou peut-être une variante deTodoFormqui a des champs supplémentaires, l’adaptation de ces composants à ces scénarios serait plus complexe. - Passage de props — Les fonctions de suppression et d’ajout sont transmises en tant que props aux composants enfants
TodoItemetTodoFormdepuis le composant parentHomeScreen. 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. - Manque d’encapsulation — Les composants
TodoItemetTodoFormexposent trop de détails d’implémentation. Par exemple,TodoItema 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. - 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 :
- 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.
- 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.
- 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.
- 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.
- 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 ?
- 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.
- Réutilisabilité — Les sous-composants, comme
CardTitle,CardImage, etCardContentpeuvent être réutilisés et réarrangés librement. Cette approche réduit la duplication du code et accroît la maintenabilité. - 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.
- Isolation — Les sous-composants (comme
CardButtonouCardImage) peuvent être mis à jour indépendamment des autres sous-composants, évitant ainsi les effets de bord inattendus. - 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
CardFooterpourrait ê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 ?
- 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, etTodos.Statsdans 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. - 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. - Hook personnalisé — Ils utilisent un hook personnalisé
useTodosContextpour obtenir les valeurs du contexte. Ce hook rend le code plus lisible et plus facile à utiliser. - 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.Listpourra être utilisé dans un autre écran ou dans une sidebar sans avoir besoin de passer d’informations supplémentaires via les props. - Extensibilité — Avec cette approche, vous pouvez également étendre facilement le composant
Todosen 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-composantTodos.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.