shadcn/ui Composition Patterns: Best Practices for Combining Components
2 AM, staring at the user management page code, I was a bit frustrated.
A DataTable displays a user list, each row has a DropdownMenu action menu, clicking “Edit” opens a Dialog containing a Form. Sounds simple enough, right?
But my code was like this: states passing around, prop drilling to the fifth layer, Dialog’s open state in the parent component, Form’s data in the child component, submit callback needs to go back to parent to update DataTable…
Honestly, I doubted shadcn/ui at that moment. “Single components are indeed smooth to use, but why is combining them so messy?”
Later I checked shadcn/ui’s design docs and realized the problem wasn’t the component library, but my lack of understanding of composition patterns. shadcn/ui’s core philosophy is “composition over inheritance”, each component has a unified predictable interface. But if you don’t know the design philosophy behind this interface, combining them will be as messy as mine was.
Today let’s talk about the pitfalls I stepped into, and the composition pattern best practices I learned later.
First Understand shadcn/ui’s Design Philosophy
Before talking about specific compositions, we need to understand shadcn/ui’s design logic. Otherwise you’ll find that while others combine components cleanly, yours looks like spaghetti code.
shadcn/ui’s biggest difference from traditional UI libraries: it’s not an npm package, you won’t see @shadcn/ui dependency in package.json. All component code is directly copied into your project.
Sounds primitive, right? But this is exactly its design philosophy:
Open Code: Component code is fully open, you can modify it anytime without worrying about version conflicts. For example, if you don’t like a Button component’s style, directly modify the source code without waiting for official new version.
Composition: All components use unified composable interface. What does this mean? Each component’s structure is predictable. For example, Card component must be <Card><CardHeader><CardTitle><CardContent> nested structure, Dialog must be <Dialog><DialogContent><DialogHeader><DialogTitle>.
The benefit of this unified interface: when you combine multiple components, you know where to nest, where to place side-by-side. Won’t encounter contradictions like “this component should wrap that one, but that one requires being outside”.
Basic Composition: Dialog + Form
Most common composition scenario: a modal containing a form.
User clicks “Edit” button, opens a Dialog, inside is a Form, after filling submit and close Dialog. Sounds simple, but when I first wrote it, I made a mistake: mixed Dialog and Form states together.
Wrong Approach
// ❌ This is my pitfall version
function EditUserDialog() {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState{{}}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => fetchUserData()}>Edit</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">Save</Button>
</form>
</DialogContent>
</Dialog>
)
}
Where’s the problem? Dialog’s open state and Form’s data state mixed in one component, and I manually managed form state without using React Hook Form, causing validation and error display to be messy.
Correct Approach
shadcn/ui’s Form component is based on React Hook Form + Zod, using this combination, code becomes much cleaner:
// ✅ Correct composition approach
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
// 1. Define Schema first (outside component)
const userSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email format")
})
function EditUserDialog({ user, onSubmit }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(userSchema),
defaultValues: user // Directly pass user data
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User Info</DialogTitle>
</DialogHeader>
{/* Form directly uses shadcn's Form component */}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
onSubmit(data) // Submit data
setOpen(false) // Close Dialog
})}>
<FormField
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage /> {/* Automatically show errors */}
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
Key points:
- Dialog is container, Form is content: Dialog only manages “open/close”, Form manages “data/validation/submit”, separate responsibilities.
- Form uses React Hook Form + Zod: Don’t manually manage form state, form.handleSubmit automatically handles validation and submission.
- FormMessage automatically shows errors: No need to write error logic manually, Zod validation failure automatically displays error messages.
This way, Dialog and Form states become clear: Dialog’s open in parent component, Form’s data inside Form component (managed by React Hook Form).
DataTable + DropdownMenu: Table Row Actions
Another common scenario: each table row has an action menu, clicking “Edit” opens Dialog.
My pitfall: didn’t know how to pass row data to Dialog. In DataTable’s column definition, you can get row.original (current row data), but Dialog is outside DataTable, how to pass?
Wrong Approach
// ❌ My first approach: nest Dialog in cell
const columns = [
{
id: "actions",
cell: { row } => (
<Dialog>
<DialogTrigger asChild>
<Button>Edit</Button>
</DialogTrigger>
<DialogContent>
{/* Problem: each cell render creates a Dialog instance */}
<EditForm user={row.original} />
</DialogContent>
</Dialog>
)
}
]
Problem with this approach: each row creates a Dialog instance, 100 rows means 100 Dialogs, very poor performance. And Dialog state is hard to manage uniformly.
Correct Approach
Use a global Dialog, manage state through Hook:
// 1. Define a Hook to manage Dialog state
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. DataTable column definition only has trigger button
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)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => deleteUser(row.original.id)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
]
return (
<>
<DataTable columns={columns} data={users} />
{/* Global unique Dialog */}
<Dialog open={open} onOpenChange={(o) => !o && closeEdit()}>
<DialogContent>
<EditUserForm
user={editingUser}
onSubmit={(data) => {
updateUser(data)
closeEdit()
refreshTable() // Refresh table data
}}
/>
</DialogContent>
</Dialog>
</>
)
}
Key points:
- Global Dialog: Put one Dialog outside DataTable, instead of creating one for each row.
- Hook manages state: openEdit opens Dialog and passes data, closeEdit closes and clears data.
- DropdownMenu triggers: Cell only has trigger button, through onClick calls openEdit(row.original).
This way structure becomes clear: DataTable manages “display data”, DropdownMenu manages “trigger action”, Dialog manages “display form”, Hook manages “state flow”.
Advanced: Context Pattern to Avoid Prop Drilling
When combining multiple components, the easiest pitfall is prop drilling: states passing layer by layer, by fifth layer you don’t know where this prop came from.
Many shadcn/ui components themselves are Compound Components pattern, like Card:
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
With this nested structure, you might think: “How does CardTitle know which Card it belongs to? Should I pass a cardId?”
Actually no need. Compound Components’ core is using Context to share state, child components automatically “know” which parent they’re in.
Implement a Collapsible Card
shadcn/ui’s Card doesn’t collapse by default, let’s extend a collapsible version to learn Context pattern:
// 1. Create Context
import { createContext, useContext, useState } from "react"
type CardContextValue = {
isCollapsed: boolean
toggle: () => void
}
const CardContext = createContext<CardContextValue | null>(null)
// 2. Root component: manage state, provide Context
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 component: show title + collapse 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 component: respond to collapse state
CollapsibleCard.Content = { children } => {
const ctx = useContext(CardContext)
if (!ctx) throw new Error("Content must be in CollapsibleCard.Root")
if (ctx.isCollapsed) return null // Don't show when collapsed
return <CardContent>{children}</CardContent>
}
Usage:
<CollapsibleCard.Root defaultCollapsed={false}>
<CollapsibleCard.Header title="User Info" />
<CollapsibleCard.Content>
<p>Name: John Doe</p>
<p>Email: [email protected]</p>
</CollapsibleCard.Content>
</CollapsibleCard.Root>
Key points:
- Context shares state: Root component creates Context, child components automatically get state through useContext, no prop drilling.
- Child components auto-respond: Header click toggles state, Content automatically shows/hides, no direct communication needed.
- Force parent constraint: If child component not in Root, throws error to remind you.
This pattern’s benefit: when combining multiple components, no worry about how to pass state. As long as child is inside parent, it can automatically get state.
Complete Scenario: DataTable + Dialog + Form
Combining what we learned, let’s write a complete user management page: DataTable displays list, clicking “Edit” opens Dialog, Dialog contains Form, after submit updates table.
Complete code example see above sections, here summarize key flow:
- Schema definition: Zod defines user data structure and validation rules
- Dialog state Hook: Uniformly manage Dialog’s open/close and data passing
- DataTable column definition: Includes DropdownMenu action column
- Edit form component: Form + FormField + various Inputs
- Main page component: Combine DataTable and Dialog
In this complete example, you see all composition patterns:
- DataTable displays data
- DropdownMenu triggers action
- Dialog displays form
- Form validates and submits
- Hook manages state flow
Each component has clear responsibility, state managed through Hook and Context, avoided prop drilling.
Advanced Tips: Performance Optimization and Type Safety
Avoid Re-render from Context
Compound Components using Context is convenient, but has a pitfall: when Context value changes, all useContext components re-render.
For example CollapsibleCard, when collapse state changes, Header and Content both re-render. If Content has a complex list, re-render will be slow.
Solution: Separate Context.
// State Context (frequently changes)
const CardStateContext = createContext<{ isCollapsed: boolean }>()
// Config Context (doesn't change)
const CardConfigContext = createContext<{ collapsible: boolean }>()
// Root component provides two Contexts
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 separate here, avoid Context change affecting child components */}
{collapsible && (
<button onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Expand" : "Collapse"}
</button>
)}
</Card>
</CardStateContext.Provider>
</CardConfigContext.Provider>
)
}
Header only reads CardConfigContext (doesn’t change, won’t re-render), Content only reads CardStateContext (only re-renders when collapsed).
TypeScript Type Safety
Compound Components type definition needs care, otherwise TypeScript will error “child component might not be in parent”.
// Complete type definition
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 } => {
// ...
}
}
This way when using, TypeScript will check if your props are correct:
// ✅ Correct
<CollapsibleCard.Root defaultCollapsed={true}>
<CollapsibleCard.Header title="Title" />
<CollapsibleCard.Content>Content</CollapsibleCard.Content>
</CollapsibleCard.Root>
// ❌ TypeScript error: title required
<CollapsibleCard.Header />
Summary: Composition Pattern Checklist
After all this talking, let’s summarize key principles:
Basic Compositions
- Dialog + Form: Dialog is container, Form is content, separate responsibilities
- DataTable + DropdownMenu: DropdownMenu triggers action, pass data through row.original
- Tabs + Form: Tabs navigation, TabsContent contains different forms
Advanced Techniques
- Context Pattern: Avoid prop drilling, child components automatically get state
- Hook manages state: Unified Dialog state management, avoid creating instance per row
- Form + Zod: Unified validation Schema, FormMessage automatically shows errors
Advanced Optimization
- Separate Context: Avoid frequently changing state causing all child components to re-render
- TypeScript types: Complete type definition, avoid wrong props passing
- Server/Client separation: Under Next.js App Router, data fetching in Server, UI in Client
Last advice: don’t directly modify shadcn/ui component files. Want to customize style, either create wrapper components, use variants, or customize through theme. Directly modifying source code will make later upgrades difficult.
Honestly, after learning shadcn/ui composition patterns, my code indeed became much cleaner. That user management page earlier, reduced from over 300 lines to under 150 lines, state management also became clear. Of course, Context pattern and performance optimization these advanced techniques, when first learning indeed a bit winding, write a few more times and you’ll get familiar.
Have you encountered similar composition difficulties? If so, try these patterns, should help you clarify thinking.
Implement DataTable + Dialog + Form Composition
Complete user management page implementation flow
⏱️ Estimated time: 45 min
- 1
Step1: Define Zod Schema
Define data structure validation outside component:
• Use z.object() to define fields
• Add validation rules (min, email, enum)
• export schema and type - 2
Step2: Create Dialog State Hook
Uniformly manage Dialog state:
• useState manages open and editingUser
• openDialog opens and passes data
• closeDialog closes and clears data - 3
Step3: Define DataTable Columns
Add action column in columns:
• Put DropdownMenu in cell
• onClick calls openDialog(row.original)
• Don't nest Dialog in cell - 4
Step4: Create Edit Form Component
Use shadcn Form component:
• useForm + zodResolver
• FormField + FormControl
• FormMessage automatically shows errors - 5
Step5: Combine Main Page
Combine all components:
• DataTable + global Dialog
• Put Form inside Dialog
• Update list data after submit
FAQ
Why shouldn't I nest Dialog in DataTable cell?
Should Form use React Hook Form or manual management?
• Automatic validation and error display
• Type safety (z.infer auto-derives)
• Better performance (reduces re-render)
• FormMessage automatically shows error messages
Does Context pattern cause performance issues?
• Separate Context (state Context + config Context)
• Only let components needing state response read state Context
• Unchanging config put in config Context
How to avoid prop drilling?
Can I directly modify shadcn/ui component source code?
• Create wrapper components
• Use variants to define variants
• Customize styles through theme
Directly modifying source code makes subsequent upgrades difficult.
How to define TypeScript types for Compound Components?
• Define Root/Header/Content Props types
• Use FC<Props> to constrain components
• Throw error when child not in Root
This way TypeScript checks if props are correct.
8 min read · Published on: Apr 1, 2026 · Modified on: Apr 1, 2026
Related Posts
Astro + Tailwind: Configuring Island Components and Global Styles Without Conflicts
Astro + Tailwind: Configuring Island Components and Global Styles Without Conflicts
React Compiler + shadcn/ui: Frontend Development in the Auto-Optimization Era
React Compiler + shadcn/ui: Frontend Development in the Auto-Optimization Era
Next.js App Router + shadcn/ui: A Guide to Mixing Server and Client Components

Comments
Sign in with GitHub to leave a comment