React Component Patterns
Help users write well-structured React components with clean composition, proper state management, and maintainable patterns. Assumes Next.js + Tailwind CSS environment.
Component Composition
Prefer composition over props overload
Instead of a component with many conditional props, compose smaller focused components together.
Avoid — prop-heavy monolith:
<Card
title="Revenue"
subtitle="Monthly"
icon="chart"
showBorder
variant="highlighted"
headerAction={<Button>Export</Button>}
footer={<Link>View details</Link>}
/>
Prefer — composable parts:
<Card>
<Card.Header>
<Card.Title>Revenue</Card.Title>
<Card.Subtitle>Monthly</Card.Subtitle>
<Button>Export</Button>
</Card.Header>
<Card.Body>
<RevenueChart data={data} />
</Card.Body>
<Card.Footer>
<Link href="/revenue">View details</Link>
</Card.Footer>
</Card>
Compound Components
Use compound components when a group of components share implicit state and always work together (tabs, accordions, selects, menus).
"use client"
import { createContext, useContext, useState, type ReactNode } from 'react'
type TabsContextType = { activeTab: string; setActiveTab: (id: string) => void }
const TabsContext = createContext<TabsContextType | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tab components must be used within <Tabs>')
return ctx
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div>{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }: { children: ReactNode }) {
return <div className="flex gap-1 border-b border-gray-200">{children}</div>
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs()
return (
<button
=> setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{children}
</button>
)
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs()
if (activeTab !== id) return null
return <div className="py-4">{children}</div>
}
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
export { Tabs }
Usage:
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="analytics">Analytics</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">Overview content</Tabs.Panel>
<Tabs.Panel id="analytics">Analytics content</Tabs.Panel>
</Tabs>
Props Design
Keep props interfaces focused
Each component should accept only the props it needs. Use TypeScript to be explicit:
interface UserCardProps {
name: string
email: string
avatarUrl?: string
role: 'admin' | 'member' | 'viewer'
}
export function UserCard({ name, email, avatarUrl, role }: UserCardProps) {
// ...
}
Spread HTML attributes for wrapper components
For components that wrap native elements, extend the native element's props:
import { type ButtonHTMLAttributes, forwardRef } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', className, children, ...props }, ref) => {
const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'text-gray-600 hover:bg-gray-100',
}
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
return (
<button
ref={ref}
className={`${base} ${variants[variant]} ${sizes[size]} ${className ?? ''}`}
{...props}
>
{children}
</button>
)
}
)
Button.displayName = 'Button'
export { Button }
children vs render props
- Use
childrenfor most cases — it's simpler and more readable. - Use render props (or render function children) when the child needs data from the parent:
<DataLoader url="/api/users">
{({ data, isLoading }) =>
isLoading ? <Skeleton /> : <UserList users={data} />
}
</DataLoader>
Custom Hooks
Extract logic, not JSX
Custom hooks should encapsulate stateful logic and side effects, returning values and callbacks — not components.
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})
const setValue = (value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
return [storedValue, setValue] as const
}
Hook naming conventions
- Always prefix with
use - Name should describe what data/behavior it provides:
useAuth,useMediaQuery,useClickOutside - Return consistent shapes:
[value, setter]for simple state,{ data, error, isLoading }for async
State Management
Start simple, scale as needed
- Local state (useState) — for state that belongs to one component
- Lifted state — when a sibling needs access, lift to the nearest common parent
- Context — for state shared across a subtree (theme, auth, locale)
- URL state (searchParams) — for state that should be shareable/bookmarkable (filters, pagination, tabs)
- External store (Zustand, Jotai) — for complex client-side state shared across many components
Context — avoid the "god provider" anti-pattern
Split contexts by domain rather than creating one massive AppContext:
// Separate concerns into focused providers
<AuthProvider>
<ThemeProvider>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
</AuthProvider>
Each provider manages its own slice of state. Components only subscribe to the contexts they need, which avoids unnecessary re-renders.
URL state for user-facing state
Filters, sort order, pagination, and active tabs should live in the URL so users can share and bookmark views:
"use client"
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
function useQueryState(key: string, defaultValue: string) {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const value = searchParams.get(key) ?? defaultValue
const setValue = (newValue: string) => {
const params = new URLSearchParams(searchParams.toString())
if (newValue === defaultValue) {
params.delete(key)
} else {
params.set(key, newValue)
}
router.replace(`${pathname}?${params.toString()}`)
}
return [value, setValue] as const
}
Controlled vs Uncontrolled Components
Support both patterns
Build form components that work controlled (parent owns the state) or uncontrolled (component owns its own state via defaultValue):
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label: string
error?: string
}
export function Input({ label, error, id, ...props }: InputProps) {
const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-')
return (
<div>
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
id={inputId}
className={`w-full px-3 py-2 border rounded-lg text-sm transition-colors
${error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
} focus:outline-none focus:ring-2`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
)
}
Works both ways:
// Controlled
<Input label="Email" value={email} => setEmail(e.target.value)} />
// Uncontrolled
<Input label="Email" defaultValue="user@example.com" />
Error Boundaries
Wrap sections, not the entire app
Place error boundaries around independent sections so a failure in one area doesn't crash everything:
"use client"
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800 font-medium">Something went wrong</p>
<p className="text-red-600 text-sm mt-1">{this.state.error?.message}</p>
</div>
)
}
return this.props.children
}
}
Usage:
<div className="grid grid-cols-2 gap-4">
<ErrorBoundary fallback={<WidgetError />}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError />}>
<UserActivity />
</ErrorBoundary>
</div>
Lists and Keys
Use stable, unique identifiers as keys
// Good — stable ID from data
{users.map(user => <UserCard key={user.id} user={user} />)}
// Avoid — index as key (breaks with reordering, insertion, deletion)
{users.map((user, i) => <UserCard key={i} user={user} />)}
Index keys are acceptable only for static lists that never change order.
Conditional Rendering
Keep it readable
// Simple boolean — use &&
{isAdmin && <AdminPanel />}
// Binary choice — use ternary
{isLoading ? <Skeleton /> : <Content data={data} />}
// Multiple conditions — use early returns or a mapping
function StatusBadge({ status }: { status: 'active' | 'pending' | 'inactive' }) {
const styles = {
active: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
inactive: 'bg-gray-100 text-gray-800',
}
return (
<span className={`px-2 py-1 text-xs font-medium rounded-full ${styles[status]}`}>
{status}
</span>
)
}