Mid-level 6 min · April 11, 2026

shadcn/ui Dialog Race Conditions — Stop Double Charges

Independent Dialog state caused double Stripe charges in 50ms.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is shadcn/ui Dialog Race Conditions?

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.

shadcn/ui is not a traditional component library you install and forget.

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.

Plain-English First

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.

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.

Don't Trust Button Disabling Alone
Disabling the button visually doesn't prevent the dialog's onOpenChange from firing again if the user clicks the overlay or presses Escape.
Production Insight
A SaaS checkout flow where the 'Confirm' button opens a Stripe dialog; user double-clicks due to network lag → two payment intents created.
Symptom: user sees 'Payment successful' twice, gets charged twice, support ticket spikes.
Rule: always gate the dialog's open state behind a 'processing' flag that is set before the async call and cleared only after the response (success or error).
Key Takeaway
Treat dialog open/close as a critical section — guard it with a mutex-like boolean.
Never rely on UI-only disabling; the dialog's state machine can fire events faster than your API responds.
Always reset the processing flag in a finally block, not just in success/catch branches.

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.

io.thecodeforge.shadcn.compound-components.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// ============================================
// 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">&#10003;</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>
  )
}
Compound Components as a State Distribution Pattern
  • 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
Production Insight
Compound components eliminate prop drilling but create implicit dependencies through context.
If a child renders outside the parent, it throws a runtime error — not a compile-time error.
Rule: always throw a descriptive error in useXxxContext() when context is null.
Key Takeaway
Compound components decouple state from rendering — parent owns state, children consume via context.
The pattern eliminates prop drilling and enables flexible component composition.
Always throw descriptive errors when context is missing — catch misuse at runtime.

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.

io.thecodeforge.shadcn.form-patterns.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
// ============================================
// 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>
  )
}
Form State Anti-Patterns
  • 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.
Production Insight
react-hook-form reduces re-renders by 90% compared to useState-based forms.
But useWatch triggers re-renders on every change for watched fields.
Rule: watch only fields that drive computed values — let react-hook-form handle the rest.
Key Takeaway
shadcn/ui Form wraps react-hook-form — Field handles state, FormMessage handles errors.
Zod schemas drive both validation and TypeScript types — single source of truth.
Server-side errors must be explicitly set via form.setError() — they do not appear automatically.

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.

io.thecodeforge.shadcn.data-table.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
// ============================================
// 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>
  )
}
Server-Side vs Client-Side Data Table
  • 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
Production Insight
Client-side tables break at 10,000+ rows — the browser freezes during sorting and filtering.
Server-side tables require manual state synchronization — sorting, filtering, and pagination state must be sent to the API.
Rule: use server-side mode for any dataset that could exceed 10,000 rows.
Key Takeaway
TanStack Table handles table logic — shadcn/ui handles table UI. The separation is clean.
Server-side mode requires manualPagination, manualSorting, manualFiltering — the table does not process data.
Debounce filter inputs by 300ms — prevents excessive API calls during typing.

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.

io.thecodeforge.shadcn.dialog-manager.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// ============================================
// 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>
  )
}
Dialogs as Async Promises
  • 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
Production Insight
Independent dialog state allows multiple dialogs to stack — causes z-index conflicts and user confusion.
Promise-based dialogs turn modal flows into async functions — the calling code awaits the result.
Rule: centralize dialog state in a provider — never manage dialog open/close in individual components.
Key Takeaway
Promise-based dialogs turn modal interactions into awaitable async flows.
One dialog at a time — the provider enforces this constraint and prevents stacking.
The calling code reads like synchronous logic — await 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.

io.thecodeforge.shadcn.toast-service.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
// ============================================
// 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',
    }
  )
}
Toast Lifecycle Management
  • 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()
Production Insight
Toast spam is a real UX problem — rapid API calls can stack dozens of identical toasts.
Deduplication by message content prevents the same toast from appearing multiple times.
Rule: wrap sonner's toast() in a service layer that handles deduplication and lifecycle.
Key Takeaway
Wrap sonner's toast() in a service layer — handle deduplication, prioritization, and lifecycle.
Loading toasts must resolve to success or error — never leave a loading toast hanging.
Error toasts stay longer than success toasts — users need time to read error messages.

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.

io.thecodeforge.shadcn.theme-switcher.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// ============================================
// 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>
  )
}
CSS Variables as a Theming API
  • 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
Production Insight
CSS variables enable runtime theme switching — no rebuild, no Tailwind config changes.
Brand themes as parent class overrides scope the theme to a container — useful for white-label apps.
Rule: never hardcode colors in shadcn/ui components — always use CSS variable references.
Key Takeaway
shadcn/ui theming uses CSS variables, not Tailwind config — runtime switching without rebuilds.
Brand themes are CSS variable overrides scoped by parent class — perfect for white-label applications.
Never hardcode colors — always reference CSS variables via Tailwind utilities like bg-background.

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.

io.thecodeforge.shadcn.slot-composition.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
// ============================================
// 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>
      }
    />
  )
}
When to Use Slots vs Children
  • 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
Production Insight
Named slots eliminate the need for multiple card variants — one component handles all layouts.
Without slots, teams create ProductCard, TeamCard, ArticleCard — all duplicating the same shell.
Rule: build one slot-based card component, compose content per use case.
Key Takeaway
Named slots let consumers inject content into predefined positions — header, content, footer, actions.
One slot-based component replaces multiple variant components — less code, more flexibility.
Each slot should be optional with sensible defaults — the component works even with no slots.

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.

io.thecodeforge.shadcn.command-palette.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// ============================================
// 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>
  )
}
Command Palette as a Universal Interface
  • 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
Production Insight
Command palette search without AbortController causes race conditions — slow responses overwrite fast ones.
useDeferredValue prevents excessive API calls — React batches rapid keystrokes into a single search.
Rule: always use AbortController with search APIs — cancel previous requests when new ones start.
Key Takeaway
The command palette is the keyboard-first interface — Cmd+K opens it, typing searches it.
useDeferredValue + AbortController prevents race conditions and excessive API calls.
Group static actions and dynamic results separately — users need to distinguish navigation from search.

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 styled() abstractions that break on upgrade.

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.

OwnedComponent.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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}
      />
    );
  }
);
Output
// You can now modify the className logic, add data-testid attributes,
// or change the focus ring style without updating npm packages.
// The component file lives in your repo. Full control.
Production Trap:
If you're copy-pasting the button into 50 different feature directories instead of one canonical @/components/ui/button.tsx, you've recreated the monolith problem. Pick one source of truth. Version your components like any other dependency.
Key Takeaway
Shadcn isn't a library you install—it's a starter kit you own. Treat the generated code as yours, not theirs.

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.

RadixDependency.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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>
Output
// Radix handles: focus trap, Escape key, clicking outside,
// aria-expanded on trigger, role='dialog', aria-modal='true',
// focus restoration when closed. You get all of this for free.
Senior Shortcut:
Run 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.
Key Takeaway
Radix primitives are the engine under Shadcn's hood. Never replace them. Customize the styling, not the behavior.
● Production incidentPOST-MORTEMseverity: high

shadcn/ui Dialog State Race Condition Crashed the Checkout Flow

Symptom
Users reported double charges on their credit cards. Server logs showed two identical payment intents created within 50ms of each other. Frontend monitoring showed multiple Dialog components mounting simultaneously on the same page.
Assumption
The Dialog component's open/onOpenChange state management would prevent multiple dialogs from opening simultaneously.
Root cause
The checkout page had three independent Dialog components — one for payment, one for confirmation, and one for error handling. Each managed its own open state via useState. When users clicked the pay button rapidly, the payment Dialog opened, but the click event also propagated to the confirmation Dialog's trigger. Because each Dialog had independent state, both opened simultaneously. The payment Dialog's form submitted, and the confirmation Dialog's confirm button also triggered a second submission — both hit the Stripe API before the first response returned.
Fix
Replaced independent Dialog state with a single useReducer that manages a dialog stack. Only one dialog can be open at a time — opening a new dialog automatically closes the previous one. Added a submission lock via useRef that prevents form submission while a request is in flight. Added idempotency keys to the Stripe API calls so duplicate submissions are rejected server-side.
Key lesson
  • 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
Production debug guideCommon issues when building with shadcn/ui in production5 entries
Symptom · 01
Tailwind classes not applying to shadcn/ui components
Fix
Check tailwind.config content paths include the component directory — shadcn/ui components must be in the content scan paths
Symptom · 02
Dialog or Popover renders behind other elements
Fix
Check z-index stacking context — shadcn/ui uses z-50 for portals, but parent containers with position:relative and z-index create new stacking contexts
Symptom · 03
Form validation errors not displaying
Fix
Verify FormMessage component is rendered inside FormField — it reads errors from FormField context, not from useForm directly
Symptom · 04
Select or Combobox dropdown scrolls with the page instead of staying fixed
Fix
Ensure the SelectContent or PopoverContent uses a portal — check that the component is not inside an overflow:hidden container
Symptom · 05
Theme colors not applying in dark mode
Fix
Verify CSS variables are defined under both :root and .dark selectors — shadcn/ui theming relies on CSS variables, not Tailwind color utilities
★ shadcn/ui Quick Debug ReferenceFast commands for diagnosing shadcn/ui issues
Component styles missing after installation
Immediate action
Verify component was added to correct directory
Commands
ls components/ui/ | grep -i 'button\|dialog\|form'
cat tailwind.config.ts | grep -A5 content
Fix now
Run npx shadcn-ui@latest add <component> and check components/ui/ directory
CSS variables not resolving+
Immediate action
Check globals.css for variable definitions
Commands
grep -n ':root' app/globals.css
grep -c '--primary\|--background\|--foreground' app/globals.css
Fix now
Run npx shadcn-ui@latest init to regenerate CSS variable definitions
TypeScript errors on component props+
Immediate action
Check Radix UI primitive types
Commands
npm ls @radix-ui/react-dialog @radix-ui/react-select
cat node_modules/@radix-ui/react-dialog/package.json | grep version
Fix now
Update Radix UI dependencies: npm update @radix-ui/*
Hydration mismatch with shadcn/ui components+
Immediate action
Check for client-only code in server components
Commands
grep -rn 'useState\|useEffect\|window\.' components/ui/ --include='*.tsx'
grep -rn '"use client"' components/ui/ --include='*.tsx'
Fix now
Add 'use client' directive to components using browser APIs or React hooks
shadcn/ui vs Traditional Component Libraries
Aspectshadcn/uiMaterial UIChakra UIAnt Design
OwnershipYou own the code — copy into your projectDependency — installed via npmDependency — installed via npmDependency — installed via npm
CustomizationModify source directly — no overrides neededTheme overrides and sx propTheme overrides and style propsConfigProvider and theme tokens
Bundle sizeOnly components you add — no tree-shaking neededFull library installed, tree-shaken at buildFull library installed, tree-shaken at buildFull library installed, tree-shaken at build
Upgrade pathManual — you diff and merge changesnpm update — automaticnpm update — automaticnpm update — automatic
Radix UI primitivesYes — built on RadixNo — custom implementationNo — custom implementationNo — custom implementation
Tailwind CSSRequired — all styles via TailwindNot used — Emotion/CSS-in-JSNot used — Emotion/CSS-in-JSNot used — Less/CSS-in-JS
TypeScriptFull support — generated typesFull supportFull supportFull support
AccessibilityRadix handles ARIA — strong foundationBuilt-in — strongBuilt-in — strongBuilt-in — moderate
Learning curveLow — standard React patternsMedium — MUI-specific APIsMedium — Chakra-specific APIsMedium — Ant-specific APIs
Best forTeams that want full controlTeams that want a complete systemTeams that want flexibility + opinionsEnterprise teams with design systems

Key takeaways

1
shadcn/ui is your code
modify it directly instead of creating wrapper components
2
Compound components eliminate prop drilling
parent owns state, children consume via context
3
react-hook-form + Zod is the standard for shadcn/ui forms
not useState
4
Server-side data tables require manualPagination, manualSorting, manualFiltering
the table does not process data
5
Promise-based dialogs prevent stacking
one dialog at a time, awaitable results
6
CSS variables enable runtime theme switching
never hardcode colors in shadcn/ui components

Common mistakes to avoid

6 patterns
×

Treating shadcn/ui as a black-box dependency

Symptom
Developers avoid modifying shadcn/ui components, creating wrapper components around them instead of editing the source directly. This adds unnecessary abstraction layers and makes debugging harder.
Fix
Edit the component source directly — it is your code, not a dependency. Modify the component in components/ui/ to match your requirements. The copy-paste model means you own every line.
×

Not using the cn() utility for class merging

Symptom
Tailwind class conflicts when extending shadcn/ui components — custom classes are overridden by component defaults or vice versa, causing inconsistent styling.
Fix
Always use 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

Symptom
Every keystroke triggers a re-render — forms with 10+ fields become noticeably slow. Validation errors do not display because FormMessage reads from react-hook-form context, not useState.
Fix
Use react-hook-form with zodResolver for all forms. FormField, FormControl, and FormMessage integrate with react-hook-form — not with useState.
×

Independent Dialog state for multiple dialogs

Symptom
Multiple dialogs can open simultaneously — z-index conflicts, overlapping modals, and confusing user experience. Rapid button clicks trigger race conditions.
Fix
Centralize dialog state in a provider — only one dialog can be open at a time. Use a Promise-based API so calling code can await the dialog result.
×

Hardcoding colors instead of using CSS variables

Symptom
Theme switching does not work — components keep their hardcoded colors when the user switches between light and dark mode. Brand theme overrides have no effect.
Fix
Always use CSS variable references via Tailwind utilities: bg-background, text-foreground, border-border. Never hardcode hex or RGB values in component styles.
×

Client-side data table for large datasets

Symptom
Browser freezes when sorting or filtering 50,000 rows — the entire dataset is loaded into memory and processed in the main thread.
Fix
Use server-side mode (manualPagination, manualSorting, manualFiltering) for any dataset that could exceed 10,000 rows. The table should only hold the current page of data.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does shadcn/ui differ from traditional component libraries like Mate...
Q02SENIOR
A form built with shadcn/ui and react-hook-form is not displaying valida...
Q03JUNIOR
What is the cn() utility in shadcn/ui and why is it necessary?
Q04SENIOR
How would you build a white-label application using shadcn/ui's theming ...
Q01 of 04SENIOR

How does shadcn/ui differ from traditional component libraries like Material UI? What are the trade-offs?

ANSWER
shadcn/ui uses a copy-paste model — you copy source code into your project and own every line. Material UI is a dependency — you install it via npm and consume its API. Advantages of shadcn/ui: - Full ownership: modify any component without fighting override APIs - Zero bundle bloat: only the components you add are in your project - Built on Radix UI primitives: strong accessibility foundation - Tailwind CSS integration: utility-first styling, no CSS-in-JS runtime Trade-offs: - Manual upgrades: you must diff and merge when shadcn/ui updates — no npm update - No centralized bug fixes: bugs in your copy must be fixed by you - Requires Tailwind CSS: teams using other CSS solutions cannot use it - More initial setup: each component must be individually added and configured The right choice depends on team size and control requirements. Small teams that want full control benefit most from shadcn/ui. Large teams that want automatic upgrades and centralized support may prefer Material UI.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Where is the cn() utility defined and why is it required everywhere?
02
Is shadcn/ui suitable for production applications?
03
How do I update shadcn/ui components when new versions are released?
04
Can I use shadcn/ui without Tailwind CSS?
05
How does shadcn/ui handle accessibility?
06
What is the difference between shadcn/ui and Radix UI?
🔥

That's React.js. Mark it forged?

6 min read · try the examples if you haven't

Previous
Partial Prerendering in Next.js 16 — The Complete Guide
22 / 47 · React.js
Next
I Built a SaaS in 48 Hours Using Only v0 + Cursor AI