frontend-patterns
Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
Best use case
frontend-patterns is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
Teams using frontend-patterns should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/frontend-patterns/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How frontend-patterns Compares
| Feature / Agent | frontend-patterns | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# Frontend Development Patterns
Modern frontend patterns for React, Next.js, and performant user interfaces.
## Component Patterns
### Composition Over Inheritance
```typescript
// ✅ GOOD: Component composition
interface CardProps {
children: React.ReactNode
variant?: 'default' | 'outlined'
}
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
export function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content</CardBody>
</Card>
```
### Compound Components
```typescript
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
export function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab must be used within Tabs')
return (
<button
className={context.activeTab === id ? 'active' : ''}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
)
}
// Usage
<Tabs defaultTab="overview">
<TabList>
<Tab id="overview">Overview</Tab>
<Tab id="details">Details</Tab>
</TabList>
</Tabs>
```
### Render Props Pattern
```typescript
interface DataLoaderProps<T> {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
return <>{children(data, loading, error)}</>
}
// Usage
<DataLoader<Market[]> url="/api/markets">
{(markets, loading, error) => {
if (loading) return <Spinner />
if (error) return <Error error={error} />
return <MarketList markets={markets!} />
}}
</DataLoader>
```
## Custom Hooks Patterns
### State Management Hook
```typescript
export function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
// Usage
const [isOpen, toggleOpen] = useToggle()
```
### Async Data Fetching Hook
```typescript
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
export function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: UseQueryOptions<T>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await fetcher()
setData(result)
options?.onSuccess?.(result)
} catch (err) {
const error = err as Error
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}, [fetcher, options])
useEffect(() => {
if (options?.enabled !== false) {
refetch()
}
}, [key, refetch, options?.enabled])
return { data, error, loading, refetch }
}
// Usage
const { data: markets, loading, error, refetch } = useQuery(
'markets',
() => fetch('/api/markets').then(r => r.json()),
{
onSuccess: data => console.log('Fetched', data.length, 'markets'),
onError: err => console.error('Failed:', err)
}
)
```
### Debounce Hook
```typescript
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery)
}
}, [debouncedQuery])
```
## State Management Patterns
### Context + Reducer Pattern
```typescript
interface State {
markets: Market[]
selectedMarket: Market | null
loading: boolean
}
type Action =
| { type: 'SET_MARKETS'; payload: Market[] }
| { type: 'SELECT_MARKET'; payload: Market }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_MARKETS':
return { ...state, markets: action.payload }
case 'SELECT_MARKET':
return { ...state, selectedMarket: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
const MarketContext = createContext<{
state: State
dispatch: Dispatch<Action>
} | undefined>(undefined)
export function MarketProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
markets: [],
selectedMarket: null,
loading: false
})
return (
<MarketContext.Provider value={{ state, dispatch }}>
{children}
</MarketContext.Provider>
)
}
export function useMarkets() {
const context = useContext(MarketContext)
if (!context) throw new Error('useMarkets must be used within MarketProvider')
return context
}
```
## Performance Optimization
### Memoization
```typescript
// ✅ useMemo for expensive computations
const sortedMarkets = useMemo(() => {
return markets.sort((a, b) => b.volume - a.volume)
}, [markets])
// ✅ useCallback for functions passed to children
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// ✅ React.memo for pure components
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
return (
<div className="market-card">
<h3>{market.name}</h3>
<p>{market.description}</p>
</div>
)
})
```
### Code Splitting & Lazy Loading
```typescript
import { lazy, Suspense } from 'react'
// ✅ Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
export function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
<Suspense fallback={null}>
<ThreeJsBackground />
</Suspense>
</div>
)
}
```
### Virtualization for Long Lists
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 5 // Extra items to render
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
```
## Form Handling Patterns
### Controlled Form with Validation
```typescript
interface FormData {
name: string
description: string
endDate: string
}
interface FormErrors {
name?: string
description?: string
endDate?: string
}
export function CreateMarketForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
endDate: ''
})
const [errors, setErrors] = useState<FormErrors>({})
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Name is required'
} else if (formData.name.length > 200) {
newErrors.name = 'Name must be under 200 characters'
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required'
}
if (!formData.endDate) {
newErrors.endDate = 'End date is required'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
try {
await createMarket(formData)
// Success handling
} catch (error) {
// Error handling
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Market name"
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* Other fields */}
<button type="submit">Create Market</button>
</form>
)
}
```
## Error Boundary Pattern
```typescript
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
error: null
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>
```
## Animation Patterns
### Framer Motion Animations
```typescript
import { motion, AnimatePresence } from 'framer-motion'
// ✅ List animations
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
return (
<AnimatePresence>
{markets.map(market => (
<motion.div
key={market.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MarketCard market={market} />
</motion.div>
))}
</AnimatePresence>
)
}
// ✅ Modal animations
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="modal-content"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
```
## Accessibility Patterns
### Keyboard Navigation
```typescript
export function Dropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
onSelect(options[activeIndex])
setIsOpen(false)
break
case 'Escape':
setIsOpen(false)
break
}
}
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
onKeyDown={handleKeyDown}
>
{/* Dropdown implementation */}
</div>
)
}
```
### Focus Management
```typescript
export function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement
// Focus modal
modalRef.current?.focus()
} else {
// Restore focus when closing
previousFocusRef.current?.focus()
}
}, [isOpen])
return isOpen ? (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={e => e.key === 'Escape' && onClose()}
>
{children}
</div>
) : null
}
```
## Design System Tokens
### Foundation — CSS Custom Properties
```css
:root {
/* Color — semantic naming, not "blue-500" */
--color-surface: #ffffff;
--color-surface-raised: #f8fafc;
--color-surface-sunken: #f1f5f9;
--color-border: #e2e8f0;
--color-border-strong: #cbd5e1;
--color-text: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-subtle: #eff6ff;
--color-danger: #dc2626;
--color-danger-subtle: #fef2f2;
--color-success: #16a34a;
--color-success-subtle: #f0fdf4;
--color-warning: #d97706;
/* Spacing — 4px base, t-shirt sizes */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
/* Radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
/* Shadow — depth levels */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-focus: 0 0 0 3px rgb(37 99 235 / 0.2);
/* Transitions */
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
[data-theme="dark"] {
--color-surface: #0f172a;
--color-surface-raised: #1e293b;
--color-surface-sunken: #020617;
--color-border: #334155;
--color-border-strong: #475569;
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-primary-subtle: #172554;
--color-danger-subtle: #450a0a;
--color-success-subtle: #052e16;
}
```
### Responsive Breakpoints
```css
/* mobile-first: base → sm → md → lg → xl */
/* 640px → sm (landscape phone) */
/* 768px → md (tablet) */
/* 1024px → lg (laptop) */
/* 1280px → xl (desktop) */
/* 1536px → 2xl (wide) */
.grid-auto {
display: grid;
gap: var(--space-4);
grid-template-columns: 1fr;
}
@media (min-width: 640px) { .grid-auto { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .grid-auto { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1280px) { .grid-auto { grid-template-columns: repeat(4, 1fr); } }
```
### Interactive States
```css
/* focus-visible > focus — keyboard only, not mouse clicks */
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* minimum 44x44px touch target */
.btn {
min-height: 44px;
min-width: 44px;
padding: var(--space-2) var(--space-4);
transition: all var(--duration-normal) var(--ease-out);
}
/* disabled state — opacity, not color change */
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
```
## Next.js App Router
### Server Components (default)
```typescript
// app/dashboard/page.tsx — server component by default
// DB query here, no "use client", no useState, no useEffect
import { db } from '@/lib/db'
export default async function DashboardPage() {
const stats = await db.query.stats.findMany()
return (
<div>
<h1>Dashboard</h1>
<StatGrid stats={stats} />
<Suspense fallback={<TableSkeleton rows={10} />}>
<RecentActivity />
</Suspense>
</div>
)
}
// async child — streams in when ready
async function RecentActivity() {
const activity = await db.query.activity.findMany({
orderBy: (a, { desc }) => [desc(a.createdAt)],
limit: 20,
})
return <ActivityTable rows={activity} />
}
```
### Client Components — only when needed
```typescript
'use client'
// push "use client" as LOW as possible in the tree
// wrong: entire page is client
// right: only the interactive widget
import { useState, useTransition } from 'react'
export function SearchFilter({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
return (
<input
value={query}
onChange={e => {
setQuery(e.target.value)
startTransition(() => onSearch(e.target.value))
}}
placeholder="Search..."
aria-label="Search"
className={isPending ? 'opacity-60' : ''}
/>
)
}
```
### Server Actions
```typescript
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']),
})
export async function createTask(formData: FormData) {
const parsed = CreateTaskSchema.safeParse({
title: formData.get('title'),
description: formData.get('description'),
priority: formData.get('priority'),
})
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await db.insert(tasks).values(parsed.data)
revalidatePath('/tasks')
redirect('/tasks')
}
```
### Parallel Routes + Loading
```
app/
dashboard/
@stats/
page.tsx ← parallel: stats panel
loading.tsx ← skeleton while stats load
@activity/
page.tsx ← parallel: activity feed
loading.tsx
layout.tsx ← renders both slots
page.tsx
```
```typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats,
activity,
}: {
children: React.ReactNode
stats: React.ReactNode
activity: React.ReactNode
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6">
<div>
{children}
{activity}
</div>
<aside>{stats}</aside>
</div>
)
}
```
### Route Handlers
```typescript
// app/api/tasks/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const page = parseInt(searchParams.get('page') ?? '1', 10)
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100)
const tasks = await db.query.tasks.findMany({
offset: (page - 1) * limit,
limit,
orderBy: (t, { desc }) => [desc(t.createdAt)],
})
return NextResponse.json({
data: tasks,
meta: { page, limit, total: await db.select({ count: count() }).from(tasks) },
})
}
export async function POST(request: NextRequest) {
const body = await request.json()
// validate with zod, then insert
}
```
## Frontend Testing
### Component Testing — React Testing Library
```typescript
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TaskForm } from './TaskForm'
describe('TaskForm', () => {
const user = userEvent.setup()
it('submits with valid data', async () => {
const onSubmit = vi.fn()
render(<TaskForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/title/i), 'Fix login bug')
await user.selectOptions(screen.getByLabelText(/priority/i), 'high')
await user.click(screen.getByRole('button', { name: /create/i }))
expect(onSubmit).toHaveBeenCalledWith({
title: 'Fix login bug',
priority: 'high',
})
})
it('shows validation error on empty title', async () => {
render(<TaskForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /create/i }))
expect(screen.getByText(/title is required/i)).toBeInTheDocument()
})
it('disables submit while loading', async () => {
render(<TaskForm onSubmit={() => new Promise(() => {})} />)
await user.type(screen.getByLabelText(/title/i), 'Test')
await user.click(screen.getByRole('button', { name: /create/i }))
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
})
})
```
### Hook Testing
```typescript
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from './useDebounce'
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500))
expect(result.current).toBe('hello')
})
it('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } }
)
rerender({ value: 'world', delay: 500 })
expect(result.current).toBe('hello')
act(() => vi.advanceTimersByTime(500))
expect(result.current).toBe('world')
})
})
```
### API Mocking — MSW
```typescript
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const handlers = [
http.get('/api/tasks', () => {
return HttpResponse.json({
data: [
{ id: '1', title: 'Task 1', priority: 'high' },
{ id: '2', title: 'Task 2', priority: 'low' },
],
meta: { total: 2, page: 1 },
})
}),
http.post('/api/tasks', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: '3', ...body }, { status: 201 })
}),
]
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
it('renders task list from API', async () => {
render(<TaskList />)
expect(await screen.findByText('Task 1')).toBeInTheDocument()
expect(screen.getByText('Task 2')).toBeInTheDocument()
})
it('handles API error', async () => {
server.use(
http.get('/api/tasks', () => HttpResponse.json(null, { status: 500 }))
)
render(<TaskList />)
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument()
})
```
### Snapshot-Free Testing
```typescript
// snapshots break on every CSS change — test behavior instead
// WRONG: expect(component).toMatchSnapshot()
// RIGHT: test what the user sees and does
it('shows empty state when no tasks', () => {
render(<TaskList tasks={[]} />)
expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /create/i })).toHaveAttribute('href', '/tasks/new')
})
```
## Tailwind Patterns
### cn() Utility — merge conditional classes
```typescript
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// usage
<div className={cn(
'rounded-lg border p-4',
variant === 'danger' && 'border-red-300 bg-red-50',
variant === 'success' && 'border-green-300 bg-green-50',
className
)} />
```
### Responsive Layout
```tsx
// mobile-first: stack → 2col → 3col
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
// sidebar layout: full-width mobile, sidebar on desktop
<div className="flex flex-col lg:flex-row lg:gap-8">
<main className="flex-1 min-w-0">{children}</main>
<aside className="w-full lg:w-80 lg:shrink-0">{sidebar}</aside>
</div>
```
### Component Variants — cva
```typescript
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-blue-600 text-white hover:bg-blue-700',
outline: 'border border-gray-300 bg-white hover:bg-gray-50',
ghost: 'hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
```
### Dark Mode
```tsx
// tailwind.config.ts
export default {
darkMode: 'class',
// ...
}
// toggle
<button onClick={() => document.documentElement.classList.toggle('dark')}>
Toggle
</button>
// usage — always pair light/dark
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<p className="text-gray-600 dark:text-gray-400">Secondary text</p>
</div>
```
### Tailwind Anti-Patterns
```tsx
// WRONG: string interpolation — Tailwind can't detect at build time
<div className={`text-${color}-500`} />
// RIGHT: map to full class names
const colorMap = {
red: 'text-red-500',
blue: 'text-blue-500',
green: 'text-green-500',
} as const
<div className={colorMap[color]} />
// WRONG: @apply everywhere — defeats the purpose
// RIGHT: @apply only in base layer for truly repeated patterns
@layer base {
h1 { @apply text-3xl font-bold tracking-tight; }
}
// WRONG: arbitrary values for things that should be tokens
<div className="p-[13px] text-[#3b82f6]" />
// RIGHT: extend theme if you need custom values
// tailwind.config.ts → theme.extend.spacing / colors
```Related Skills
websocket-patterns
Connection management, room patterns, reconnection strategies, message buffering, and binary protocol design.
vector-db-patterns
Embedding strategies, ANN algorithms, hybrid search, RAG chunking strategies, and reranking for semantic search and retrieval.
tracing-patterns
OpenTelemetry setup, span context propagation, sampling strategies, Jaeger queries
terraform-patterns
Module composition, state management, workspace strategy, provider versioning, and infrastructure-as-code best practices.
swift-patterns
SwiftUI view composition, @Observable patterns, async/await concurrency, TCA architecture, and Combine reactive streams.
springboot-patterns
Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work.
seo-patterns
Meta tag patterns, structured data (JSON-LD), Core Web Vitals optimization, and SSR/SSG strategies for search visibility.
secret-patterns
30+ service-specific secret detection regex patterns, entropy-based detection, PEM/JWT/Base64 identification, and false positive filtering.
saas-payment-patterns
Payment provider abstraction, webhook security, subscription lifecycle, dunning flows, pricing models, invoicing, tax handling, and refund patterns for SaaS applications.
saas-auth-patterns
SaaS authentication and authorization patterns including JWT vs session strategies, multi-tenant isolation, RBAC, API key management, passwordless flows, MFA, and secure session handling.
saas-analytics-patterns
SaaS analytics event taxonomy, metric formulas (MRR, churn, LTV), provider-agnostic tracking, funnel analysis, cohort setup, and privacy-respecting instrumentation.
revenuecat-patterns
RevenueCat SDK entegrasyon pattern'leri. iOS (Swift), Android (Kotlin), React Native ve Flutter icin setup, offerings, entitlement checking, webhook integration, StoreKit 2 migration ve sandbox testing.