shadcn/ui : patterns de composition — bonnes pratiques pour faire collaborer plusieurs composants

Ouvrez le code de la page de gestion des utilisateurs.
Un DataTable affiche la liste, chaque ligne a un DropdownMenu d’actions ; un clic sur « Modifier » ouvre un Dialog qui contient un Form. Une fonctionnalité simple, non ?
Mais le code ressemble à ça : l’état qui circule partout, le prop drilling jusqu’au cinquième niveau, l’état open du Dialog dans le parent, les données du Form dans l’enfant, et après soumission il faut renvoyer un callback au parent pour rafraîchir le DataTable…
À un moment, j’ai vraiment douté de shadcn/ui. « Un composant isolé, c’est agréable ; pourquoi la composition devient-elle si chaotique ? »
En lisant la doc de conception de shadcn/ui, j’ai compris que le problème n’était pas la bibliothèque, mais l’ignorance des patterns de composition. Le cœur de shadcn/ui, c’est « composition plutôt qu’héritage » : chaque composant expose une interface prévisible et composable. Sans cette philosophie, tout s’emmêle.
Aujourd’hui, je partage les pièges que j’ai pris et les bonnes pratiques de composition apprises ensuite.
Comprendre d’abord la philosophie de shadcn/ui
Avant les combinaisons concrètes, il faut saisir la logique de conception. Sinon vous vous demanderez pourquoi d’autres composent proprement et vous, c’est le plat de spaghettis.
La plus grande différence avec une UI classique : ce n’est pas un paquet npm. Vous ne verrez pas @shadcn/ui dans le package.json. Vous copiez le code des composants directement dans le projet.
Ça paraît archaïque ? C’est pourtant la philosophie :
Open Code : le code est entièrement ouvert ; vous modifiez sans craindre les conflits de version. Un style de Button ne vous plaît pas ? Vous changez la source, sans attendre une release.
Composition : tous les composants partagent une interface composable unifiée. Chaque structure est prévisible. Card, c’est <Card><CardHeader><CardTitle><CardContent> ; Dialog, c’est <Dialog><DialogContent><DialogHeader><DialogTitle>.
L’avantage : en combinant plusieurs composants, vous savez quoi imbriquer et quoi mettre en parallèle. Pas de « ce composant doit être dedans, mais l’autre exige qu’il soit dehors ».
Combinaison de base : Dialog + Form
Scénario le plus courant : un formulaire dans une modale.
L’utilisateur clique « Modifier », un Dialog s’ouvre avec un Form ; après soumission, le Dialog se ferme. Simple en théorie — au début j’ai mélangé les états Dialog et Form.
Mauvais exemple
// ❌ Version où j'ai trébuché
function EditUserDialog() {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState({});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => fetchUserData()}>Modifier</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={(e) => {
e.preventDefault()
submitForm(formData)
setOpen(false)
}}>
<Input
value={formData.username}
onChange={(e) => setFormData({...formData, username: e.target.value})}
/>
<Button type="submit">Enregistrer</Button>
</form>
</DialogContent>
</Dialog>
)
}
Problème : état open du Dialog et données du Form dans le même composant ; gestion manuelle du formulaire sans React Hook Form — validation et erreurs deviennent le bazar.
Bonne approche
Le Form shadcn/ui repose sur React Hook Form + Zod. La combinaison est bien plus claire :
// ✅ Bonne combinaison
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
// 1. Définir le Schema (hors composant)
const userSchema = z.object({
username: z.string().min(3, "Au moins 3 caractères"),
email: z.string().email("Format e-mail invalide")
})
function EditUserDialog({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Modifier</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier l'utilisateur</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data)
setOpen(false)
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Nom d'utilisateur</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Enregistrer</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Points clés :
- Dialog = conteneur, Form = contenu : Dialog gère ouvert/fermé ; Form gère données, validation et soumission.
- React Hook Form + Zod : pas d’état formulaire manuel ;
form.handleSubmitgère validation et envoi. - FormMessage : les erreurs Zod s’affichent sans logique custom.
États séparés : open côté parent, données du Form dans le Form (via React Hook Form).
DataTable + DropdownMenu : actions par ligne
Autre scénario : menu d’actions par ligne, « Modifier » ouvre un Dialog.
Mon piège : comment passer les données de ligne au Dialog ? Dans les colonnes du DataTable, row.original est disponible, mais le Dialog est en dehors — comment lier ?
Mauvais exemple
// ❌ Ce que j'ai fait au début : Dialog dans la cellule
const columns = [
{
id: "actions",
cell: ({ row }) => (
<Dialog>
<DialogTrigger asChild>
<Button>Modifier</Button>
</DialogTrigger>
<DialogContent>
{/* Problème : une instance Dialog par rendu de cellule */}
<EditForm user={row.original} />
</DialogContent>
</Dialog>
)
}
]
100 lignes = 100 Dialog, performance dégradée ; états difficiles à centraliser.
Bonne approche
Un Dialog global, état géré par un Hook :
const useEditDialog = () => {
const [open, setOpen] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const openEdit = (user) => {
setEditingUser(user)
setOpen(true)
};
const closeEdit = () => {
setOpen(false)
setEditingUser(null)
};
return { open, editingUser, openEdit, closeEdit }
};
function UserDataTable({ users }) {
const { open, editingUser, openEdit, closeEdit } = useEditDialog()
const columns = [
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => openEdit(row.original)}>
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable()
}}
/>
</DialogContent>
</Dialog>
</>
)
}
Points clés :
- Dialog global : un seul Dialog hors du DataTable.
- Hook d’état :
openEditouvre et injecte les données ;closeEditferme et vide. - DropdownMenu comme déclencheur : la cellule ne contient que le bouton ;
onClickappelleopenEdit(row.original).
Rôles clairs : DataTable affiche, DropdownMenu déclenche, Dialog héberge le formulaire, Hook orchestre l’état.
Avancé : le pattern Context pour éviter le prop drilling
En combinant plusieurs composants, le piège classique est le prop drilling : l’état descend jusqu’au cinquième niveau sans savoir d’où il vient.
Beaucoup de composants shadcn/ui sont des Compound Components, par exemple Card :
<Card>
<CardHeader>
<CardTitle>Titre</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>Contenu</CardContent>
<CardFooter>Pied de page</CardFooter>
</Card>
On pourrait se demander : « Comment CardTitle sait-il à quelle Card il appartient ? Faut-il un cardId ? » Non. Les Compound Components partagent l’état via Context ; l’enfant « sait » dans quel parent il se trouve.
Étendre une Card pliable
La Card shadcn/ui ne se replie pas par défaut. Voici une version pliable pour illustrer le Context :
import { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
CollapsibleCard.Root = ({ children, defaultCollapsed = false }) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardContext.Provider value={{
isCollapsed,
toggle: () => setIsCollapsed(!isCollapsed)
}}>
<Card className="border rounded-lg">{children}</Card>
</CardContext.Provider>
)
}
CollapsibleCard.Header = ({ title }) => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Header must be in CollapsibleCard.Root")
return (
<CardHeader className="cursor-pointer" onClick={ctx.toggle}>
<div className="flex items-center justify-between">
<CardTitle>{title}</CardTitle>
{ctx.isCollapsed ? <ChevronDown /> : <ChevronUp />}
</div>
</CardHeader>
)
}
CollapsibleCard.Content = ({ children }) => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")
if (ctx.isCollapsed) return null
return <CardContent>{children}</CardContent>
}
Utilisation :
<CollapsibleCard.Root defaultCollapsed={false}>
<CollapsibleCard.Header title="Informations utilisateur" />
<CollapsibleCard.Content>
<p>Nom : Zhang San</p>
<p>E-mail : [email protected]</p>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
Points clés :
- Context partage l’état : Root fournit le Context ; les enfants utilisent
useContextsans props intermédiaires. - Réaction automatique : Header bascule l’état ; Content affiche ou masque sans communication directe.
- Contrainte de parent : hors Root, erreur explicite.
Tant que l’enfant est dans le parent, il récupère l’état — pas de prop drilling.
Scénario complet : DataTable + Dialog + Form
En synthèse : page de gestion utilisateurs — DataTable, « Modifier » ouvre Dialog avec Form, soumission met à jour la table.
Le flux reprend les sections précédentes :
- Schema : Zod pour structure et validation
- Hook Dialog : ouverture, fermeture, passage des données
- Colonnes DataTable : colonne d’actions avec DropdownMenu
- Composant formulaire : Form + FormField + champs
- Page principale : assemble DataTable et Dialog
Tous les patterns ensemble :
- DataTable pour l’affichage
- DropdownMenu pour déclencher
- Dialog pour le formulaire
- Form pour validation et soumission
- Hook et Context pour l’état
Chaque composant a un rôle net.
Astuces avancées : performance et typage
Éviter les re-renders dus au Context
Le Context est pratique, mais quand sa valeur change, tous les useContext re-rendent.
Sur CollapsibleCard, un changement de repli fait re-rendre Header et Content ; une liste lourde dans Content devient coûteuse.
Solution : séparer les Context.
const CardStateContext = createContext<{ isCollapsed: boolean }>()
const CardConfigContext = createContext<{ collapsible: boolean }>()
CollapsibleCard.Root = ({ children, collapsible = true, defaultCollapsed = false }) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardConfigContext.Provider value={{ collapsible }}>
<CardStateContext.Provider value={{ isCollapsed }}>
<Card>
{children}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Déplier" : "Replier"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
Header lit CardConfigContext (stable) ; Content lit CardStateContext (re-render seulement au repli).
Typage TypeScript sûr
Les Compound Components demandent des types soignés, sinon TypeScript signale que l’enfant pourrait être hors du parent.
type CollapsibleCardProps = {
children: React.ReactNode
defaultCollapsed?: boolean
collapsible?: boolean
}
type CollapsibleCardComponents = {
Root: FC<CollapsibleCardProps>
Header: FC<{ title: string }>
Content: FC<{ children: React.ReactNode }>
}
const CollapsibleCard: CollapsibleCardComponents = {
Root: ({ children, defaultCollapsed = false, collapsible = true }) => {
// ...
},
Header: ({ title }) => {
// ...
},
Content: ({ children }) => {
// ...
},
}
// ✅ Correct
<CollapsibleCard.Root defaultCollapsed={true}>
<CollapsibleCard.Header title="Titre" />
<CollapsibleCard.Content>Contenu</CollapsibleCard.Content>
</CollapsibleCard.Root>
// ❌ Erreur TS : title obligatoire
<CollapsibleCard.Header />
Synthèse : checklist des patterns
Combinaisons de base
- Dialog + Form : Dialog conteneur, Form contenu — responsabilités séparées
- DataTable + DropdownMenu : menu déclenche l’action ; données via
row.original - Tabs + Form : Tabs pour la navigation, TabsContent pour chaque formulaire
Techniques intermédiaires
- Context : éviter le prop drilling
- Hook d’état : un Dialog global, pas une instance par ligne
- Form + Zod : schéma unique, FormMessage pour les erreurs
Optimisation avancée
- Context séparés : limiter les re-renders
- Types TypeScript : props vérifiées à la compilation
- Séparation Server/Client : sous Next.js App Router, données côté serveur, UI côté client
Dernier conseil : ne modifiez pas directement les fichiers shadcn/ui. Pour le style, préférez un wrapper, des variants ou le thème. Modifier la source complique les mises à jour.
Après avoir intégré ces patterns, mon code s’est nettement clarifié. La page de gestion utilisateurs est passée de plus de 300 lignes à moins de 150, avec un état lisible. Context et perf demandent un peu d’entraînement — après quelques essais, ça devient naturel.
Vous avez déjà eu des galères de composition similaires ? Ces patterns peuvent aider à y voir clair.
Implémenter DataTable + Dialog + Form
Flux complet pour une page de gestion utilisateurs
⏱️ Estimated time: 45 min
- 1
Step 1: Définir le schéma Zod
Définir la validation hors composant :
• z.object() pour les champs
• règles min, email, enum
• exporter schema et type - 2
Step 2: Créer un Hook d'état Dialog
Centraliser l'état du Dialog :
• useState pour open et editingUser
• openDialog ouvre et injecte les données
• closeDialog ferme et réinitialise - 3
Step 3: Définir les colonnes DataTable
Ajouter une colonne d'actions :
• DropdownMenu dans cell
• onClick appelle openDialog(row.original)
• ne pas imbriquer Dialog dans cell - 4
Step 4: Créer le composant formulaire
Utiliser le Form shadcn :
• useForm + zodResolver
• FormField + FormControl
• FormMessage pour les erreurs - 5
Step 5: Assembler la page
Combiner les blocs :
• DataTable + Dialog global
• Form dans le Dialog
• après soumission, rafraîchir la liste
FAQ
Pourquoi ne pas imbriquer le Dialog dans une cellule du DataTable ?
React Hook Form ou gestion manuelle du formulaire ?
• validation et erreurs automatiques
• typage sûr (z.infer)
• moins de re-renders
• FormMessage affiche les erreurs
Le pattern Context pose-t-il des problèmes de performance ?
• séparer Context état et configuration
• seuls les composants concernés lisent le Context d'état
• configuration stable dans un Context dédié
Comment éviter le prop drilling ?
Peut-on modifier directement le code source shadcn/ui ?
• un composant wrapper
• des variants
• le thème pour le style
Modifier la source complique les mises à jour ultérieures.
Comment typer les Compound Components en TypeScript ?
• Props pour Root, Header, Content
• FC<Props> pour chaque sous-composant
• erreur si utilisé hors Root
TypeScript vérifie alors les props à la compilation.
9 min de lecture · Publié le: 1 avr. 2026 · Mis à jour le: 27 juil. 2026
Tailwind & shadcn/ui en pratique
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Mode sombre Tailwind : class vs data-theme, deux approches comparées
Comparaison systématique des modes sombre Tailwind CSS via class et data-theme : principes, configuration et intégration framework pour choisir la bonne approche.
Partie 7 sur 14
Suivant
shadcn/ui et Radix : conserver l'accessibilité lors de la personnalisation
shadcn/ui repose sur Radix Primitives — comment préserver l'accessibilité en personnalisant vos composants ? asChild, gestion du focus et héritage ARIA expliqués pour éviter la navigation clavier cassée.
Partie 9 sur 14



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire