React HOC: Higher-Order Component Patterns (2026)

A Higher-Order Component is a function that takes a component and returns a new component with enhanced behavior. HOCs were the primary code-reuse pattern before hooks, and they're still valuable today for cross-cutting concerns like authentication guards, analytics tracking, feature flags and error boundaries that need to wrap many components at once.

HOC Basics

// A HOC is a function: Component => EnhancedComponent
function withEnhancement<P extends object>(WrappedComponent: React.ComponentType<P>) {
  // Return a new component
  function EnhancedComponent(props: P) {
    // Add behavior before rendering
    return <WrappedComponent {...props} />
  }

  // displayName helps in React DevTools
  EnhancedComponent.displayName = `withEnhancement(${WrappedComponent.displayName ?? WrappedComponent.name})`

  return EnhancedComponent
}

// Simple example: withBorder
function withBorder<P extends object>(WrappedComponent: React.ComponentType<P>) {
  return function BorderedComponent(props: P) {
    return (
      <div style={{ border: '2px solid #6366f1', borderRadius: 8, padding: 16 }}>
        <WrappedComponent {...props} />
      </div>
    )
  }
}

function UserCard({ name, email }: { name: string; email: string }) {
  return <div><h3>{name}</h3><p>{email}</p></div>
}

const BorderedUserCard = withBorder(UserCard)

// Usage — same props as UserCard
<BorderedUserCard name="Alice" email="alice@example.com" />

withAuth: Authentication Guard

import { useRouter } from 'next/navigation'

interface WithAuthOptions {
  redirectTo?: string
  requiredRole?: 'admin' | 'user'
}

function withAuth<P extends object>(
  WrappedComponent: React.ComponentType<P>,
  options: WithAuthOptions = {}
) {
  const { redirectTo = '/login', requiredRole } = options

  function AuthGuard(props: P) {
    const router = useRouter()
    const { user, isLoading } = useAuth()   // Your auth hook

    if (isLoading) {
      return (
        <div className="flex items-center justify-center h-64">
          <Spinner />
        </div>
      )
    }

    if (!user) {
      router.replace(`${redirectTo}?callbackUrl=${encodeURIComponent(window.location.pathname)}`)
      return null
    }

    if (requiredRole && user.role !== requiredRole) {
      return (
        <div className="text-center py-12">
          <h2>Access Denied</h2>
          <p>You need {requiredRole} role to view this page.</p>
        </div>
      )
    }

    return <WrappedComponent {...props} />
  }

  AuthGuard.displayName = `withAuth(${WrappedComponent.displayName ?? WrappedComponent.name})`
  return AuthGuard
}

// Usage
const AdminDashboard = withAuth(Dashboard, { requiredRole: 'admin' })
const ProfilePage = withAuth(Profile, { redirectTo: '/login' })

// In Next.js App Router — use at the page level
export default withAuth(AdminDashboard, { requiredRole: 'admin' })

withLoading: Data Fetching HOC

interface WithLoadingProps {
  isLoading: boolean
  error?: Error | null
}

// HOC adds loading/error states — component only receives clean data
function withLoading<P extends object>(
  WrappedComponent: React.ComponentType<Omit<P, keyof WithLoadingProps>>,
  LoadingComponent: React.ComponentType = DefaultSkeleton,
  ErrorComponent: React.ComponentType<{ error: Error }> = DefaultError
) {
  return function LoadingWrapper({ isLoading, error, ...props }: P & WithLoadingProps) {
    if (isLoading) return <LoadingComponent />
    if (error) return <ErrorComponent error={error} />
    return <WrappedComponent {...(props as Omit<P, keyof WithLoadingProps>)} />
  }
}

// Usage
function UserProfile({ user }: { user: User }) {
  return <div>{user.name}</div>
}

const UserProfileWithLoading = withLoading(UserProfile)

function UserPage({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery(['user', userId], () => fetchUser(userId))

  return (
    <UserProfileWithLoading
      user={user}
      isLoading={isLoading}
      error={error}
    />
  )
}

withErrorBoundary HOC

import { ErrorBoundary } from 'react-error-boundary'

interface WithErrorBoundaryOptions {
  fallback?: React.ReactElement
  onError?: (error: Error, info: React.ErrorInfo) => void
}

function withErrorBoundary<P extends object>(
  WrappedComponent: React.ComponentType<P>,
  options: WithErrorBoundaryOptions = {}
) {
  const { fallback, onError } = options

  function ErrorBoundaryWrapper(props: P) {
    return (
      <ErrorBoundary
        fallback={fallback ?? <DefaultErrorFallback />}
        onError={onError ?? ((error) => console.error('Component error:', error))}
      >
        <WrappedComponent {...props} />
      </ErrorBoundary>
    )
  }

  ErrorBoundaryWrapper.displayName = `withErrorBoundary(${WrappedComponent.name})`
  return ErrorBoundaryWrapper
}

// Apply to risky components
const SafeChart = withErrorBoundary(RevenueChart, {
  fallback: <div>Chart failed to load</div>,
  onError: (error) => Sentry.captureException(error),
})

const SafeUserTable = withErrorBoundary(UserTable)

withLogging: Analytics HOC

interface LoggingOptions {
  componentName?: string
  trackProps?: string[]
}

function withLogging<P extends object>(
  WrappedComponent: React.ComponentType<P>,
  options: LoggingOptions = {}
) {
  const name = options.componentName ?? WrappedComponent.displayName ?? WrappedComponent.name

  return function LoggedComponent(props: P) {
    // Track mount/unmount
    useEffect(() => {
      analytics.track('component_mounted', { component: name })
      return () => analytics.track('component_unmounted', { component: name })
    }, [])

    // Track specific prop changes
    const trackedProps = options.trackProps ?? []
    useEffect(() => {
      const values = Object.fromEntries(
        trackedProps.map(key => [key, (props as Record<string, unknown>)[key]])
      )
      analytics.track('component_props_changed', { component: name, ...values })
    }, trackedProps.map(key => (props as Record<string, unknown>)[key]))

    return <WrappedComponent {...props} />
  }
}

const TrackedCheckout = withLogging(CheckoutForm, {
  componentName: 'CheckoutForm',
  trackProps: ['step', 'cartTotal'],
})

TypeScript Generics

// Injecting props — HOC adds props, caller doesn't need to provide them
interface InjectedProps {
  currentUser: User
  permissions: string[]
}

function withCurrentUser<P extends InjectedProps>(
  WrappedComponent: React.ComponentType<P>
) {
  // OuterProps = P without the injected props
  type OuterProps = Omit<P, keyof InjectedProps>

  return function WithCurrentUser(outerProps: OuterProps) {
    const { user, permissions } = useCurrentUser()

    if (!user) return null

    return (
      <WrappedComponent
        {...(outerProps as P)}
        currentUser={user}
        permissions={permissions}
      />
    )
  }
}

// Component requires currentUser — but caller doesn't provide it
function AdminPanel({ currentUser, permissions, title }: InjectedProps & { title: string }) {
  return (
    <div>
      <h1>{title}</h1>
      <p>Welcome, {currentUser.name}</p>
      {permissions.includes('delete') && <DeleteButton />}
    </div>
  )
}

const ConnectedAdminPanel = withCurrentUser(AdminPanel)

// TypeScript knows title is required, currentUser is injected
<ConnectedAdminPanel title="Dashboard" />

Composing HOCs

// Compose multiple HOCs left-to-right
function compose<P extends object>(...hocs: Array<(c: React.ComponentType<P>) => React.ComponentType<P>>) {
  return (Component: React.ComponentType<P>) =>
    hocs.reduceRight((acc, hoc) => hoc(acc), Component)
}

// Apply all at once
const EnhancedDashboard = compose(
  withAuth,
  withErrorBoundary,
  (C) => withLogging(C, { componentName: 'Dashboard' })
)(Dashboard)

// Equivalent manual composition (inside-out)
const EnhancedDashboard2 = withAuth(
  withErrorBoundary(
    withLogging(Dashboard, { componentName: 'Dashboard' })
  )
)

HOC vs Hooks

Aspect HOC Custom Hook
Use whenWrapping/replacing render, injecting JSX (error boundaries, loading states)Sharing stateful logic without changing component tree
Prop conflictsRisk of prop name collisionNo collision — values are destructured locally
DevToolsAdds wrapper components — deeper treeClean — no extra components
Auth guardsHOC is cleaner — wrap the exportRequires manual redirect logic in every component
Data fetchingOverhead — prefer hooksNatural fit