Patterns Still Matter (Even with Hooks)
React Hooks changed how we write components, but the fundamental patterns of component design — separation of concerns, composition over inheritance, inversion of control — remain as relevant as ever.
The difference is that hooks made several patterns easier to implement and some patterns less necessary. Understanding which patterns solve which problems is what separates maintainable React codebases from ones that accumulate technical debt.
Custom Hooks: The Universal Pattern
Custom hooks aren't just about reusing stateful logic — they're about separating what from how:
// ❌ Logic tangled with presentation
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetchUser(userId)
.then(data => { if (!cancelled) setUser(data) })
.catch(err => { if (!cancelled) setError(err) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [userId])
if (loading) return <Spinner />
if (error) return <ErrorDisplay error={error} />
if (!user) return null
return <div>{user.name}</div>
}
// ✅ Separated into custom hook
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetchUser(userId)
.then(data => { if (!cancelled) setUser(data) })
.catch(err => { if (!cancelled) setError(err) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [userId])
return { user, loading, error }
}
// Clean, testable component
function UserProfile({ userId }: { userId: string }) {
const { user, loading, error } = useUser(userId)
if (loading) return <Spinner />
if (error) return <ErrorDisplay error={error} />
if (!user) return null
return <div>{user.name}</div>
}
Custom Hook Patterns
// Pattern: Encapsulating complex state machines
type Status = 'idle' | 'loading' | 'success' | 'error'
function useAsync<T>(asyncFn: () => Promise<T>) {
const [status, setStatus] = useState<Status>('idle')
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const execute = useCallback(async () => {
setStatus('loading')
setData(null)
setError(null)
try {
const result = await asyncFn()
setData(result)
setStatus('success')
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)))
setStatus('error')
}
}, [asyncFn])
return { status, data, error, execute, isLoading: status === 'loading' }
}
// Usage:
const { data, isLoading, execute } = useAsync(() => fetchUser(userId))
// Pattern: External store synchronization
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 = useCallback((value: T | ((val: T) => T)) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value
if (typeof window !== 'undefined') {
localStorage.setItem(key, JSON.stringify(valueToStore))
}
return valueToStore
})
}, [key])
return [storedValue, setValue] as const
}
// Usage:
const [theme, setTheme] = useLocalStorage('theme', 'light')
Compound Components
Compound components create APIs that are flexible and readable — they let parent and children share implicit state without prop drilling:
// ❌ Monolithic component with too many props
<Tabs
tabs={[{ label: 'Tab 1', content: <Content1 /> }, { label: 'Tab 2', content: <Content2 /> }]}
activeTab={active}
onTabChange={setActive}
tabStyle="underline"
contentStyle="padded"
/>
// ✅ Compound component — flexible, readable
<Tabs defaultValue="tab1">
<Tabs.List>
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
</Tabs.List>
<Tabs.Content value="tab1"><Content1 /></Tabs.Content>
<Tabs.Content value="tab2"><Content2 /></Tabs.Content>
</Tabs>
Implementation:
// Context for shared state
const TabsContext = createContext<{
value: string
onChange: (value: string) => void
} | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('useTabs must be used within <Tabs>')
return ctx
}
// Root component manages state
function Tabs({ defaultValue, children }: { defaultValue: string, children: React.ReactNode }) {
const [value, setValue] = useState(defaultValue)
return (
<TabsContext.Provider value={{ value, onChange: setValue }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
// Subcomponents consume context
function TabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className="tabs-list">{children}</div>
}
function Tab({ value, children }: { value: string, children: React.ReactNode }) {
const { value: activeValue, onChange } = useTabs()
const isActive = value === activeValue
return (
<button
role="tab"
aria-selected={isActive}
className={isActive ? 'tab active' : 'tab'}
onClick={() => onChange(value)}
>
{children}
</button>
)
}
function TabsContent({ value, children }: { value: string, children: React.ReactNode }) {
const { value: activeValue } = useTabs()
if (value !== activeValue) return null
return <div role="tabpanel">{children}</div>
}
// Attach subcomponents to root
Tabs.List = TabsList
Tabs.Tab = Tab
Tabs.Content = TabsContent
Render Props (Still Useful)
Render props fell out of fashion with hooks, but they're still the best solution for sharing rendering logic while giving consumers full control over the output:
// Virtualized list — consumer controls how each item renders
interface VirtualListProps<T> {
items: T[]
itemHeight: number
renderItem: (item: T, index: number) => React.ReactNode
containerHeight: number
}
function VirtualList<T>({ items, itemHeight, renderItem, containerHeight }: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0)
const startIndex = Math.floor(scrollTop / itemHeight)
const visibleCount = Math.ceil(containerHeight / itemHeight)
const endIndex = Math.min(startIndex + visibleCount + 1, items.length)
const visibleItems = items.slice(startIndex, endIndex)
const totalHeight = items.length * itemHeight
const offsetY = startIndex * itemHeight
return (
<div
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, i) => renderItem(item, startIndex + i))}
</div>
</div>
</div>
)
}
// Usage — consumer decides the render
<VirtualList
items={users}
itemHeight={60}
containerHeight={400}
renderItem={(user, index) => (
<UserRow key={user.id} user={user} highlighted={index % 2 === 0} />
)}
/>
Controlled vs Uncontrolled Components
This is one of the most common sources of bugs in React:
// Uncontrolled — component owns its state
// Use when: simple, isolated, form data only needed on submit
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null)
function handleSubmit() {
console.log(inputRef.current?.value) // Read on demand
}
return <input ref={inputRef} defaultValue="initial" />
}
// Controlled — parent owns state
// Use when: real-time validation, dependent fields, external state
function ControlledInput({ value, onChange }: { value: string, onChange: (v: string) => void }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
The Flexible "Controlled or Uncontrolled" Pattern
Libraries like Radix UI use this pattern — components work both ways:
interface AccordionProps {
// Controlled API
value?: string | null
onValueChange?: (value: string | null) => void
// Uncontrolled API
defaultValue?: string | null
}
function Accordion({ value: controlledValue, onValueChange, defaultValue }: AccordionProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? null)
// If value prop is provided, use controlled mode
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
function handleChange(newValue: string | null) {
if (!isControlled) {
setUncontrolledValue(newValue) // Manage state internally
}
onValueChange?.(newValue) // Always notify parent
}
return (/* ... */)
}
// Works uncontrolled (simple usage):
<Accordion defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Works controlled (complex usage):
<Accordion value={openItem} onValueChange={setOpenItem}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
State Machines for Complex UI
For components with non-trivial state logic, useReducer + TypeScript discriminated unions are more maintainable than multiple useState calls:
type UploadState =
| { status: 'idle' }
| { status: 'selecting' }
| { status: 'uploading'; progress: number; file: File }
| { status: 'success'; url: string; file: File }
| { status: 'error'; error: string; file: File }
type UploadAction =
| { type: 'SELECT' }
| { type: 'START'; file: File }
| { type: 'PROGRESS'; progress: number }
| { type: 'SUCCESS'; url: string }
| { type: 'ERROR'; error: string }
| { type: 'RESET' }
function uploadReducer(state: UploadState, action: UploadAction): UploadState {
switch (action.type) {
case 'SELECT': return { status: 'selecting' }
case 'START': return { status: 'uploading', progress: 0, file: action.file }
case 'PROGRESS':
if (state.status !== 'uploading') return state
return { ...state, progress: action.progress }
case 'SUCCESS':
if (state.status !== 'uploading') return state
return { status: 'success', url: action.url, file: state.file }
case 'ERROR':
if (state.status !== 'uploading') return state
return { status: 'error', error: action.error, file: state.file }
case 'RESET': return { status: 'idle' }
default: return state
}
}
function FileUploader() {
const [state, dispatch] = useReducer(uploadReducer, { status: 'idle' })
async function handleFileSelect(file: File) {
dispatch({ type: 'START', file })
try {
const url = await uploadWithProgress(file, (progress) => {
dispatch({ type: 'PROGRESS', progress })
})
dispatch({ type: 'SUCCESS', url })
} catch (e) {
dispatch({ type: 'ERROR', error: String(e) })
}
}
// TypeScript narrows state.status in each branch:
return (
<div>
{state.status === 'idle' && <DropZone onSelect={handleFileSelect} />}
{state.status === 'uploading' && <ProgressBar value={state.progress} />}
{state.status === 'success' && <SuccessView url={state.url} onReset={() => dispatch({ type: 'RESET' })} />}
{state.status === 'error' && <ErrorView error={state.error} onRetry={() => dispatch({ type: 'RESET' })} />}
</div>
)
}
Composition Over Configuration
The "one component with many props" anti-pattern leads to API bloat. Composition scales better:
// ❌ Config-heavy component — hard to extend
<Card
title="User Profile"
subtitle="Member since 2021"
avatar="/avatar.jpg"
badge="Pro"
action={<Button>Edit</Button>}
footer={<Stats />}
variant="horizontal"
elevated
/>
// ✅
// ✅ Composable card — flexible, extensible
<Card>
<Card.Header>
<Avatar src="/avatar.jpg" />
<div>
<Card.Title>User Profile</Card.Title>
<Card.Subtitle>Member since 2021</Card.Subtitle>
</div>
<Badge variant="pro">Pro</Badge>
</Card.Header>
<Card.Body>
<Stats />
</Card.Body>
<Card.Footer>
<Button>Edit</Button>
</Card.Footer>
</Card>
The Provider Pattern
For cross-cutting concerns that don't fit into component props, the Provider pattern keeps things clean:
// Theme provider — affects entire subtree
interface ThemeContextValue {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
return (localStorage.getItem('theme') as 'light' | 'dark') ?? 'light'
})
const toggleTheme = useCallback(() => {
setTheme(prev => {
const next = prev === 'light' ? 'dark' : 'light'
localStorage.setItem('theme', next)
return next
})
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div data-theme={theme}>{children}</div>
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
Portals: Rendering Outside the Tree
For modals, tooltips, and dropdowns that need to escape CSS overflow/z-index constraints:
import { createPortal } from 'react-dom'
import { useEffect, useRef } from 'react'
function Modal({ children, onClose }: { children: React.ReactNode, onClose: () => void }) {
const portalRoot = document.getElementById('modal-root') ?? document.body
// Trap focus and handle Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>,
portalRoot
)
}
Error Boundaries
Class components still have one exclusive use case — Error Boundaries:
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback: ReactNode | ((error: Error) => ReactNode)
}
interface State {
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error('Error boundary caught:', error, info)
}
render() {
if (this.state.error) {
const { fallback } = this.props
return typeof fallback === 'function'
? fallback(this.state.error)
: fallback
}
return this.props.children
}
}
// Usage
<ErrorBoundary
fallback={(error) => (
<div className="error-state">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
)}
>
<RiskyComponent />
</ErrorBoundary>
Pattern Anti-Patterns to Avoid
1. Prop drilling beyond 2 levels: Use context or composition instead.
2. God components: If a component file exceeds ~300 lines, it's doing too much. Extract logic into hooks and sub-components.
3. Overusing useEffect: Most data transformation belongs in render logic or custom hooks, not effects.
4. Memo everywhere: React.memo, useMemo, and useCallback are optimizations. Apply them after profiling shows a problem, not preemptively.
// ❌ Premature optimization — adds complexity, rarely needed
const value = useMemo(() => data.filter(x => x.active), [data])
// ✅ Only memoize if this computation is genuinely expensive
// or if the reference equality matters for child re-renders
React patterns are ultimately about managing complexity. Start simple (useState, props), extract when duplication appears (custom hooks), and reach for advanced patterns (compound components, context) only when composition alone isn't enough. The best pattern is the simplest one that solves the problem.
→ Hash and verify text integrity with the Hash Text tool.