shadcn/ui Kompositionsmuster: Best Practices für mehrere Komponenten
Öffnen Sie den Code der Benutzerverwaltungsseite.
Eine DataTable zeigt die Benutzerliste, jede Zeile hat ein DropdownMenu mit Aktionen. Klick auf „Bearbeiten“ öffnet einen Dialog mit einem Formular drin. Klingt nach einer einfachen Funktion, oder?
Der Code sieht aber so aus: State wird hin und her gereicht, Prop Drilling bis in die fünfte Ebene, der Dialog-open-State sitzt in der Elternkomponente, die Form-Daten in der Kindkomponente, und nach dem Submit muss der Callback wieder hoch zur Elternkomponente, um die DataTable zu aktualisieren …
Damals war ich ziemlich skeptisch gegenüber shadcn/ui. „Einzelne Komponenten fühlen sich super an – warum wird es in Kombination so chaotisch?“
Später habe ich die Design-Dokumentation von shadcn/ui gelesen und gemerkt: Das Problem liegt nicht an der Bibliothek, sondern daran, dass man die Kompositionsmuster nicht kennt. Die Kernidee von shadcn/ui ist „Komposition vor Vererbung“ – jede Komponente hat eine einheitliche, vorhersagbare Schnittstelle. Wer die dahinterstehende Designphilosophie nicht versteht, kombiniert am Ende alles durcheinander.
Heute teile ich die Stolpersteine, auf die ich gelaufen bin, und die Best Practices für Kompositionsmuster, die ich danach gelernt habe.
Zuerst die Designphilosophie von shadcn/ui verstehen
Bevor wir konkrete Kombinationen besprechen, sollten Sie die Designlogik von shadcn/ui verstehen. Sonst fragen Sie sich vielleicht: Warum kombinieren andere React-Bibliotheken so klar und strukturiert – und bei mir sieht es aus wie Spaghetti?
Der größte Unterschied zu klassischen UI-Bibliotheken: shadcn/ui ist kein npm-Paket. In der package.json finden Sie keine Abhängigkeit @shadcn/ui. Den gesamten Komponentencode kopieren Sie direkt ins Projekt.
Klingt erstmal primitiv – genau das ist aber die Designphilosophie:
Open Code: Der Komponentencode ist vollständig offen. Sie können alles anpassen, ohne Versionskonflikte. Gefällt Ihnen ein Stil am Button nicht? Quellcode direkt ändern – kein Warten auf ein neues Release.
Composition: Alle Komponenten nutzen einheitliche, komponierbare Schnittstellen. Das heißt: Die Struktur jeder Komponente ist vorhersagbar. Eine Card ist immer <Card><CardHeader><CardTitle><CardContent>, ein Dialog immer <Dialog><DialogContent><DialogHeader><DialogTitle>.
Der Vorteil: Beim Kombinieren mehrerer Komponenten wissen Sie, wo verschachtelt und wo nebeneinander platziert wird. Es entstehen keine Widersprüche wie „Diese Komponente muss in jene – aber jene verlangt, draußen zu sein“.
Basis-Kombination: Dialog + Form
Das häufigste Szenario: Ein Formular in einem Dialog.
Der Nutzer klickt „Bearbeiten“, ein Dialog öffnet sich mit einem Formular, nach dem Submit schließt sich der Dialog. Klingt einfach – mein Anfangsfehler war, Dialog- und Form-State zu vermischen.
Anti-Pattern
// ❌ Meine Anfangsversion mit diesem Fehler
function EditUserDialog() {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState({})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => fetchUserData()}>Bearbeiten</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">Speichern</Button>
</form>
</DialogContent>
</Dialog>
)
}
Was ist das Problem? Dialog-open-State und Form-Daten liegen in einer Komponente. Ich habe den Form-State manuell verwaltet statt React Hook Form – Validierung und Fehleranzeige wurden schnell unübersichtlich.
Richtige Vorgehensweise
Die Form-Komponente von shadcn/ui basiert auf React Hook Form + Zod. Mit dieser Kombination wird der Code deutlich klarer:
// ✅ Korrekte Kombination
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
// 1. Schema zuerst definieren (außerhalb der Komponente)
const userSchema = z.object({
username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen haben"),
email: z.string().email("Ungültiges E-Mail-Format")
})
function EditUserDialog({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user // Benutzerdaten direkt übergeben
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Bearbeiten</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzerinformationen bearbeiten</DialogTitle>
</DialogHeader>
{/* Form direkt mit der shadcn Form-Komponente */}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data) // Daten absenden
setOpen(false) // Dialog schließen
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Benutzername</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Fehler automatisch anzeigen */}
</FormItem>
)}
/>
<Button type="submit">Speichern</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Kernpunkte:
- Dialog ist Container, Form ist Inhalt: Der Dialog kümmert sich nur um Öffnen/Schließen, das Formular um Daten/Validierung/Submit – klare Trennung der Verantwortlichkeiten.
- Form mit React Hook Form + Zod: Form-State nicht manuell verwalten;
form.handleSubmitübernimmt Validierung und Submit. - FormMessage zeigt Fehler automatisch: Keine manuelle Fehlerlogik – bei fehlgeschlagener Zod-Validierung erscheinen die Meldungen von selbst.
So wird der State klar: Dialog-open in der Elternkomponente, Form-Daten intern im Form (über React Hook Form).
DataTable + DropdownMenu: Zeilenaktionen
Ein weiteres häufiges Szenario: Pro Tabellenzeile ein Aktionsmenü; Klick auf „Bearbeiten“ öffnet einen Dialog.
Mein Stolperstein: Wie übergebe ich Zeilendaten an den Dialog? In der Column-Definition der DataTable haben Sie row.original – aber der Dialog sitzt außerhalb der DataTable. Wie verbinden?
Anti-Pattern
// ❌ Mein erster Ansatz: Dialog in der Zelle verschachteln
const columns = [
{
id: "actions",
cell: ({ row }) => (
<Dialog>
<DialogTrigger asChild>
<Button>Bearbeiten</Button>
</DialogTrigger>
<DialogContent>
{/* Problem: Bei jedem Cell-Render eine neue Dialog-Instanz */}
<EditForm user={row.original} />
</DialogContent>
</Dialog>
)
}
]
Das Problem: Jede Zeile erzeugt eine eigene Dialog-Instanz – bei 100 Zeilen 100 Dialoge. Schlechte Performance, und der State lässt sich kaum zentral verwalten.
Richtige Vorgehensweise
Ein globaler Dialog, State über einen Hook:
// 1. Hook für Dialog-State definieren
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 }
}
// 2. In der Column-Definition nur Trigger-Buttons
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)}>
Bearbeiten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
Löschen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
{/* Global einziger Dialog */}
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable() // Tabellendaten aktualisieren
}}
/>
</DialogContent>
</Dialog>
</>
)
}
Kernpunkte:
- Globaler Dialog: Ein Dialog außerhalb der DataTable – nicht pro Zeile.
- Hook für State:
openEditöffnet den Dialog und setzt die Daten,closeEditschließt und leert sie. - DropdownMenu als Trigger: In der Zelle nur Buttons;
onClickruftopenEdit(row.original)auf.
Die Struktur wird klar: DataTable zeigt Daten, DropdownMenu löst Aktionen aus, Dialog zeigt das Formular, der Hook steuert den State-Flow.
Fortgeschritten: Context-Muster gegen Prop Drilling
Beim Kombinieren mehrerer Komponenten ist Prop Drilling die häufigste Falle: State wird Ebene für Ebene weitergereicht – in der fünften Ebene wissen Sie nicht mehr, woher der Prop kommt.
Viele shadcn/ui-Komponenten folgen dem Compound-Components-Muster, z. B. Card:
<Card>
<CardHeader>
<CardTitle>Titel</CardTitle>
<CardDescription>Beschreibung</CardDescription>
</CardHeader>
<CardContent>Inhalt</CardContent>
<CardFooter>Fußzeile</CardFooter>
</Card>
Bei dieser Verschachtelung fragt man sich vielleicht: „Wie weiß CardTitle, zu welcher Card er gehört? Brauche ich eine cardId?“
Nein. Kern des Compound-Components-Musters: Context teilt State – Kindkomponenten „wissen“ automatisch, in welcher Elternkomponente sie sitzen.
Eigene einklappbare Card
Die shadcn/ui Card ist standardmäßig nicht einklappbar. Wir erweitern sie – und lernen dabei das Context-Muster:
// 1. Context erstellen
import { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
// 2. Root-Komponente: State verwalten, Context bereitstellen
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>
)
}
// 3. Header: Titel + Einklapp-Button
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>
)
}
// 4. Content: reagiert auf Einklapp-Status
CollapsibleCard.Content = ({ children }) => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")
if (ctx.isCollapsed) return null // eingeklappt: nichts anzeigen
return <CardContent>{children}</CardContent>
}
Verwendung:
<CollapsibleCard.Root defaultCollapsed={false}>
<CollapsibleCard.Header title="Benutzerinformationen" />
<CollapsibleCard.Content>
<p>Name: Max Mustermann</p>
<p>E-Mail: [email protected]</p>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
Kernpunkte:
- Context teilt State: Root erstellt Context, Kinder holen State über
useContext– kein Prop Drilling. - Kinder reagieren automatisch: Header-Klick toggelt State, Content zeigt/versteckt sich – ohne direkte Kommunikation.
- Eltern-Zwang: Liegt eine Kindkomponente nicht in Root, wirft sie einen Fehler.
Der Vorteil: Beim Kombinieren müssen Sie nicht überlegen, wie State weitergegeben wird. Solange Kindkomponenten innerhalb der Eltern liegen, haben sie Zugriff auf den State.
Vollständiges Szenario: DataTable + Dialog + Form
Alles zusammen: eine Benutzerverwaltungsseite – DataTable für die Liste, „Bearbeiten“ öffnet Dialog mit Form, nach Submit wird die Tabelle aktualisiert.
Den vollständigen Code finden Sie in den Abschnitten oben. Hier der Ablauf in Kurzform:
- Schema definieren: Zod für Datenstruktur und Validierung
- Dialog-State-Hook: Öffnen/Schließen und Datenübergabe zentral
- DataTable-Spalten: inkl. DropdownMenu-Aktionsspalte
- Bearbeitungsformular: Form + FormField + Inputs
- Hauptseite: DataTable und Dialog kombinieren
In diesem Beispiel sehen Sie alle Muster:
- DataTable zeigt Daten
- DropdownMenu löst Aktionen aus
- Dialog zeigt das Formular
- Form validiert und submitted
- Hook steuert den State-Flow
Jede Komponente hat eine klare Rolle; State läuft über Hook und Context – ohne Prop Drilling.
Fortgeschrittene Techniken: Performance und Typsicherheit
Re-Renders durch Context vermeiden
Compound Components mit Context sind praktisch – aber: Ändert sich der Context-Wert, rendern alle useContext-Komponenten neu.
Bei CollapsibleCard: Beim Einklappen rendern Header und Content neu. Enthält Content eine komplexe Liste, kann das langsam werden.
Lösung: Context trennen.
// State-Context (häufige Änderungen)
const CardStateContext = createContext<{ isCollapsed: boolean }>()
// Config-Context (statisch)
const CardConfigContext = createContext<{ collapsible: boolean }>()
// Root stellt zwei Contexts bereit
CollapsibleCard.Root = ({ children, collapsible = true, defaultCollapsed = false }) => {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
return (
<CardConfigContext.Provider value={{ collapsible }}>
<CardStateContext.Provider value={{ isCollapsed }}>
<Card>
{children}
{/* Toggle-Button hier – Context-Änderung betrifft weniger Kinder */}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Aufklappen" : "Einklappen"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
Header liest nur CardConfigContext (statisch, kein Re-Render), Content nur CardStateContext (Re-Render nur beim Einklappen).
TypeScript-Typsicherheit
Bei Compound Components sollten Typen sorgfältig definiert werden – sonst meldet TypeScript, Kindkomponenten könnten außerhalb der Eltern liegen.
// Vollständige Typdefinition
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 }) => {
// ...
}
}
So prüft TypeScript die übergebenen Props:
// ✅ Korrekt
<CollapsibleCard.Root defaultCollapsed={true}>
<CollapsibleCard.Header title="Titel" />
<CollapsibleCard.Content>Inhalt</CollapsibleCard.Content>
</CollapsibleCard.Root>
// ❌ TypeScript-Fehler: title ist Pflicht
<CollapsibleCard.Header />
Zusammenfassung: Checkliste für Kompositionsmuster
Kurz die wichtigsten Prinzipien:
Basis-Kombinationen
- Dialog + Form: Dialog als Container, Form als Inhalt – Verantwortlichkeiten trennen
- DataTable + DropdownMenu: DropdownMenu löst Aktionen aus, Daten über
row.original - Tabs + Form: Tabs als Navigation,
TabsContententhält jeweils ein Formular
Fortgeschritten
- Context-Muster: Prop Drilling vermeiden, State automatisch in Kindern
- Hook für State: Dialog zentral verwalten, keine Instanz pro Zeile
- Form + Zod: Einheitliches Validierungsschema,
FormMessagefür Fehler
Performance und Typen
- Context trennen: Häufig wechselnder State soll nicht alle Kinder neu rendern
- TypeScript-Typen: Vollständige Definitionen, falsche Props früh erkennen
- Server/Client trennen: Unter Next.js App Router Daten auf dem Server, UI im Client
Ein letzter Tipp: shadcn/ui-Komponentendateien nicht direkt anfassen. Für Anpassungen: Wrapper-Komponenten, variants oder Theme. Direkte Quelländerungen erschweren spätere Updates.
Ehrlich gesagt: Nach dem Lernen der Kompositionsmuster wurde mein Code deutlich klarer. Die Benutzerverwaltungsseite schrumpfte von über 300 auf unter 150 Zeilen, das State-Management wurde übersichtlich. Context und Performance-Optimierung wirken am Anfang vielleicht komplex – nach ein paar Durchläufen sitzt es.
Hatten Sie ähnliche Kombinationsprobleme? Probieren Sie diese Muster – sie helfen, die Struktur zu ordnen.
DataTable + Dialog + Form kombinieren
Vollständiger Ablauf für eine Benutzerverwaltungsseite
⏱️ Estimated time: 45 min
- 1
Step 1: Zod-Schema definieren
Datenstruktur und Validierung außerhalb der Komponente:
• Felder mit z.object() definieren
• Validierungsregeln hinzufügen (min, email, enum)
• schema und type exportieren - 2
Step 2: Dialog-State-Hook erstellen
Dialog-State zentral verwalten:
• useState für open und editingUser
• openDialog öffnet und setzt Daten
• closeDialog schließt und leert Daten - 3
Step 3: DataTable-Spalten definieren
Aktionsspalte in columns:
• DropdownMenu in cell
• onClick ruft openDialog(row.original) auf
• Dialog nicht in cell verschachteln - 4
Step 4: Bearbeitungsformular erstellen
shadcn Form-Komponente nutzen:
• useForm + zodResolver
• FormField + FormControl
• FormMessage zeigt Fehler automatisch - 5
Step 5: Hauptseite zusammensetzen
Alle Teile kombinieren:
• DataTable + globaler Dialog
• Form im Dialog
• Nach Submit Liste aktualisieren
FAQ
Warum Dialog nicht in DataTable-Zellen verschachteln?
React Hook Form oder manuelles Form-State-Management?
• Automatische Validierung und Fehleranzeige
• Typsicherheit (z.infer leitet Typen ab)
• Bessere Performance (weniger Re-Renders)
• FormMessage zeigt Fehler automatisch
Verursacht das Context-Muster Performance-Probleme?
• Context trennen (State-Context + Config-Context)
• Nur Komponenten, die reagieren müssen, lesen State-Context
• Statische Konfiguration im Config-Context
Wie Prop Drilling vermeiden?
Darf ich shadcn/ui-Quellcode direkt ändern?
• Wrapper-Komponenten erstellen
• Varianten über variants definieren
• Stile über Theme anpassen
Direkte Quelländerungen erschweren spätere Updates.
Wie TypeScript-Typen für Compound Components definieren?
• Props-Typen für Root/Header/Content
• Komponenten mit FC<Props> einschränken
• Fehler werfen, wenn Kind nicht in Root liegt
So prüft TypeScript die Props zur Compile-Zeit.
10 Min. Lesezeit · Veröffentlicht am: 1. Apr. 2026 · Aktualisiert am: 13. Juli 2026
Tailwind & shadcn/ui in der Praxis
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Tailwind Dark Mode: class vs. data-theme im Vergleich
Systematischer Vergleich der Tailwind-CSS-Dark-Mode-Strategien class und data-theme – Implementierung, Konfiguration, Framework-Integration und die passende Wahl für Ihr Projekt
Teil 7 von 14
Nächster
shadcn/ui und Radix: Barrierefreiheit bei eigenen Komponenten bewahren
shadcn/ui basiert auf Radix Primitives – wie bleibt Barrierefreiheit beim Anpassen erhalten? asChild, Fokusmanagement und ARIA-Vererbung, damit Tastatur-Navigation nicht ausfällt.
Teil 9 von 14
Ähnliche Beiträge
Tailwind v4 + Vite: 5-Minuten-Setup-Vorlage und Verzeichnisstruktur

Tailwind v4 + Vite: 5-Minuten-Setup-Vorlage und Verzeichnisstruktur
Tailwind CSS v4 – Neue Features: Performance, Konfiguration und Migrationsleitfaden


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen