正在加载,请稍候…

React Patterns in 2026: Compound Components, Render Props, and Custom Hooks

Modern React patterns for 2026: compound components, custom hooks, render props, controlled vs uncontrolled components, and the composition patterns that make codebases maintainable.

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.