mirror of
https://github.com/duthaho/claudekit.git
synced 2026-08-27 21:49:41 +03:00
feat: enhanced the writing skills
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user