feat: enhanced the writing skills

This commit is contained in:
duthaho
2026-04-18 18:50:39 +07:00
parent 7fa9a48c6c
commit 09538078e7
136 changed files with 10175 additions and 7947 deletions
@@ -0,0 +1,689 @@
# Frontend — Next.js Patterns
# Next.js
## When to Use
- React applications with SSR/SSG
- Full-stack applications
- App Router patterns
- SEO-critical sites needing server rendering
## When NOT to Use
- Pure React SPAs without SSR needs — use the `react` skill instead
- Non-React frameworks (Vue, Svelte, Angular) — this skill is React/Next.js specific
- Backend-only projects without a frontend — consider `fastapi` or `django`
---
## Core Patterns
### 1. App Router
#### Directory structure
```
app/
├── layout.tsx # Root layout (wraps entire app)
├── page.tsx # Home page (/)
├── loading.tsx # Root loading UI (Suspense fallback)
├── error.tsx # Root error boundary
├── not-found.tsx # Custom 404 page
├── global-error.tsx # Error boundary for root layout itself
├── favicon.ico
├── globals.css
├── api/
│ ├── users/
│ │ └── route.ts # GET/POST /api/users
│ │ └── [id]/
│ │ └── route.ts # GET/PUT/DELETE /api/users/:id
│ └── webhooks/
│ └── stripe/
│ └── route.ts # POST /api/webhooks/stripe
├── (marketing)/ # Route group (no URL segment)
│ ├── layout.tsx # Layout for marketing pages only
│ ├── page.tsx # / (same as root, can override)
│ ├── about/
│ │ └── page.tsx # /about
│ └── pricing/
│ └── page.tsx # /pricing
├── (app)/ # Route group for authenticated app
│ ├── layout.tsx # App shell layout (sidebar, nav)
│ ├── dashboard/
│ │ ├── page.tsx # /dashboard
│ │ ├── loading.tsx # Loading skeleton for dashboard
│ │ └── error.tsx # Error boundary for dashboard
│ ├── projects/
│ │ ├── page.tsx # /projects
│ │ └── [id]/
│ │ ├── page.tsx # /projects/:id
│ │ ├── edit/
│ │ │ └── page.tsx # /projects/:id/edit
│ │ └── layout.tsx # Shared layout for project detail
│ └── settings/
│ └── page.tsx # /settings
└── @modal/ # Parallel route slot
└── (.)projects/
└── [id]/
└── page.tsx # Intercepted route modal
```
#### Special files and their roles
| File | Purpose | Renders when |
|------|---------|-------------|
| `page.tsx` | Route UI | URL matches segment |
| `layout.tsx` | Shared wrapper, preserved across navigation | Always for child routes |
| `loading.tsx` | Suspense fallback | While page/data is loading |
| `error.tsx` | Error boundary | When child throws |
| `not-found.tsx` | 404 UI | When `notFound()` is called |
| `route.ts` | API endpoint | HTTP request to segment |
| `template.tsx` | Like layout but re-mounts on navigation | Every navigation |
| `default.tsx` | Fallback for parallel routes | When slot has no match |
```tsx
// app/layout.tsx — Root layout (required)
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { default: "My App", template: "%s | My App" },
description: "Application description",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<nav>{/* Global navigation */}</nav>
<main>{children}</main>
</body>
</html>
);
}
// app/error.tsx — Error boundary (must be client component)
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Page not found</h2>
<p>The requested resource does not exist.</p>
</div>
);
}
```
### 2. Server vs Client Components
#### Decision guide
| Use Server Component when | Use Client Component when |
|---------------------------|--------------------------|
| Fetching data | Using useState, useEffect, useRef |
| Accessing backend resources directly | Adding event handlers (onClick, onChange) |
| Keeping sensitive data on server | Using browser APIs (localStorage, window) |
| Reducing client bundle size | Using third-party client libraries |
| SEO-critical content | Animations, real-time updates |
#### Composition patterns
```tsx
// Server Component (default — no directive needed)
// app/projects/page.tsx
import { ProjectList } from "./project-list";
import { SearchBar } from "./search-bar"; // Client component
export default async function ProjectsPage() {
const projects = await db.project.findMany({
orderBy: { createdAt: "desc" },
});
return (
<div>
<h1>Projects</h1>
{/* Client component receives server data as props */}
<SearchBar />
{/* Server component can render client children */}
<ProjectList projects={projects} />
</div>
);
}
// Client Component — must have "use client" at top
// app/projects/search-bar.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("q", term);
} else {
params.delete("q");
}
startTransition(() => {
router.replace(`/projects?${params.toString()}`);
});
}
return (
<input
type="search"
placeholder="Search projects..."
defaultValue={searchParams.get("q") ?? ""}
onChange={(e) => handleSearch(e.target.value)}
className={isPending ? "opacity-50" : ""}
/>
);
}
```
**Key rule:** The `"use client"` directive creates a boundary. Everything imported into a client component becomes part of the client bundle. Pass server data down as serializable props (no functions, no classes).
### 3. Data Fetching
#### Server component fetch with caching
```tsx
// Fetch with automatic deduplication and caching
async function getProjects() {
const res = await fetch("https://api.example.com/projects", {
next: { revalidate: 60 }, // Revalidate every 60 seconds (ISR)
// next: { tags: ["projects"] }, // Tag-based revalidation
// cache: "no-store", // Always fresh (SSR)
// cache: "force-cache", // Cache indefinitely (SSG)
});
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json();
}
export default async function ProjectsPage() {
const projects = await getProjects();
return <ProjectList projects={projects} />;
}
```
#### generateStaticParams for static generation
```tsx
// app/projects/[id]/page.tsx
export async function generateStaticParams() {
const projects = await db.project.findMany({ select: { id: true } });
return projects.map((p) => ({ id: String(p.id) }));
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id } });
if (!project) notFound();
return <ProjectDetail project={project} />;
}
```
#### Route handlers (API routes)
```typescript
// app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = Number(searchParams.get("page") ?? "1");
const limit = Number(searchParams.get("limit") ?? "20");
const projects = await db.project.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json({ data: projects, page, limit });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const project = await db.project.create({ data: body });
return NextResponse.json(project, { status: 201 });
}
// Dynamic route: app/api/projects/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id } });
if (!project) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(project);
}
```
### 4. Server Actions
#### Form actions
```tsx
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
const ProjectSchema = z.object({
title: z.string().min(3).max(200),
description: z.string().optional(),
});
export async function createProject(prevState: unknown, formData: FormData) {
const parsed = ProjectSchema.safeParse({
title: formData.get("title"),
description: formData.get("description"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const project = await db.project.create({ data: parsed.data });
revalidatePath("/projects");
redirect(`/projects/${project.id}`);
}
export async function deleteProject(id: string) {
await db.project.delete({ where: { id } });
revalidatePath("/projects");
}
```
#### Using actions in client components with useActionState
```tsx
"use client";
import { useActionState } from "react";
import { createProject } from "../actions";
export function CreateProjectForm() {
const [state, formAction, isPending] = useActionState(createProject, null);
return (
<form action={formAction}>
<input name="title" placeholder="Project title" required />
{state?.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
<textarea name="description" placeholder="Description" />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}
```
#### Optimistic updates
```tsx
"use client";
import { useOptimistic } from "react";
import { deleteProject } from "../actions";
export function ProjectList({ projects }: { projects: Project[] }) {
const [optimisticProjects, removeOptimistic] = useOptimistic(
projects,
(state, removedId: string) => state.filter((p) => p.id !== removedId),
);
async function handleDelete(id: string) {
removeOptimistic(id);
await deleteProject(id);
}
return (
<ul>
{optimisticProjects.map((project) => (
<li key={project.id}>
{project.title}
<button onClick={() => handleDelete(project.id)}>Delete</button>
</li>
))}
</ul>
);
}
```
### 5. Middleware
```typescript
// middleware.ts (root of project, NOT inside app/)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Auth check
const token = request.cookies.get("session")?.value;
if (pathname.startsWith("/dashboard") && !token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Add headers
const response = NextResponse.next();
response.headers.set("x-pathname", pathname);
// Geo-based redirect
const country = request.geo?.country;
if (pathname === "/" && country === "DE") {
return NextResponse.redirect(new URL("/de", request.url));
}
// Rewrite (URL stays same, content changes)
if (pathname.startsWith("/old-path")) {
return NextResponse.rewrite(new URL("/new-path", request.url));
}
return response;
}
// Matcher: only run middleware on specific paths
export const config = {
matcher: [
// Match all paths except static files and api routes
"/((?!_next/static|_next/image|favicon.ico|api).*)",
// Or match specific paths
// "/dashboard/:path*",
// "/projects/:path*",
],
};
```
### 6. Caching
#### Cache layers overview
| Layer | What it caches | Control |
|-------|---------------|---------|
| Request Memoization | `fetch()` calls with same URL during single render | Automatic, per-request |
| Data Cache | `fetch()` results across requests | `next: { revalidate }`, `cache` option |
| Full Route Cache | HTML and RSC payload of static routes | `export const dynamic = "force-dynamic"` |
| Router Cache | Client-side RSC payload | `router.refresh()`, time-based |
#### Revalidation strategies
```tsx
// Time-based revalidation (ISR)
fetch(url, { next: { revalidate: 3600 } }); // 1 hour
// On-demand revalidation by path
import { revalidatePath } from "next/cache";
revalidatePath("/projects"); // Revalidate specific page
revalidatePath("/projects", "layout"); // Revalidate layout and all pages under it
// On-demand revalidation by tag
import { revalidateTag } from "next/cache";
// When fetching:
fetch(url, { next: { tags: ["projects"] } });
// When invalidating:
revalidateTag("projects");
// Route segment config
export const dynamic = "force-dynamic"; // Never cache (SSR)
export const revalidate = 60; // ISR with 60s interval
export const fetchCache = "default-cache";
```
#### unstable_cache for non-fetch data
```tsx
import { unstable_cache } from "next/cache";
const getCachedProjects = unstable_cache(
async (orgId: string) => {
return db.project.findMany({ where: { organizationId: orgId } });
},
["projects"], // Cache key parts
{ revalidate: 60, tags: ["projects"] },
);
export default async function ProjectsPage() {
const projects = await getCachedProjects("org-123");
return <ProjectList projects={projects} />;
}
```
### 7. Route Groups & Parallel Routes
#### Route groups with `(groupName)`
Route groups organize routes without affecting the URL:
```
app/
├── (marketing)/ # URL: / , /about, /pricing (no "marketing" in URL)
│ ├── layout.tsx # Marketing layout (hero, footer)
│ ├── page.tsx
│ └── about/page.tsx
├── (app)/ # URL: /dashboard, /projects
│ ├── layout.tsx # App layout (sidebar, auth)
│ └── dashboard/page.tsx
```
#### Parallel routes with `@slotName`
```
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx # Rendered in parallel
│ └── default.tsx # Fallback when no match
├── @sidebar/
│ ├── page.tsx
│ └── default.tsx
```
```tsx
// app/layout.tsx — receives parallel route slots as props
export default function Layout({
children,
analytics,
sidebar,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
sidebar: React.ReactNode;
}) {
return (
<div className="flex">
<aside>{sidebar}</aside>
<main>{children}</main>
<aside>{analytics}</aside>
</div>
);
}
```
#### Intercepting routes
```
app/
├── projects/
│ ├── page.tsx # /projects — full list
│ └── [id]/
│ └── page.tsx # /projects/:id — full page
├── @modal/
│ ├── (.)projects/
│ │ └── [id]/
│ │ └── page.tsx # Intercepts /projects/:id as modal
│ └── default.tsx # No modal by default
```
Convention: `(.)` = same level, `(..)` = one level up, `(...)` = root.
### 8. Image & Font Optimization
#### next/image
```tsx
import Image from "next/image";
// Local image (automatically gets width/height)
import heroImage from "@/public/hero.png";
export function Hero() {
return (
<Image
src={heroImage}
alt="Hero banner"
placeholder="blur" // Auto blur placeholder for local images
priority // Preload for LCP images
className="w-full h-auto"
/>
);
}
// Remote image (must specify dimensions)
export function Avatar({ url, name }: { url: string; name: string }) {
return (
<Image
src={url}
alt={name}
width={48}
height={48}
className="rounded-full"
/>
);
}
// next.config.ts — allow remote image domains
const config = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
{ protocol: "https", hostname: "**.cloudinary.com" },
],
},
};
```
#### next/font
```tsx
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
const customFont = localFont({
src: "./fonts/CustomFont.woff2",
variable: "--font-custom",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
```
```css
/* In Tailwind config or globals.css */
:root {
--font-sans: var(--font-inter);
--font-mono: var(--font-mono);
}
```
---
## Best Practices
1. **Default to Server Components** — only add `"use client"` when you need interactivity, event handlers, or browser APIs. Server Components reduce bundle size and allow direct data access.
2. **Colocate data fetching with the component that uses it** — fetch inside the Server Component that renders the data, not in a parent that passes it down. Next.js deduplicates identical `fetch()` calls automatically.
3. **Use `loading.tsx` for instant loading states** — every route segment can have a `loading.tsx` that wraps the page in a Suspense boundary. This gives users immediate feedback during navigation.
4. **Validate Server Action inputs** — Server Actions are public HTTP endpoints. Always validate with zod or similar. Never trust `formData` values without parsing and validating.
5. **Use route groups to share layouts without affecting URLs**`(marketing)` and `(app)` let you have completely different layouts (public vs authenticated) without nesting URL segments.
6. **Prefer `revalidatePath`/`revalidateTag` over `cache: "no-store"`** — on-demand revalidation gives you fresh data when it changes while still serving cached content for performance. Only use `"no-store"` for truly dynamic per-request data.
7. **Put middleware at the project root**`middleware.ts` must be at the same level as `app/`, not inside it. Use the `matcher` config to limit which paths it runs on for performance.
8. **Use `next/image` for all images** — it handles lazy loading, responsive sizes, format conversion (WebP/AVIF), and blur placeholders. Set `priority` on above-the-fold LCP images. Configure `remotePatterns` for external image sources.
---
## Common Pitfalls
1. **Using hooks in Server Components**`useState`, `useEffect`, `useRouter` (from `next/navigation`) only work in Client Components. If you see "hooks can only be called inside a function component," add `"use client"` or restructure to push interactivity to a child component.
2. **Passing non-serializable props across the server/client boundary** — functions, class instances, and Dates cannot be passed from Server to Client Components. Serialize data to plain objects and strings before passing as props.
3. **Large client bundles from misplaced `"use client"`** — placing the directive too high in the tree pulls entire subtrees into the client bundle. Push `"use client"` as deep as possible, wrapping only the interactive leaf components.
4. **Stale data from aggressive caching** — the Full Route Cache and Data Cache can serve stale content. Use `revalidatePath()`/`revalidateTag()` in Server Actions and route handlers after mutations. Call `router.refresh()` on the client if needed.
5. **Missing `default.tsx` for parallel routes** — when navigating to a URL that does not match a parallel route slot, Next.js renders `default.tsx`. Without it, you get a 404. Always provide a default for every `@slot`.
6. **Forgetting `loading.tsx` leads to blank pages during navigation** — without loading boundaries, users see nothing while Server Components fetch data. Add `loading.tsx` at every route segment that does async work.
---
## Related Skills
- `react` — React component patterns, hooks, and state management
- `typescript` — TypeScript strict mode and type patterns
- `tailwind` — Styling with Tailwind CSS
- `shadcn-ui` — UI component library built on Radix and Tailwind
- `authentication` — Protected routes and auth middleware for Next.js
- `caching` — Next.js caching layers and invalidation
- `state-management` — React state management in Next.js apps
+712
View File
@@ -0,0 +1,712 @@
# Frontend — React Patterns
# React
## When to Use
- Building React components
- Using React hooks
- Component state management
- Client-side interactivity in any React-based framework
## When NOT to Use
- Vue, Svelte, or Angular projects — this skill is React-specific
- Backend-only projects without a frontend UI layer
- Static HTML pages that do not require a JavaScript framework
---
## Core Patterns
### 1. Hooks
#### When-to-use guide
| Hook | Use when you need | Do NOT use for |
|------|-------------------|----------------|
| `useState` | Simple local state (toggle, form input, counter) | Derived/computed values |
| `useEffect` | Side effects: subscriptions, DOM mutations, timers | Data transformation (use useMemo) |
| `useRef` | Mutable value that persists across renders without triggering re-render; DOM refs | State that should cause re-render |
| `useMemo` | Expensive computation that should only rerun when deps change | Simple/cheap calculations |
| `useCallback` | Stable function reference to prevent child re-renders | Every function (only when needed) |
| `useReducer` | Complex state with multiple sub-values or state transitions | Simple boolean/string state |
| `useContext` | Reading context values | Frequently changing global state (causes re-renders) |
#### useState
```tsx
// Simple state
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
// Functional updates (when new state depends on previous)
setCount((prev) => prev + 1);
// Lazy initialization (expensive initial value)
const [data, setData] = useState(() => computeExpensiveDefault());
```
#### useEffect
```tsx
// Run on mount + cleanup on unmount
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal).then(setData);
return () => controller.abort(); // Cleanup
}, []); // Empty deps = run once
// Run when dependency changes
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []); // No deps needed — handler is stable
// Sync external system with state
useEffect(() => {
document.title = `${count} items`;
}, [count]);
```
#### useRef
```tsx
// DOM reference
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => inputRef.current?.focus();
// Mutable value (no re-render on change)
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
});
// Previous value pattern
const prevValueRef = useRef(value);
useEffect(() => {
prevValueRef.current = value;
}, [value]);
```
#### useReducer
```tsx
interface State {
items: Item[];
loading: boolean;
error: string | null;
}
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: Item[] }
| { type: "FETCH_ERROR"; error: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, loading: true, error: null };
case "FETCH_SUCCESS":
return { items: action.payload, loading: false, error: null };
case "FETCH_ERROR":
return { ...state, loading: false, error: action.error };
}
}
const [state, dispatch] = useReducer(reducer, {
items: [],
loading: false,
error: null,
});
// Dispatch actions
dispatch({ type: "FETCH_START" });
```
### 2. Custom Hooks
#### Extraction pattern
Extract a custom hook when:
- Two or more components share the same stateful logic
- A component's hook logic is complex enough to deserve its own name and tests
- You want to abstract away an external API (localStorage, WebSocket, etc.)
**Rules:**
- Name must start with `use`
- Can call other hooks (unlike regular functions)
- Each call gets its own independent state
#### Practical examples
```tsx
// useLocalStorage — persist state to localStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(next));
return next;
});
},
[key],
);
return [stored, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage("theme", "light");
```
```tsx
// useDebounce — debounce a rapidly changing value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
fetchResults(debouncedSearch);
}, [debouncedSearch]);
```
```tsx
// useFetch — generic data fetching hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => setData(json as T))
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// Usage
const { data: users, loading, error } = useFetch<User[]>("/api/users");
```
### 3. Component Patterns
#### Compound components
```tsx
// Components that work together, sharing implicit state
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div role="tablist">{children}</div>
</TabsContext.Provider>
);
}
function TabTrigger({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
return (
<button
role="tab"
aria-selected={ctx.activeTab === value}
onClick={() => ctx.setActiveTab(value)}
>
{children}
</button>
);
}
function TabContent({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
if (ctx.activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components
Tabs.Trigger = TabTrigger;
Tabs.Content = TabContent;
// Usage
<Tabs defaultTab="settings">
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
<Tabs.Content value="profile"><ProfileForm /></Tabs.Content>
<Tabs.Content value="settings"><SettingsForm /></Tabs.Content>
</Tabs>
```
#### Render props
```tsx
interface MousePosition {
x: number;
y: number;
}
function MouseTracker({ render }: { render: (pos: MousePosition) => ReactNode }) {
const [pos, setPos] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return <>{render(pos)}</>;
}
// Usage
<MouseTracker render={({ x, y }) => <span>Mouse: {x}, {y}</span>} />
```
#### Controlled vs uncontrolled
```tsx
// Controlled — parent owns the state
interface ControlledInputProps {
value: string;
onChange: (value: string) => void;
}
function ControlledInput({ value, onChange }: ControlledInputProps) {
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}
// Uncontrolled — component owns the state, parent reads via ref or callback
function UncontrolledInput({ defaultValue }: { defaultValue?: string }) {
const ref = useRef<HTMLInputElement>(null);
return <input ref={ref} defaultValue={defaultValue} />;
}
// Flexible pattern — supports both controlled and uncontrolled
function FlexibleInput({
value: controlledValue,
defaultValue = "",
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternalValue(e.target.value);
onChange?.(e.target.value);
}
return <input value={value} onChange={handleChange} />;
}
```
### 4. Context
#### Provider pattern with separate state and dispatch
```tsx
// Split context to prevent unnecessary re-renders
interface AppState {
user: User | null;
theme: "light" | "dark";
}
type AppAction =
| { type: "SET_USER"; user: User | null }
| { type: "TOGGLE_THEME" };
const AppStateContext = createContext<AppState | null>(null);
const AppDispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.user };
case "TOGGLE_THEME":
return { ...state, theme: state.theme === "light" ? "dark" : "light" };
}
}
function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: "light",
});
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}
// Typed hooks for consumers
function useAppState() {
const ctx = useContext(AppStateContext);
if (!ctx) throw new Error("useAppState must be used within AppProvider");
return ctx;
}
function useAppDispatch() {
const ctx = useContext(AppDispatchContext);
if (!ctx) throw new Error("useAppDispatch must be used within AppProvider");
return ctx;
}
```
**Why split?** Components that only dispatch actions (buttons) do not re-render when state changes. Only components that read state re-render.
#### Context splitting for performance
```tsx
// Instead of one giant context with everything:
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<"light" | "dark">("light");
const NotificationContext = createContext<Notification[]>([]);
// Components subscribe only to the context they need
function Avatar() {
const user = useContext(UserContext); // Only re-renders when user changes
return <img src={user?.avatar} />;
}
```
### 5. Error Boundaries
#### Class-based error boundary
```tsx
class ErrorBoundary extends React.Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode; fallback: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error boundary caught:", error, info.componentStack);
// Send to error tracking service
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
```
#### react-error-boundary library (recommended)
```tsx
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
// Usage
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// Reset app state if needed
}}
resetKeys={[userId]} // Auto-reset when these values change
>
<Dashboard />
</ErrorBoundary>
// Programmatic error throwing from child
function SaveButton() {
const { showBoundary } = useErrorBoundary();
async function handleSave() {
try {
await saveData();
} catch (error) {
showBoundary(error); // Propagate to nearest ErrorBoundary
}
}
return <button onClick={handleSave}>Save</button>;
}
```
### 6. Suspense
#### Suspense boundaries with lazy loading
```tsx
import { Suspense, lazy } from "react";
// Code-split heavy components
const HeavyChart = lazy(() => import("./heavy-chart"));
const AdminPanel = lazy(() => import("./admin-panel"));
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
<Suspense fallback={<div>Loading admin panel...</div>}>
<AdminPanel />
</Suspense>
</div>
);
}
```
#### Suspense with data fetching (React 19+ / framework integration)
```tsx
// With a Suspense-compatible data source (React Query, Next.js, Relay)
function ProjectList() {
return (
<Suspense fallback={<ProjectListSkeleton />}>
<ProjectListContent />
</Suspense>
);
}
// The data-fetching component suspends while loading
function ProjectListContent() {
const { data } = useSuspenseQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
});
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
```
#### Named exports with lazy
```tsx
// For named exports, wrap in a default export adapter
const UserSettings = lazy(() =>
import("./user-settings").then((mod) => ({ default: mod.UserSettings })),
);
```
### 7. Performance
#### React.memo
```tsx
// Only re-renders when props change (shallow comparison)
const ExpensiveList = React.memo(function ExpensiveList({
items,
onSelect,
}: {
items: Item[];
onSelect: (item: Item) => void;
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item)}>
{item.name}
</li>
))}
</ul>
);
});
// Custom comparison
const Chart = React.memo(ChartComponent, (prev, next) => {
return prev.data.length === next.data.length && prev.title === next.title;
});
```
#### useMemo and useCallback together
```tsx
function ParentComponent({ items }: { items: Item[] }) {
// Memoize expensive derived data
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items],
);
// Stable function reference for memoized child
const handleSelect = useCallback((item: Item) => {
console.log("Selected:", item.id);
}, []);
// ExpensiveList only re-renders when sortedItems or handleSelect change
return <ExpensiveList items={sortedItems} onSelect={handleSelect} />;
}
```
#### Key prop optimization
```tsx
// BAD: using index as key — breaks state when list order changes
{items.map((item, index) => <Item key={index} data={item} />)}
// GOOD: stable unique key
{items.map((item) => <Item key={item.id} data={item} />)}
// Force remount: change key to reset component state
<ProfileForm key={userId} userId={userId} />
// When userId changes, the form unmounts and remounts with fresh state
```
#### Virtualization for large lists
```tsx
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated row height in px
overscan: 5, // Extra rows rendered above/below viewport
});
return (
<div ref={parentRef} style={{ height: "400px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
width: "100%",
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
```
---
## Best Practices
1. **Keep components small and single-purpose** — if a component exceeds ~100 lines or handles multiple concerns, extract sub-components or custom hooks. Name components after what they render, not what they do.
2. **Use TypeScript interfaces for all props** — define explicit prop types. Avoid `any`. Use discriminated unions for props that change based on a variant. Export prop types for reuse.
3. **Clean up all effects** — return a cleanup function from every `useEffect` that subscribes to events, starts timers, or creates abort controllers. Missing cleanups cause memory leaks and stale state bugs.
4. **Derive state instead of syncing it** — if a value can be computed from props or other state, compute it during render (or with `useMemo`). Never `useEffect` to sync derived state — it causes an extra render.
5. **Lift state to the lowest common ancestor** — not higher. State should live in the closest parent that needs it. If siblings need shared state, lift to their parent. If distant components need it, use context.
6. **Use `useCallback` and `React.memo` together, not alone**`useCallback` only helps when the function is passed to a memoized child. `React.memo` only helps when the parent actually passes stable props. Using one without the other is wasted effort.
7. **Prefer composition over prop drilling** — instead of passing props through 5 levels, restructure so the parent renders the child directly (component composition) or use context for truly global state.
8. **Handle all async states** — every data-fetching component should handle loading, error, and empty states. Use Suspense and Error Boundaries for declarative handling. Never leave a component that shows nothing while loading.
---
## Common Pitfalls
1. **Missing or wrong dependency arrays** — forgetting to add a dependency to `useEffect`/`useMemo`/`useCallback` causes stale closures. Adding too many causes unnecessary re-runs. Use the `react-hooks/exhaustive-deps` ESLint rule.
2. **Setting state during render** — calling `setState` unconditionally in the render body causes infinite re-render loops. State updates should be in event handlers, effects, or callbacks — never at the top level of the component function.
3. **Prop drilling through many layers** — passing a prop through 4+ intermediate components that do not use it. Fix with composition (restructuring the component tree), context (for shared state), or a state management library.
4. **Creating objects/arrays in JSX props**`<Child style={{ color: "red" }} />` creates a new object every render, defeating `React.memo`. Hoist constants outside the component or use `useMemo`.
5. **Using `useEffect` for derived state** — syncing state with `useEffect(() => setFullName(first + last), [first, last])` causes double renders. Just compute it: `const fullName = first + last`. Use `useMemo` if the computation is expensive.
6. **Not handling race conditions in effects** — when a component fetches data based on a prop, fast prop changes can cause older responses to arrive after newer ones, displaying stale data. Use `AbortController` or a boolean flag to ignore stale responses.
---
## Related Skills
- `nextjs` — Next.js App Router, SSR, and full-stack React patterns
- `typescript` — TypeScript strict mode and type patterns
- `tailwind` — Styling with Tailwind CSS
- `shadcn-ui` — UI component library built on Radix and Tailwind
- `vitest` — Testing React components with vitest and testing-library
- `state-management` — State management patterns for React
@@ -0,0 +1,935 @@
# Frontend — shadcn/ui Patterns
# shadcn/ui
## When to Use
- Building React component libraries
- Accessible UI components
- Customizable design systems
## When NOT to Use
- Non-React projects using Vue, Svelte, Angular, or other frameworks
- Projects already using a different component library such as MUI, Chakra UI, or Ant Design
- Vanilla HTML/CSS projects without a React build pipeline
---
## Core Patterns
### 1. Installation & Setup
**Initialize shadcn/ui in a Next.js or Vite project:**
```bash
# Initialize -- creates components.json and sets up paths
npx shadcn@latest init
# You will be prompted for:
# - Style (default or new-york)
# - Base color
# - CSS variables for colors (yes recommended)
# - Tailwind config path
# - Components alias path (@/components)
# - Utils alias path (@/lib/utils)
```
**Install individual components as needed:**
```bash
# Install specific components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add table
npx shadcn@latest add toast
# Install multiple at once
npx shadcn@latest add button card input label textarea select
# List available components
npx shadcn@latest add
```
**Project structure after setup:**
```
src/
├── components/
│ └── ui/ # shadcn/ui components live here
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ └── ...
├── lib/
│ └── utils.ts # cn() utility
└── app/
└── globals.css # CSS variables for theming
```
**The `cn()` utility -- the foundation of class merging:**
```ts
// lib/utils.ts (auto-generated)
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
### 2. Component Customization
**Extending an existing component with new variants:**
```tsx
// components/ui/button.tsx -- add a "brand" variant
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
// Custom variant added
brand: "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
```
**Wrapping a shadcn component with project-specific defaults:**
```tsx
// components/app/submit-button.tsx
import { Button, type ButtonProps } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface SubmitButtonProps extends ButtonProps {
loading?: boolean;
}
export function SubmitButton({
children,
loading,
disabled,
className,
...props
}: SubmitButtonProps) {
return (
<Button
type="submit"
disabled={disabled || loading}
className={cn("min-w-[120px]", className)}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</Button>
);
}
```
**Using `asChild` for composition:**
```tsx
import { Button } from "@/components/ui/button";
import Link from "next/link";
// Render as a Next.js Link instead of a <button>
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
// Render as an anchor tag
<Button asChild variant="link">
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
External Link
</a>
</Button>
```
### 3. Form Patterns
**Complete form with react-hook-form + zod validation:**
```tsx
"use client";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
category: z.enum(["general", "support", "billing"], {
required_error: "Please select a category",
}),
message: z.string().min(10, "Message must be at least 10 characters"),
});
type ContactFormValues = z.infer<typeof contactSchema>;
export function ContactForm() {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
message: "",
},
});
async function onSubmit(values: ContactFormValues) {
// Handle form submission
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormDescription>We will never share your email.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="general">General Inquiry</SelectItem>
<SelectItem value="support">Technical Support</SelectItem>
<SelectItem value="billing">Billing</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormControl>
<Textarea
placeholder="How can we help?"
className="min-h-[120px] resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
</Form>
);
}
```
### 4. Data Table
**Column definitions with sorting and formatting:**
```tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface Payment {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
createdAt: Date;
}
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: "email",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
const variant = {
pending: "secondary",
processing: "outline",
success: "default",
failed: "destructive",
}[status] as "secondary" | "outline" | "default" | "destructive";
return <Badge variant={variant}>{status}</Badge>;
},
filterFn: (row, id, value) => value.includes(row.getValue(id)),
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.id)}>
Copy ID
</DropdownMenuItem>
<DropdownMenuItem>View details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
```
**DataTable component with filtering and pagination:**
```tsx
"use client";
import { useState } from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
SortingState,
ColumnFiltersState,
useReactTable,
} from "@tanstack/react-table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
state: { sorting, columnFilters },
});
return (
<div className="space-y-4">
{searchKey && (
<Input
placeholder={`Filter by ${searchKey}...`}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(e) => table.getColumn(searchKey)?.setFilterValue(e.target.value)}
className="max-w-sm"
/>
)}
<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}>
{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>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} row(s) total
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}
```
### 5. Dialog / Sheet / Drawer
**Controlled dialog with form:**
```tsx
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function EditProfileDialog() {
const [open, setOpen] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Save profile...
setOpen(false); // Close after success
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Make changes to your profile. Click save when you are done.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Input id="name" defaultValue="John Doe" className="col-span-3" />
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="username" className="text-right">Username</Label>
<Input id="username" defaultValue="@johndoe" className="col-span-3" />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit">Save changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
```
**Sheet for side panels (mobile nav, filters):**
```tsx
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Menu } from "lucide-react";
export function MobileNav() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px]">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Browse the application.</SheetDescription>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
<a href="/" className="rounded-md px-3 py-2 text-sm hover:bg-accent">Home</a>
<a href="/about" className="rounded-md px-3 py-2 text-sm hover:bg-accent">About</a>
<a href="/settings" className="rounded-md px-3 py-2 text-sm hover:bg-accent">Settings</a>
</nav>
</SheetContent>
</Sheet>
);
}
```
**Confirmation dialog pattern (reusable):**
```tsx
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
interface ConfirmDialogProps {
trigger: React.ReactNode;
title: string;
description: string;
onConfirm: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
trigger,
title,
description,
onConfirm,
destructive = false,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className={destructive ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
```
### 6. Toast / Notifications
**Setup with Sonner (recommended approach):**
```bash
npx shadcn@latest add sonner
```
```tsx
// app/layout.tsx -- add the Toaster provider
import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}
```
```tsx
// Using toast anywhere in your app
import { toast } from "sonner";
function SaveButton() {
async function handleSave() {
try {
await saveData();
toast.success("Changes saved", {
description: "Your profile has been updated.",
});
} catch (error) {
toast.error("Failed to save", {
description: "Please try again later.",
});
}
}
return <Button onClick={handleSave}>Save</Button>;
}
// Toast variants
toast("Default notification");
toast.success("Operation completed");
toast.error("Something went wrong");
toast.warning("Please review your input");
toast.info("New version available");
// Toast with action
toast("File deleted", {
action: {
label: "Undo",
onClick: () => restoreFile(),
},
});
// Promise toast -- shows loading, success, and error states
toast.promise(fetchData(), {
loading: "Loading data...",
success: "Data loaded successfully",
error: "Failed to load data",
});
```
### 7. Theme System
**CSS variables in globals.css:**
```css
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
```
**Creating a custom color theme:**
```css
/* Add a custom "ocean" theme alongside light and dark */
.theme-ocean {
--background: 210 50% 10%;
--foreground: 195 80% 90%;
--primary: 195 90% 50%;
--primary-foreground: 210 50% 10%;
--secondary: 200 40% 20%;
--secondary-foreground: 195 80% 90%;
--muted: 200 30% 18%;
--muted-foreground: 195 30% 60%;
--accent: 180 60% 40%;
--accent-foreground: 195 80% 90%;
--border: 200 30% 25%;
--input: 200 30% 25%;
--ring: 195 90% 50%;
}
```
**Theme provider for Next.js:**
```tsx
// components/theme-provider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
```
```tsx
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
```
```tsx
// components/theme-toggle.tsx
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
---
## Best Practices
1. **Install components individually** -- only add what you need. Each component is copied into your codebase, so unused components add dead code.
2. **Customize at the source** -- since components live in your `components/ui/` directory, modify them directly rather than wrapping with overrides. This is the intended workflow.
3. **Use `cn()` for all conditional styling** -- it merges Tailwind classes correctly, avoiding conflicts. Never concatenate class strings manually.
4. **Keep forms type-safe end to end** -- define a zod schema, infer the TypeScript type from it, and pass it to `useForm<T>`. This gives you validation and type safety in one place.
5. **Use `asChild` for semantic HTML** -- when a Button should be a link, or a DialogTrigger should be a custom component, use `asChild` to avoid nested interactive elements.
6. **Follow the CSS variable naming convention** -- shadcn/ui expects HSL values without the `hsl()` wrapper (e.g., `220 14% 96%`). The `hsl()` is applied in Tailwind config.
7. **Wrap layout-level providers once** -- place `ThemeProvider`, `Toaster`, and other providers in the root layout. Do not nest them in individual pages.
8. **Prefer Sonner over the legacy toast** -- the Sonner integration is simpler, supports rich colors, promise toasts, and requires less boilerplate than the older toast component.
## Common Pitfalls
1. **Missing `cn` import** -- every component uses `cn()` from `@/lib/utils`. If you see class merging issues, verify this import exists and uses both `clsx` and `tailwind-merge`.
2. **Incorrect `asChild` usage** -- `asChild` merges props onto the immediate child. If you wrap the child in a fragment or extra div, the props will not pass through correctly.
3. **Hardcoded colors instead of CSS variables** -- using `bg-blue-500` instead of `bg-primary` bypasses the theme system. Always use semantic token names so dark mode and custom themes work.
4. **Forgetting `"use client"` directive** -- shadcn/ui components using hooks (Dialog, Form, Sheet, etc.) require the `"use client"` directive in Next.js App Router. The UI primitives themselves include it, but your page-level components that use them may also need it.
5. **Not handling controlled state in dialogs** -- for dialogs that contain forms, use the controlled `open` / `onOpenChange` pattern so you can close the dialog programmatically after submission.
6. **Stale component versions** -- since components are copied into your project, they do not auto-update. Periodically check the shadcn/ui docs for fixes and re-run `npx shadcn@latest add <component>` to pull updates (review the diff before accepting).
## Related Skills
- `tailwind` - Tailwind CSS utility classes used for styling shadcn/ui components
- `react` - React patterns and hooks used alongside shadcn/ui
- `nextjs` - Next.js integration with shadcn/ui for full-stack applications