Changer le thème

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

Easton editorial illustration: performance tuning console

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 :

  1. Dialog = conteneur, Form = contenu : Dialog gère ouvert/fermé ; Form gère données, validation et soumission.
  2. React Hook Form + Zod : pas d’état formulaire manuel ; form.handleSubmit gère validation et envoi.
  3. 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 :

  1. Dialog global : un seul Dialog hors du DataTable.
  2. Hook d’état : openEdit ouvre et injecte les données ; closeEdit ferme et vide.
  3. DropdownMenu comme déclencheur : la cellule ne contient que le bouton ; onClick appelle openEdit(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 :

  1. Context partage l’état : Root fournit le Context ; les enfants utilisent useContext sans props intermédiaires.
  2. Réaction automatique : Header bascule l’état ; Content affiche ou masque sans communication directe.
  3. 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 :

  1. Schema : Zod pour structure et validation
  2. Hook Dialog : ouverture, fermeture, passage des données
  3. Colonnes DataTable : colonne d’actions avec DropdownMenu
  4. Composant formulaire : Form + FormField + champs
  5. 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. 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. 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. 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. 4

    Step 4: Créer le composant formulaire

    Utiliser le Form shadcn :

    • useForm + zodResolver
    • FormField + FormControl
    • FormMessage pour les erreurs
  5. 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 ?
Une instance Dialog par ligne dégrade les performances (100 lignes = 100 Dialog) et complique la gestion d'état. Utilisez un Dialog global et un Hook pour open, données et fermeture.
React Hook Form ou gestion manuelle du formulaire ?
Fortement recommandé : React Hook Form + Zod :

• 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 ?
Oui. Tout changement de valeur Context re-rend les consommateurs. Solutions :

• 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 ?
Pattern Context : le Root crée le Context, les enfants utilisent useContext. Pas de props sur cinq niveaux — être dans le parent suffit pour accéder à l'état.
Peut-on modifier directement le code source shadcn/ui ?
Déconseillé. Préférez :

• 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 ?
Définir des types complets :

• 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

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog