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.
Table of Contents
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 when | Wrapping/replacing render, injecting JSX (error boundaries, loading states) | Sharing stateful logic without changing component tree |
| Prop conflicts | Risk of prop name collision | No collision — values are destructured locally |
| DevTools | Adds wrapper components — deeper tree | Clean — no extra components |
| Auth guards | HOC is cleaner — wrap the export | Requires manual redirect logic in every component |
| Data fetching | Overhead — prefer hooks | Natural fit |