shadcn/ui Dialog Race Conditions — Stop Double Charges
Independent Dialog state caused double Stripe charges in 50ms.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- shadcn/ui is a copy-paste component library built on Radix UI primitives and Tailwind CSS
- Advanced patterns include compound components, controlled form state, and slot-based composition
- The cn() utility merges Tailwind classes without specificity conflicts — use it everywhere
- Theming works through CSS variables, not Tailwind config — override --primary, --background, etc.
- Production apps need data table patterns, dialog state management, and toast coordination
- Biggest mistake: treating shadcn/ui as a black-box dependency — it is your code, modify it
This article addresses a critical but often overlooked class of bugs in shadcn/ui Dialog components: race conditions that can cause double charges, duplicate form submissions, or inconsistent UI state. shadcn/ui is a collection of beautifully designed, accessible React components built on Radix UI primitives, but its Dialog component—like all modal systems—introduces asynchronous state management challenges. When users trigger actions (e.g., payment confirmations, form saves) inside a Dialog, rapid interactions, network latency, or state updates can lead to multiple simultaneous submissions.
This is especially dangerous in payment processing, where a single click might fire two API calls, charging a customer twice. The article covers five battle-tested patterns to prevent these issues: compound components with shared context to enforce single-action semantics, react-hook-form integration for controlled form state, TanStack Table for data-driven dialogs, orchestration patterns for managing multiple dialogs without state collisions, and toast coordination to avoid notification floods.
These patterns are essential for any production app using shadcn/ui where Dialog interactions involve side effects, particularly in e-commerce, SaaS billing, or any system where idempotency and user feedback matter. The techniques apply broadly to Radix UI, MUI, or any modal system, but the examples are specific to shadcn/ui's implementation, which uses React context and controlled state by default.
You'll learn not just how to fix double charges, but how to architect Dialog state so it's predictable, debuggable, and resilient under real-world user behavior.
shadcn/ui is not a traditional component library you install and forget. It copies source code into your project — you own every line. This means you can modify, extend, and compose components in ways that boxed libraries like Material UI or Chakra never allow. The advanced patterns in this article exploit that ownership: compound components that share state, forms that handle validation at the field level, and data tables that manage pagination, sorting, and filtering without external state managers.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
shadcn/ui changed how teams build component systems. Instead of installing a dependency and accepting its API, you copy production-grade React components into your codebase. Every component is yours to modify, extend, and compose.
Most tutorials cover installation and basic usage. This article covers the patterns that separate a prototype from a production application: compound component architecture, form state management with react-hook-form integration, data table patterns with TanStack Table, dialog orchestration, toast coordination, and theming strategies.
Each pattern includes a real-world use case, production code, and the failure scenario you will encounter if you get it wrong.
Why Dialog Race Conditions Are a Payment-Processing Bug
Advanced shadcn/ui patterns are composable, state-driven UI architectures that go beyond basic component usage. The core mechanic is managing asynchronous state transitions triggered by user interactions — like opening a dialog, awaiting a server response, and updating the UI — without race conditions. In practice, the key property is that shadcn/ui dialogs are controlled components: their open/close state lives in React state, not DOM attributes. This means a double-click on a 'Confirm Purchase' button can fire two API calls before the first response closes the dialog, leading to duplicate charges. The pattern to prevent this is a 'submitting' boolean that disables the button and ignores subsequent open requests until the current transaction resolves. Use this pattern whenever a dialog triggers a side effect (payment, delete, submit) that must execute exactly once. It matters because in production, a 200ms network variance between two rapid clicks is enough to create a double charge — and that's a P0 incident.
Pattern 1: Compound Components with Shared Context
shadcn/ui components are built on Radix UI primitives, which use compound component patterns internally. Understanding this pattern lets you build custom composite components that share state without prop drilling.
The compound component pattern exposes a context from a parent component and consumes it in child components. The parent manages state, the children render UI. This is how shadcn/ui's Form, Select, and Dialog components work — and it is the pattern you should use for your own composite components.
The key insight: shadcn/ui components are not black boxes. You own the source code. You can wrap, extend, and compose them using the same compound pattern they use internally.
Biggest gotcha: the cn() utility (used everywhere in the examples below). It lives in lib/utils.ts and is required for safe Tailwind class merging.
// ============================================ // Pattern 1: Compound Components with Shared Context // ============================================ // This pattern creates a parent component that manages state // and child components that consume that state via context. // shadcn/ui uses this pattern internally — you should too. 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' // ---- Step 1: Define the context ---- // The context holds shared state and actions interface PricingContextValue { selectedPlan: string | null setSelectedPlan: (plan: string) => void billingCycle: 'monthly' | 'annual' setBillingCycle: (cycle: 'monthly' | 'annual') => void isLoading: boolean } const PricingContext = React.createContext<PricingContextValue | null>(null) function usePricingContext() { const context = React.useContext(PricingContext) if (!context) { throw new Error('Pricing compound components must be used within <Pricing>') } return context } // ---- Step 2: Create the parent component ---- // The parent manages state and provides context interface PricingProps { defaultPlan?: string defaultCycle?: 'monthly' | 'annual' onPlanSelect?: (plan: string, cycle: 'monthly' | 'annual') => void children: React.ReactNode } function Pricing({ defaultPlan, defaultCycle = 'monthly', onPlanSelect, children, }: PricingProps) { const [selectedPlan, setSelectedPlan] = React.useState<string | null>( defaultPlan ?? null ) const [billingCycle, setBillingCycle] = React.useState<'monthly' | 'annual'>( defaultCycle ) const [isLoading, setIsLoading] = React.useState(false) const handlePlanSelect = React.useCallback( async (plan: string) => { setSelectedPlan(plan) setIsLoading(true) try { await onPlanSelect?.(plan, billingCycle) } finally { setIsLoading(false) } }, [billingCycle, onPlanSelect] ) const value = React.useMemo( () => ({ selectedPlan, setSelectedPlan: handlePlanSelect, billingCycle, setBillingCycle, isLoading, }), [selectedPlan, handlePlanSelect, billingCycle, isLoading] ) return ( <PricingContext.Provider value={value}> <div className="grid gap-6 md:grid-cols-3"> {children} </div> </PricingContext.Provider> ) } // ---- Step 3: Create child components ---- // Each child consumes the context to read/write shared state interface PricingCardProps { plan: string price: { monthly: number; annual: number } features: string[] popular?: boolean children?: React.ReactNode } function PricingCard({ plan, price, features, popular, children, }: PricingCardProps) { const { selectedPlan, setSelectedPlan, billingCycle, isLoading } = usePricingContext() const isSelected = selectedPlan === plan const currentPrice = billingCycle === 'monthly' ? price.monthly : price.annual return ( <Card className={cn( 'relative transition-all', isSelected && 'border-primary shadow-lg', popular && 'border-primary' )} > {popular && ( <Badge className="absolute -top-2 right-4"> Most Popular </Badge> )} <CardHeader> <CardTitle>{plan}</CardTitle> <CardDescription> <span className="text-3xl font-bold">\${currentPrice}</span> <span className="text-muted-foreground"> /{billingCycle === 'monthly' ? 'mo' : 'yr'} </span> </CardDescription> </CardHeader> <CardContent className="space-y-4"> <ul className="space-y-2"> {features.map((feature) => ( <li key={feature} className="flex items-center gap-2"> <span className="text-primary">✓</span> {feature} </li> ))} </ul> <Button className="w-full" variant={isSelected ? 'default' : 'outline'} onClick={() => setSelectedPlan(plan)} disabled={isLoading} > {isSelected ? 'Selected' : 'Select Plan'} </Button> {children} </CardContent> </Card> ) } // ---- Step 4: Create toggle sub-component ---- function BillingToggle() { const { billingCycle, setBillingCycle } = usePricingContext() return ( <div className="flex items-center justify-center gap-2"> <Button variant={billingCycle === 'monthly' ? 'default' : 'ghost'} size="sm" onClick={() => setBillingCycle('monthly')} > Monthly </Button> <Button variant={billingCycle === 'annual' ? 'default' : 'ghost'} size="sm" onClick={() => setBillingCycle('annual')} > Annual (Save 20%) </Button> </div> ) } // ---- Step 5: Compose the compound component ---- // Usage in a page — clean, declarative, no prop drilling // app/pricing/page.tsx export default function PricingPage() { return ( <div className="space-y-8"> <BillingToggle /> <Pricing onPlanSelect={async (plan, cycle) => { await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ plan, cycle }), }) }} > <PricingCard plan="Starter" price={{ monthly: 9, annual: 86 }} features={['5 projects', '10GB storage', 'Email support']} /> <PricingCard plan="Pro" price={{ monthly: 29, annual: 278 }} features={['Unlimited projects', '100GB storage', 'Priority support', 'API access']} popular /> <PricingCard plan="Enterprise" price={{ monthly: 99, annual: 950 }} features={['Unlimited everything', 'SSO', 'Dedicated support', 'SLA']} /> </Pricing> </div> ) }
- Parent component manages state and provides it via React Context
- Child components consume context — no prop drilling required
- Children can be reordered, conditionally rendered, or extended without changing the parent
- The pattern scales to any depth — nested compound components share context hierarchically
- shadcn/ui's Form, Select, and Dialog use this exact pattern internally
Pattern 2: Form State Management with react-hook-form
shadcn/ui's Form components wrap react-hook-form with Radix UI primitives. The integration is clean, but the pattern breaks down in complex forms with conditional fields, async validation, and multi-step wizards.
The key to production-grade forms is understanding the separation of concerns: FormField handles the field-level state, FormMessage handles error display, and react-hook-form handles validation and submission. Each layer has a specific responsibility — mixing them causes bugs.
The most common production mistake: using controlled state (useState) for form values instead of react-hook-form's register/setValue. This causes unnecessary re-renders on every keystroke and breaks the form's integration with shadcn/ui's FormMessage error display.
// ============================================ // Pattern 2: Advanced Form Patterns with shadcn/ui // ============================================ 'use client' import * as React from 'react' import { useForm, useFieldArray, useWatch } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import * as z from 'zod' import { cn } from '@/lib/utils' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' // ---- Schema: Define validation rules with Zod ---- // Zod schemas drive both runtime validation and TypeScript types const invoiceItemSchema = z.object({ description: z.string().min(1, 'Description is required'), quantity: z.coerce.number().min(1, 'Minimum quantity is 1'), unitPrice: z.coerce.number().min(0.01, 'Price must be positive'), taxable: z.boolean().default(false), }) const invoiceSchema = z.object({ clientName: z.string().min(2, 'Client name is required'), clientEmail: z.string().email('Invalid email address'), invoiceNumber: z.string().min(1, 'Invoice number is required'), dueDate: z.string().min(1, 'Due date is required'), items: z .array(invoiceItemSchema) .min(1, 'Add at least one line item'), notes: z.string().optional(), paymentTerms: z.enum(['net15', 'net30', 'net60', 'due_on_receipt']), }) type InvoiceFormValues = z.infer<typeof invoiceSchema> // ---- Pattern 2A: Dynamic Field Arrays ---- // Use useFieldArray for dynamic lists of form fields export function InvoiceForm() { const form = useForm<InvoiceFormValues>({ resolver: zodResolver(invoiceSchema), defaultValues: { clientName: '', clientEmail: '', invoiceNumber: `INV-${Date.now()}`, dueDate: '', items: [{ description: '', quantity: 1, unitPrice: 0, taxable: false }], notes: '', paymentTerms: 'net30', }, }) const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items', }) // Watch items for real-time total calculation const items = useWatch({ control: form.control, name: 'items' }) const subtotal = items.reduce((sum, item) => { return sum + (item.quantity || 0) * (item.unitPrice || 0) }, 0) const taxTotal = items.reduce((sum, item) => { if (!item.taxable) return sum return sum + (item.quantity || 0) * (item.unitPrice || 0) * 0.08 }, 0) const total = subtotal + taxTotal async function onSubmit(data: InvoiceFormValues) { const response = await fetch('/api/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!response.ok) { const error = await response.json() // Set server-side validation errors on specific fields if (error.fieldErrors) { Object.entries(error.fieldErrors).forEach(([field, message]) => { form.setError(field as keyof InvoiceFormValues, { type: 'server', message: message as string, }) }) } return } // Success — redirect or show toast } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> {/* ---- Client Info Section ---- */} <div className="grid gap-4 md:grid-cols-2"> <FormField control={form.control} name="clientName" render={({ field }) => ( <FormItem> <FormLabel>Client Name</FormLabel> <FormControl> <Input placeholder="Acme Corp" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="clientEmail" render={({ field }) => ( <FormItem> <FormLabel>Client Email</FormLabel> <FormControl> <Input placeholder="billing@acme.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> {/* ---- Dynamic Line Items ---- */} <div className="space-y-4"> <div className="flex items-center justify-between"> <FormLabel className="text-base">Line Items</FormLabel> <Button type="button" variant="outline" size="sm" onClick={() => append({ description: '', quantity: 1, unitPrice: 0, taxable: false, }) } > Add Item </Button> </div> {fields.map((field, index) => ( <div key={field.id} className="grid gap-4 rounded-lg border p-4 md:grid-cols-5" > <FormField control={form.control} name={`items.${index}.description`} render={({ field }) => ( <FormItem className="md:col-span-2"> <FormLabel className={cn(index !== 0 && 'sr-only')}> Description </FormLabel> <FormControl> <Input placeholder="Service description" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.quantity`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Qty </FormLabel> <FormControl> <Input type="number" min="1" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name={`items.${index}.unitPrice`} render={({ field }) => ( <FormItem> <FormLabel className={cn(index !== 0 && 'sr-only')}> Price </FormLabel> <FormControl> <Input type="number" step="0.01" min="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="flex items-end gap-2"> <FormField control={form.control} name={`items.${index}.taxable`} render={({ field }) => ( <FormItem className="flex items-center gap-2"> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel className="text-xs">Tax</FormLabel> </FormItem> )} /> {fields.length > 1 && ( <Button type="button" variant="ghost" size="sm" onClick={() => remove(index)} > Remove </Button> )} </div> </div> ))} {/* Totals — calculated from watched values */} <div className="flex flex-col items-end gap-1 text-sm"> <div className="flex w-48 justify-between"> <span>Subtotal</span> <span>\${subtotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between"> <span>Tax (8%)</span> <span>\${taxTotal.toFixed(2)}</span> </div> <div className="flex w-48 justify-between font-bold"> <span>Total</span> <span>\${total.toFixed(2)}</span> </div> </div> </div> <Button type="submit" disabled={form.formState.isSubmitting} className="w-full" > {form.formState.isSubmitting ? 'Creating...' : 'Create Invoice'} </Button> </form> </Form> ) }
- Never mix useState with react-hook-form — use form.setValue() instead of setState for form values
- FormMessage reads errors from FormField context, not from useForm() directly — always render it inside FormField
- Server-side validation errors must be set via form.setError() — they do not appear automatically
- useWatch triggers re-renders on every change — use it only for computed values like totals, not for every field
- Default values must match the schema shape exactly — missing fields cause uncontrolled/controlled warnings
- Server errors in onSubmit must be caught — uncaught errors crash the form. Always wrap in try/catch and call form.setError(). Check formState.isSubmitting — if true, the form is waiting for server response.
Pattern 3: Data Tables with TanStack Table
shadcn/ui provides a DataTable component built on TanStack Table. The basic example handles static data, but production applications need server-side pagination, column sorting, row selection, bulk actions, and filtered views.
The key architectural decision: client-side vs server-side data management. Client-side works for datasets under 10,000 rows. Server-side is required for larger datasets or when data comes from an API with pagination cursors.
The shadcn/ui DataTable example uses client-side sorting and filtering. Production applications almost always need server-side operations — this pattern shows how to adapt the DataTable for API-driven data.
// ============================================ // Pattern 3: Server-Side Data Table // ============================================ 'use client' import * as React from 'react' import { ColumnDef, ColumnFiltersState, SortingState, VisibilityState, flexRender, getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table' import { cn } from '@/lib/utils' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' // ---- Type definitions ---- interface User { id: string name: string email: string role: 'admin' | 'member' | 'viewer' status: 'active' | 'inactive' | 'suspended' createdAt: string } interface DataTableProps { data: User[] pageCount: number currentPage: number totalCount: number onPaginationChange: (page: number, pageSize: number) => void onSortingChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void onFilterChange: (filters: Record<string, string>) => void onBulkAction?: (action: string, selectedIds: string[]) => void } // ---- Column definitions with shadcn/ui components ---- const columns: ColumnDef<User>[] = [ { id: 'select', header: ({ table }) => ( <Checkbox checked={ table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate') } onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} aria-label="Select all" /> ), cell: ({ row }) => ( <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" /> ), enableSorting: false, enableHiding: false, }, { accessorKey: 'name', header: 'Name', cell: ({ row }) => ( <div className="font-medium">{row.getValue('name')}</div> ), }, { accessorKey: 'email', header: 'Email', cell: ({ row }) => ( <div className="text-muted-foreground">{row.getValue('email')}</div> ), }, { accessorKey: 'role', header: 'Role', cell: ({ row }) => { const role = row.getValue('role') as string return ( <Badge variant={ role === 'admin' ? 'default' : role === 'member' ? 'secondary' : 'outline' } > {role} </Badge> ) }, }, { accessorKey: 'status', header: 'Status', cell: ({ row }) => { const status = row.getValue('status') as string return ( <div className="flex items-center gap-2"> <span className={cn( 'h-2 w-2 rounded-full', status === 'active' && 'bg-green-500', status === 'inactive' && 'bg-gray-400', status === 'suspended' && 'bg-red-500' )} /> <span className="capitalize">{status}</span> </div> ) }, }, { accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => { const date = new Date(row.getValue('createdAt')) return ( <time dateTime={date.toISOString()}> {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} </time> ) }, }, ] // ---- Server-side Data Table Component ---- export function UserDataTable({ data, pageCount, currentPage, totalCount, onPaginationChange, onSortingChange, onFilterChange, onBulkAction, }: DataTableProps) { const [rowSelection, setRowSelection] = React.useState({}) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) const [sorting, setSorting] = React.useState<SortingState>([]) // Sync sorting state to server React.useEffect(() => { if (sorting.length > 0) { onSortingChange(sorting[0].id, sorting[0].desc ? 'desc' : 'asc') } }, [sorting, onSortingChange]) // Sync filter state to server (debounced) React.useEffect(() => { const timer = setTimeout(() => { const filters: Record<string, string> = {} columnFilters.forEach((filter) => { filters[filter.id] = filter.value as string }) onFilterChange(filters) }, 300) return () => clearTimeout(timer) }, [columnFilters, onFilterChange]) const table = useReactTable({ data, columns, pageCount, state: { sorting, columnVisibility, rowSelection, columnFilters, pagination: { pageIndex: currentPage - 1, pageSize: 20 }, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), manualPagination: true, manualSorting: true, manualFiltering: true, }) const selectedRows = table.getFilteredSelectedRowModel().rows return ( <div className="space-y-4"> {/* Toolbar */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <Input placeholder="Filter by name..." value={(table.getColumn('name')?.getFilterValue() as string) ?? ''} onChange={(e) => table.getColumn('name')?.setFilterValue(e.target.value) } className="max-w-sm" /> {selectedRows.length > 0 && onBulkAction && ( <div className="flex items-center gap-2"> <span className="text-sm text-muted-foreground"> {selectedRows.length} selected </span> <Button variant="outline" size="sm" onClick={() => onBulkAction( 'deactivate', selectedRows.map((r) => r.original.id) ) } > Deactivate Selected </Button> </div> )} </div> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> Columns </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((col) => col.getCanHide()) .map((column) => ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value) } > {column.id} </DropdownMenuCheckboxItem> ))} </DropdownMenuContent> </DropdownMenu> </div> {/* Table */} <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext() )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center" > No results. </TableCell> </TableRow> )} </TableBody> </Table> </div> {/* Pagination */} <div className="flex items-center justify-between"> <div className="text-sm text-muted-foreground"> {selectedRows.length} of {totalCount} row(s) selected. </div> <div className="flex items-center gap-2"> <Button variant="outline" size="sm" onClick={() => onPaginationChange(currentPage - 1, 20)} disabled={currentPage <= 1} > Previous </Button> <span className="text-sm"> Page {currentPage} of {pageCount} </span> <Button variant="outline" size="sm" onClick={() => onPaginationChange(currentPage + 1, 20)} disabled={currentPage >= pageCount} > Next </Button> </div> </div> </div> ) }
- Client-side: sorting, filtering, pagination happen in the browser — fine for under 10,000 rows
- Server-side: set manualPagination, manualSorting, manualFiltering to true — the table does not process data
- Debounce filter inputs by 300ms before sending to the server — prevents excessive API calls
- Use getFacetedUniqueValues() for column filter dropdowns — it shows only values present in the current data
- Row selection works the same in both modes — use getFilteredSelectedRowModel() for selected rows
Pattern 4: Dialog Orchestration and State Management
shadcn/ui's Dialog component is a controlled component — you manage the open state. In production applications, dialogs are rarely isolated. They chain (confirm before delete), stack (settings (close dialog inside a modal), and coordinate A when dialog B opens).
The naive approach — independent useState for each dialog — breaks down quickly. Opening dialog A while dialog B is open creates z-index conflicts. Chaining dialogs requires callback coordination. The solution is a centralized dialog manager.
// ============================================ // Pattern 4: Centralized Dialog Manager // ============================================ 'use client' import * as React from 'react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' // ---- Dialog state types ---- type DialogType = 'confirm-delete' | 'edit-user' | 'create-item' | null interface DialogState { type: DialogType data: Record<string, unknown> | null resolve: ((value: boolean) => void) | null } interface DialogContextValue { open: (type: DialogType, data?: Record<string, unknown>) => Promise<boolean> close: () => void state: DialogState } const DialogContext = React.createContext<DialogContextValue | null>(null) export function useDialog() { const context = React.useContext(DialogContext) if (!context) { throw new Error('useDialog must be used within <DialogProvider>') } return context } // ---- Dialog Provider ---- // Manages a single dialog at a time — prevents stacking issues export function DialogProvider({ children }: { children: React.ReactNode }) { const [state, setState] = React.useState<DialogState>({ type: null, data: null, resolve: null, }) const open = React.useCallback( (type: DialogType, data?: Record<string, unknown>) => { return new Promise<boolean>((resolve) => { setState({ type, data: data ?? null, resolve }) }) }, [] ) const close = React.useCallback(() => { if (state.resolve) { state.resolve(false) } setState({ type: null, data: null, resolve: null }) }, [state.resolve]) const confirm = React.useCallback(() => { if (state.resolve) { state.resolve(true) } setState({ type: null, data: null, resolve: null }) }, [state.resolve]) const value = React.useMemo( () => ({ open, close, state }), [open, close, state] ) return ( <DialogContext.Provider value={value}> {children} {/* Confirm Delete Dialog */} <Dialog open={state.type === 'confirm-delete'} onOpenChange={(isOpen) => !isOpen && close()} > <DialogContent> <DialogHeader> <DialogTitle>Confirm Deletion</DialogTitle> <DialogDescription> Are you sure you want to delete{' '} <strong>{(state.data?.name as string) ?? 'this item'}</strong>? This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={close}> Cancel </Button> <Button variant="destructive" onClick={confirm}> Delete </Button> </DialogFooter> </DialogContent> </Dialog> {/* Edit User Dialog */} <Dialog open={state.type === 'edit-user'} onOpenChange={(isOpen) => !isOpen && close()} > <DialogContent> <DialogHeader> <DialogTitle>Edit User</DialogTitle> <DialogDescription> Update details for {state.data?.name as string} </DialogDescription> </DialogHeader> {/* Edit form would go here */} <DialogFooter> <Button variant="outline" onClick={close}> Cancel </Button> <Button onClick={confirm}>Save Changes</Button> </DialogFooter> </DialogContent> </Dialog> </DialogContext.Provider> ) } // ---- Usage: Promise-based dialog interaction ---- // Components await the dialog result — clean async flow export function UserActions({ user }: { user: { id: string; name: string } }) { const dialog = useDialog() async function handleDelete() { // Open dialog and wait for user response const confirmed = await dialog.open('confirm-delete', { name: user.name, id: user.id, }) if (confirmed) { await fetch(`/api/users/${user.id}`, { method: 'DELETE' }) } } async function handleEdit() { const saved = await dialog.open('edit-user', { name: user.name, id: user.id, }) if (saved) { // Refresh data } } return ( <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={handleEdit}> Edit </Button> <Button variant="destructive" size="sm" onClick={handleDelete}> Delete </Button> </div> ) }
- dialog.open() returns a Promise that resolves to true (confirmed) or false (cancelled)
- The calling code awaits the result — no callbacks, no state management in the consumer
- Only one dialog can be open at a time — the provider enforces this constraint
- Dialog data is passed as a parameter — the dialog reads it from context, not from props
- This pattern works for any modal interaction: confirmations, forms, pickers, wizards
dialog.open() returns true or false.Pattern 5: Toast Coordination and Notification Queuing
shadcn/ui's Toast component uses the sonner library under the hood. The basic usage is straightforward — call toast() and a notification appears. Production applications need more: deduplication (prevent the same toast from appearing twice), prioritization (error toasts stay longer than success toasts), and coordination (dismiss loading toast when success toast appears).
The key pattern: wrap sonner's toast() in a service layer that handles deduplication, prioritization, and lifecycle management. This prevents toast spam and ensures users see the most important notifications.
// ============================================ // Pattern 5: Toast Service with Deduplication // ============================================ import { toast as sonnerToast } from 'sonner' // ---- Toast configuration ---- interface ToastConfig { id?: string duration?: number dismissible?: boolean } const TOAST_DURATIONS = { success: 4000, error: 8000, warning: 6000, info: 4000, loading: Infinity, } as const // ---- Active toast tracking ---- const activeToasts = new Map<string, string | number>() function deduplicationKey(message: string): string { return message.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50) } // ---- Toast service ---- export const toast = { success(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) // Dismiss existing toast with same key if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.success(message, { duration: config?.duration ?? TOAST_DURATIONS.success, dismissible: config?.dismissible ?? true, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, error(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.error(message, { duration: config?.duration ?? TOAST_DURATIONS.error, dismissible: config?.dismissible ?? true, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, loading(message: string, config?: ToastConfig) { const key = config?.id ?? deduplicationKey(message) if (activeToasts.has(key)) { sonnerToast.dismiss(activeToasts.get(key)) } const id = sonnerToast.loading(message, { duration: config?.duration ?? TOAST_DURATIONS.loading, id: key + '-' + Date.now(), // Unique per instance onDismiss: () => activeToasts.delete(key), }) activeToasts.set(key, id) return id }, // Dismiss loading toast and show result resolve( loadingId: string | number, type: 'success' | 'error', message: string ) { sonnerToast.dismiss(loadingId) // Find and remove from active tracking for (const [key, id] of activeToasts.entries()) { if (id === loadingId) { activeToasts.delete(key) break } } // Show result toast if (type === 'success') { return this.success(message) } else { return this.error(message) } }, dismissAll() { sonnerToast.dismiss() activeToasts.clear() }, } // ---- Usage with async operations ---- export async function withToast<T>( promise: Promise<T>, messages: { loading: string success: string error: string } ): Promise<T> { const loadingId = toast.loading(messages.loading) try { const result = await promise toast.resolve(loadingId, 'success', messages.success) return result } catch (error) { const errorMessage = error instanceof Error ? error.message : messages.error toast.resolve(loadingId, 'error', errorMessage) throw error } } // ---- Usage example ---- async function deleteUser(userId: string) { await withToast( fetch(`/api/users/${userId}`, { method: 'DELETE' }), { loading: 'Deleting user...', success: 'User deleted successfully', error: 'Failed to delete user', } ) }
- Deduplicate toasts by message content — the same error should not stack 5 times
- Loading toasts must be dismissed when the operation completes — use
resolve()to swap loading for success/error - Error toasts should stay longer (8s) than success toasts (4s) — users need time to read error messages
- Dismiss all toasts on route changes — stale toasts from previous pages confuse users
- Use toast IDs to track and dismiss specific toasts — not just dismissAll()
toast() in a service layer that handles deduplication and lifecycle.toast() in a service layer — handle deduplication, prioritization, and lifecycle.Pattern 6: Theming with CSS Variables
shadcn/ui uses CSS variables for theming — not Tailwind's color config. This is a deliberate design decision that enables runtime theme switching without rebuilding CSS. Understanding this mechanism is essential for building white-label applications, dark mode, and custom brand themes.
The CSS variables are defined in globals.css under :root (light) and .dark (dark) selectors. Each shadcn/ui component references these variables via Tailwind utilities like bg-background, text-foreground, border-border. Changing a CSS variable changes every component that uses it.
// ============================================ // Pattern 6: Runtime Theme Switching // ============================================ 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' // ---- Theme types ---- type Theme = 'light' | 'dark' | 'system' type BrandTheme = 'default' | 'ocean' | 'forest' | 'sunset' interface ThemeContextValue { theme: Theme setTheme: (theme: Theme) => void brandTheme: BrandTheme setBrandTheme: (brand: BrandTheme) => void } const ThemeContext = React.createContext<ThemeContextValue | null>(null) export function useTheme() { const context = React.useContext(ThemeContext) if (!context) { throw new Error('useTheme must be used within <ThemeProvider>') } return context } // ---- Theme Provider ---- export function ThemeProvider({ children, defaultTheme = 'system', defaultBrand = 'default', }: { children: React.ReactNode defaultTheme?: Theme defaultBrand?: BrandTheme }) { const [theme, setThemeState] = React.useState<Theme>(defaultTheme) const [brandTheme, setBrandThemeState] = React.useState<BrandTheme>(defaultBrand) const [mounted, setMounted] = React.useState(false) const setTheme = React.useCallback((newTheme: Theme) => { setThemeState(newTheme) const root = document.documentElement root.classList.remove('light', 'dark') if (newTheme === 'system') { const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') .matches ? 'dark' : 'light' root.classList.add(systemTheme) } else { root.classList.add(newTheme) } localStorage.setItem('theme', newTheme) }, []) const setBrandTheme = React.useCallback((newBrand: BrandTheme) => { setBrandThemeState(newBrand) const root = document.documentElement root.classList.remove( 'theme-brand-ocean', 'theme-brand-forest', 'theme-brand-sunset' ) if (newBrand !== 'default') { root.classList.add(`theme-brand-${newBrand}`) } localStorage.setItem('brand-theme', newBrand) }, []) // Initialize from localStorage React.useEffect(() => { setMounted(true) const savedTheme = localStorage.getItem('theme') as Theme | null const savedBrand = localStorage.getItem('brand-theme') as BrandTheme | null if (savedTheme) setTheme(savedTheme) if (savedBrand) setBrandTheme(savedBrand) }, [setTheme, setBrandTheme]) // Listen for system theme changes React.useEffect(() => { if (theme !== 'system') return const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const handler = (e: MediaQueryListEvent) => { document.documentElement.classList.remove('light', 'dark') document.documentElement.classList.add(e.matches ? 'dark' : 'light') } mediaQuery.addEventListener('change', handler) return () => mediaQuery.removeEventListener('change', handler) }, [theme]) const value = React.useMemo( () => ({ theme, setTheme, brandTheme, setBrandTheme }), [theme, setTheme, brandTheme, setBrandTheme] ) return ( <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> ) } // ---- Theme Switcher Component ---- export function ThemeSwitcher() { const { theme, setTheme, brandTheme, setBrandTheme } = useTheme() const [mounted, setMounted] = React.useState(false) React.useEffect(() => { setMounted(true) }, []) if (!mounted) { return null // Skip SSR to prevent hydration mismatch } return ( <div className="flex items-center gap-2"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> {theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'System'} </Button> </DropdownMenuTrigger> <DropdownMenuContent> <DropdownMenuItem onClick={() => setTheme('light')}> Light </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}> Dark </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}> System </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm"> Brand: {brandTheme} </Button> </DropdownMenuTrigger> <DropdownMenuContent> <DropdownMenuItem onClick={() => setBrandTheme('default')}> Default </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('ocean')}> Ocean </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('forest')}> Forest </DropdownMenuItem> <DropdownMenuItem onClick={() => setBrandTheme('sunset')}> Sunset </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> ) }
- shadcn/ui components reference CSS variables (bg-background, text-foreground) — not hardcoded colors
- Theme switching is a class toggle on the root element — no rebuild required
- Brand themes are additional CSS variable overrides applied via a parent class — scoped to a container
- The HSL format (hue saturation lightness) without
hsl()wrapper is intentional — Tailwind wraps it automatically - Runtime theme switching works because CSS variables are resolved at paint time, not at build time
Pattern 7: Slot-Based Component Composition
The slot pattern lets consumers inject custom content into predefined positions of a component. Unlike children (which is a single slot), named slots allow multiple injection points. This pattern is essential for building flexible layouts, card templates, and reusable page structures.
shadcn/ui's Card component uses a simple version of this pattern — CardHeader, CardContent, and CardFooter are implicit slots. The advanced version uses render props and explicit slot props for maximum flexibility.
// ============================================ // Pattern 7: Slot-Based Component Composition // ============================================ 'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Card, CardContent, CardFooter } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' // ---- Slot-based Entity Card ---- // A reusable card with named slots for maximum flexibility (extend as needed) interface EntityCardProps { className?: string // Named slots — each is optional (simplified to 3 core slots) header?: React.ReactNode content?: React.ReactNode footer?: React.ReactNode // Interaction onCardClick?: () => void } function EntityCard({ className, header, content, footer, onCardClick, }: EntityCardProps) { return ( <Card className={cn( 'overflow-hidden transition-shadow hover:shadow-md', onCardClick && 'cursor-pointer', className )} onClick={onCardClick} > <div className="p-4"> {/* Header slot — title, badges, avatar */} {header && <div className="mb-3">{header}</div>} {/* Content slot — description, body text */} {content && <div className="mb-3">{content}</div>} </div> {/* Footer slot — additional info, secondary actions */} {footer && <CardFooter className="border-t bg-muted/50 px-4 py-3"> {footer} </CardFooter>} </Card> ) } // ---- Usage: Same card, completely different content ---- // Product Card function ProductCard({ product }: { product: any }) { return ( <EntityCard header={ <div className="flex items-center justify-between"> <h3 className="font-semibold">{product.name}</h3> <Badge>{product.category}</Badge> </div> } content={ <> <p className="text-sm text-muted-foreground"> {product.description} </p> <span className="text-lg font-bold">\${product.price}</span> <span className="text-sm text-muted-foreground"> {product.stock} in stock </span> </> } footer={ <> <Button size="sm">Add to Cart</Button> <Button size="sm" variant="outline">Details</Button> </> } /> ) } // Team Member Card function TeamMemberCard({ member }: { member: any }) { return ( <EntityCard header={ <div className="flex items-center gap-3"> <Avatar> <AvatarImage src={member.avatar} /> <AvatarFallback>{member.name[0]}</AvatarFallback> </Avatar> <div> <h3 className="font-semibold">{member.name}</h3> <p className="text-sm text-muted-foreground">{member.role}</p> </div> </div> } content={ <p className="text-sm text-muted-foreground"> {member.bio} </p> } footer={ <div className="flex w-full justify-between text-sm text-muted-foreground"> <span>{member.projects} projects</span> <span>Joined {member.joinedDate}</span> </div> } /> ) }
- Use children when there is one injection point — the component wraps the content
- Use named slots when there are multiple injection points — header, content, footer, actions
- Render props (functions as children) when the parent needs to control rendering logic
- Slot components should have sensible defaults — the card works even with no slots provided
- Type each slot as React.ReactNode — this accepts strings, elements, fragments, and null
Pattern 8: Accessible Command Palette
shadcn/ui's Command component wraps cmdk — a fast, accessible command palette. The basic usage shows a search box with static options. Production applications need dynamic search, grouped results, keyboard navigation, and integration with application state.
The command palette is the power-user interface for navigation, actions, and search. Building it well requires understanding cmdk's API, React's useDeferredValue for search debouncing, and keyboard accessibility patterns.
// ============================================ // Pattern 8: Production Command Palette // ============================================ 'use client' import * as React from 'react' import { useRouter } from 'next/navigation' import { cn } from '@/lib/utils' import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import { Badge } from '@/components/ui/badge' // ---- Types ---- interface CommandAction { id: string label: string description?: string icon?: React.ReactNode shortcut?: string group: string action: () => void | Promise<void> } // ---- Global keyboard shortcut hook ---- function useCommandPalette() { const [open, setOpen] = React.useState(false) React.useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen((prev) => !prev) } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, []) return { open, setOpen } } // ---- Command Palette Component ---- export function CommandPalette() { const router = useRouter() const { open, setOpen } = useCommandPalette() const [search, setSearch] = React.useState('') const [debouncedSearch, setDebouncedSearch] = React.useState('') const [pages, setPages] = React.useState<string[]>([]) const [results, setResults] = React.useState<CommandAction[]>([]) const [isLoading, setIsLoading] = React.useState(false) // Proper debounce for search — prevents excessive API calls React.useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(search) }, 300) return () => clearTimeout(timer) }, [search]) // Search with debounced value React.useEffect(() => { if (!debouncedSearch || debouncedSearch.length < 2) { setResults([]) return } const controller = new AbortController() setIsLoading(true) fetch(`/api/search?q=${encodeURIComponent(debouncedSearch)}`, { signal: controller.signal, }) .then((res) => res.json()) .then((data) => { const actions: CommandAction[] = data.results.map((r: any) => ({ id: r.id, label: r.title, description: r.subtitle, group: r.type, action: () => { router.push(r.url) setOpen(false) }, })) setResults(actions) }) .catch((err) => { if (err.name !== 'AbortError') { console.error('Search failed:', err) } }) .finally(() => setIsLoading(false)) return () => controller.abort() }, [debouncedSearch, router, setOpen]) // Static navigation actions const navigationActions: CommandAction[] = React.useMemo( () => [ { id: 'nav-dashboard', label: 'Go to Dashboard', group: 'Navigation', action: () => { router.push('/dashboard'); setOpen(false) }, }, { id: 'nav-settings', label: 'Go to Settings', group: 'Navigation', action: () => { router.push('/settings'); setOpen(false) }, }, { id: 'nav-billing', label: 'Go to Billing', group: 'Navigation', action: () => { router.push('/billing'); setOpen(false) }, }, ], [router, setOpen] ) // Quick actions const quickActions: CommandAction[] = React.useMemo( () => [ { id: 'action-new-project', label: 'Create New Project', shortcut: '⌘N', group: 'Actions', action: () => { router.push('/projects/new'); setOpen(false) }, }, { id: 'action-invite', label: 'Invite Team Member', shortcut: '⌘I', group: 'Actions', action: () => { router.push('/team/invite'); setOpen(false) }, }, ], [router, setOpen] ) // Combine and filter actions const allActions = [...navigationActions, ...quickActions, ...results] const filteredActions = debouncedSearch ? allActions.filter( (action) => action.label.toLowerCase().includes(debouncedSearch.toLowerCase()) || action.description?.toLowerCase().includes(debouncedSearch.toLowerCase()) ) : allActions // Group actions const groupedActions = filteredActions.reduce((acc, action) => { if (!acc[action.group]) acc[action.group] = [] acc[action.group].push(action) return acc }, {} as Record<string, CommandAction[]>) return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Search or type a command..." value={search} onValueChange={setSearch} /> <CommandList> <CommandEmpty> {isLoading ? 'Searching...' : 'No results found.'} </CommandEmpty> {Object.entries(groupedActions).map(([group, actions]) => ( <React.Fragment key={group}> <CommandGroup heading={group}> {actions.map((action) => ( <CommandItem key={action.id} onSelect={() => action.action()} > {action.icon && ( <span className="mr-2">{action.icon}</span> )} <div className="flex flex-1 items-center justify-between"> <div> <div>{action.label}</div> {action.description && ( <div className="text-xs text-muted-foreground"> {action.description} </div> )} </div> {action.shortcut && ( <Badge variant="outline" className="text-xs"> {action.shortcut} </Badge> )} </div> </CommandItem> ))} </CommandGroup> <CommandSeparator /> </React.Fragment> ))} </CommandList> </CommandDialog> ) }
- useDeferredValue debounces search input — React batches updates and the API call uses the deferred value
- AbortController cancels in-flight requests when the user types more — prevents race conditions
- Static actions (navigation, quick actions) are always available — dynamic results supplement them
- Grouping by type (Navigation, Actions, Search Results) helps users find what they need faster
- The Cmd+K shortcut is a convention — users expect it in every modern web application
Why Shadcn Exists: The 'No Abstraction' UI Bet
Most UI libraries ship as opaque black boxes. You import a <Button>, you get a <Button>. Want to change the border-radius? You either fork the library or pray they expose a prop. Shadcn took the opposite bet: ship raw, copy-paste code that you own entirely.
This matters because the moment you hit a production edge case—a modal that traps focus incorrectly, a table that needs virtual scrolling for 10k rows—you can't wait for a library maintainer to fix it. With Shadcn, the code is yours. You edit the component file directly. No wrapper hell, no abstractions that break on upgrade.styled()
The trade-off is obvious: you maintain the code now. But for any team shipping to real users, that's not a cost—it's the only sane choice. Libraries that abstract away the DOM inevitably abstract away your ability to debug a production crash at 3 AM.
// io.thecodeforge — javascript tutorial // shadcn button vs. traditional library button // Traditional: you have zero control over the rendered DOM import { Button } from '@some-ui-lib'; <Button variant="primary">Submit</Button> // Output: <button class="some-ui__button--primary-abc123">Submit</button> // Shadcn: you own every pixel // components/ui/button.tsx import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'default' | 'destructive' | 'outline'; size?: 'default' | 'sm' | 'lg'; asChild?: boolean; } export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant = 'default', size = 'default', asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( <Comp className={`inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 ${className}`} ref={ref} {...props} /> ); } );
@/components/ui/button.tsx, you've recreated the monolith problem. Pick one source of truth. Version your components like any other dependency.The Radix Dependencies You Can't Skip
Shadcn components wrap Radix UI primitives. That's not a footnote—it's the architecture. When you add a Dialog, Shadcn pulls in @radix-ui/react-dialog. When you add a DropdownMenu, it's @radix-ui/react-dropdown-menu. These aren't optional dependencies you can swap out. They are the reason the components handle focus trapping, keyboard navigation, and screen reader announcements correctly.
Here's where devs go wrong: they copy a Shadcn component, strip the Radix import, and re-implement the behavior themselves. Two weeks later, they're debugging why Escape key doesn't close the modal on iOS VoiceOver. Radix has already solved that. Use their primitives.
The real power is that Radix handles accessibility states (open/closed, pressed, expanded) through its own state machines. You just provide the styling and layout. That separation lets you swap tailwind classes without breaking ARIA attributes. Don't fight the abstraction—that's the part worth keeping.
// io.thecodeforge — javascript tutorial // Bad: re-implementing focus trap manually const MyDialog = () => { const [open, setOpen] = React.useState(false); // ... 80 lines of focus management, event listeners, onEscape handlers }; // Good: let Radix handle it import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; <Dialog> <DialogTrigger>Open</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Confirm Payment</DialogTitle> <DialogDescription> This action will charge $299.99 to your card ending in 4242. </DialogDescription> </DialogHeader> {/* Your form here */} </DialogContent> </Dialog>
npx shadcn-ui@latest add dialog instead of hand-copying. The CLI resolves the correct Radix version for your React version. Version mismatches between Radix packages are the #1 cause of 'component renders but doesn't work' bugs.shadcn/ui Dialog State Race Condition Crashed the Checkout Flow
- Independent Dialog state allows multiple dialogs to open simultaneously — use a centralized dialog manager
- Form submission must be locked while a request is in flight — use useRef for the lock, not useState (state updates are async)
- Server-side idempotency keys are the last line of defense against duplicate submissions
- Test rapid-click scenarios — automated tests that click once miss race conditions
ls components/ui/ | grep -i 'button\|dialog\|form'cat tailwind.config.ts | grep -A5 contentgrep -n ':root' app/globals.cssgrep -c '--primary\|--background\|--foreground' app/globals.cssnpm ls @radix-ui/react-dialog @radix-ui/react-selectcat node_modules/@radix-ui/react-dialog/package.json | grep versiongrep -rn 'useState\|useEffect\|window\.' components/ui/ --include='*.tsx'grep -rn '"use client"' components/ui/ --include='*.tsx'| Aspect | shadcn/ui | Material UI | Chakra UI | Ant Design |
|---|---|---|---|---|
| Ownership | You own the code — copy into your project | Dependency — installed via npm | Dependency — installed via npm | Dependency — installed via npm |
| Customization | Modify source directly — no overrides needed | Theme overrides and sx prop | Theme overrides and style props | ConfigProvider and theme tokens |
| Bundle size | Only components you add — no tree-shaking needed | Full library installed, tree-shaken at build | Full library installed, tree-shaken at build | Full library installed, tree-shaken at build |
| Upgrade path | Manual — you diff and merge changes | npm update — automatic | npm update — automatic | npm update — automatic |
| Radix UI primitives | Yes — built on Radix | No — custom implementation | No — custom implementation | No — custom implementation |
| Tailwind CSS | Required — all styles via Tailwind | Not used — Emotion/CSS-in-JS | Not used — Emotion/CSS-in-JS | Not used — Less/CSS-in-JS |
| TypeScript | Full support — generated types | Full support | Full support | Full support |
| Accessibility | Radix handles ARIA — strong foundation | Built-in — strong | Built-in — strong | Built-in — moderate |
| Learning curve | Low — standard React patterns | Medium — MUI-specific APIs | Medium — Chakra-specific APIs | Medium — Ant-specific APIs |
| Best for | Teams that want full control | Teams that want a complete system | Teams that want flexibility + opinions | Enterprise teams with design systems |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.shadcn.compound-components.tsx | 'use client' | Pattern 1 |
| io.thecodeforge.shadcn.form-patterns.tsx | 'use client' | Pattern 2 |
| io.thecodeforge.shadcn.data-table.tsx | 'use client' | Pattern 3 |
| io.thecodeforge.shadcn.dialog-manager.tsx | 'use client' | Pattern 4 |
| io.thecodeforge.shadcn.toast-service.ts | interface ToastConfig { | Pattern 5 |
| io.thecodeforge.shadcn.theme-switcher.tsx | 'use client' | Pattern 6 |
| io.thecodeforge.shadcn.slot-composition.tsx | 'use client' | Pattern 7 |
| io.thecodeforge.shadcn.command-palette.tsx | 'use client' | Pattern 8 |
| OwnedComponent.js | Why Shadcn Exists | |
| RadixDependency.js | const MyDialog = () => { | The Radix Dependencies You Can't Skip |
Key takeaways
Common mistakes to avoid
6 patternsTreating shadcn/ui as a black-box dependency
Not using the cn() utility for class merging
cn() from @/lib/utils to merge classes. It uses clsx and tailwind-merge to handle conflicts.
// WRONG: <Button className={bg-red-500 ${className}}>
// RIGHT: <Button className={cn('bg-red-500', className)}>Using controlled state (useState) instead of react-hook-form for forms
Independent Dialog state for multiple dialogs
Hardcoding colors instead of using CSS variables
Client-side data table for large datasets
Interview Questions on This Topic
How does shadcn/ui differ from traditional component libraries like Material UI? What are the trade-offs?
A form built with shadcn/ui and react-hook-form is not displaying validation errors. The form submits successfully but errors from the server are not shown to the user. How would you debug this?
What is the cn() utility in shadcn/ui and why is it necessary?
cn() utility is essential for shadcn/ui because components accept a className prop that merges with default classes. Without cn(), custom classes would conflict with component defaults. With cn(), the merge is predictable and the consumer's classes take precedence.How would you build a white-label application using shadcn/ui's theming system?
Frequently Asked Questions
It lives in lib/utils.ts and is the single source of truth for safe Tailwind class merging.
// lib/utils.ts import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
Without cn(), Tailwind class conflicts are resolved unpredictably by CSS specificity. Always use cn() when extending shadcn/ui components.
Yes. shadcn/ui is built on Radix UI primitives, which are production-grade and used by companies like Vercel, Linear, and GitHub. The components follow WAI-ARIA patterns for accessibility. The copy-paste model means you own the code and can fix issues immediately without waiting for a dependency update.
There is no automatic update mechanism — this is by design. You must manually compare your component files with the latest shadcn/ui source and merge changes. The npx shadcn-ui@latest diff command shows what changed. Some teams use git diff tools to track upstream changes.
No. shadcn/ui components are styled entirely with Tailwind CSS utilities. The cn() utility depends on tailwind-merge. If your project uses a different CSS solution, shadcn/ui is not compatible without significant modification.
shadcn/ui builds on Radix UI primitives, which implement WAI-ARIA patterns for keyboard navigation, screen reader support, and focus management. The accessibility is handled at the primitive level — shadcn/ui adds visual styling on top. You get accessible components by default without additional configuration.
Radix UI provides unstyled, accessible primitives — they handle behavior and accessibility but have no visual styling. shadcn/ui adds Tailwind CSS styling on top of Radix UI primitives and packages them as copy-paste components. You could use Radix UI directly and style it yourself, but shadcn/ui provides a well-designed starting point.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
4 min read · try the examples if you haven't