Difficulty: Advanced
What are Higher-Order Components? How do they compare to hooks and render props?
Higher-Order Components, or HOCs, are a pattern that was central to React development for years before hooks came along. Even though hooks have replaced most HOC use cases, understanding HOCs is important because you will encounter them in legacy codebases, in libraries like Redux's connect(), and the pattern teaches you important composition concepts.
At its core, a Higher-Order Component is a function that takes a component as an argument and returns a new, enhanced component. It is directly inspired by higher-order functions in functional programming -- just as a higher-order function takes a function and returns a new function, a HOC takes a component and returns a new component. The original component is not modified; it is wrapped.
The classic example is withAuth. You write a function that takes any component, checks whether the user is authenticated, and either renders the wrapped component (passing along all props plus user data) or redirects to the login page. Now any component you wrap with withAuth automatically gets authentication protection. You write the auth logic once and apply it everywhere by wrapping: const ProtectedDashboard = withAuth(Dashboard).
Other common HOC use cases include: withLoading (adds a loading spinner while data is being fetched), withErrorBoundary (wraps a component with error handling), withTracking (adds analytics page view tracking), and connect from Redux (injects store state and dispatch as props).
HOCs compose through nesting: const EnhancedPage = withAuth(withLoading(withTracking(Page))). Each HOC wraps the previous result. This is powerful but leads to the first major problem: wrapper hell. In React DevTools, instead of seeing Page, you see WithAuth > WithLoading > WithTracking > Page. The component tree gets deeply nested, which makes debugging harder.
The second problem is prop collision. If your component has a prop called data and the withLoading HOC also injects a prop called data, one overwrites the other silently. With hooks, this cannot happen because each hook returns values into its own variable.
The third problem is that HOCs require special handling for refs and static methods. When you wrap a component in a HOC, the ref points to the wrapper component, not the inner component. You need to use forwardRef to pass the ref through. Similarly, static methods on the original component are lost -- you need the hoist-non-react-statics library to copy them to the wrapper. And for debugging, you need to set displayName on the wrapper component so React DevTools shows a meaningful name.
Hooks solved all of these problems. Instead of wrapping your component in withAuth, you call useAuth() inside it. No wrapper, no prop collision, no ref forwarding needed. The logic is inline and explicit -- you can see exactly where each piece of data comes from. This is why the React community has largely moved away from HOCs.
However, there are still cases where HOCs make sense. If you need to modify what a component renders (wrapping it in a provider, adding a sibling element, or injecting a wrapper div), hooks cannot do that because hooks only share logic, not rendered output. HOCs can modify the rendered output because they control what gets returned. HOCs also work with class components, while hooks do not.
The naming convention is to prefix HOCs with 'with': withAuth, withRouter, withTheme. Never create a HOC inside a render function -- that creates a new component type on every render, which forces React to unmount and remount the inner component, destroying all state. Always define HOCs at the module level.
// HOC that adds authentication
function withAuth(WrappedComponent) {
return function AuthenticatedComponent(props) {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
// Pass user data + original props to wrapped component
return <WrappedComponent {...props} user={user} />;
};
}
// HOC that adds loading state
function withLoading(WrappedComponent) {
return function LoadingComponent({ isLoading, ...props }) {
if (isLoading) return <Spinner />;
return <WrappedComponent {...props} />;
};
}
// Usage: compose HOCs
const EnhancedDashboard = withAuth(withLoading(Dashboard));
// In JSX:
<EnhancedDashboard isLoading={loading} someProp="value" />
// The component tree becomes:
// AuthenticatedComponent -> LoadingComponent -> Dashboard
// This nesting is the "wrapper hell" problem
HOCs wrap components to add behavior. Composition chains them, but deep nesting makes debugging harder. Each HOC adds a layer to the component tree.
// HOC approach: wraps the component
function withWindowSize(WrappedComponent) {
return function WindowSizeComponent(props) {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <WrappedComponent {...props} windowSize={size} />;
};
}
const ResponsiveChart = withWindowSize(Chart);
// Props: <ResponsiveChart data={data} />
// Chart receives: { data, windowSize: { width, height } }
// Hook approach: used inside the component
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
function ResponsiveChart({ data }) {
const { width, height } = useWindowSize();
return <Chart data={data} width={width} height={height} />;
}
The hook version is more explicit (you see exactly what data comes from where), has no wrapper, and no prop collision risk. Hooks are the modern replacement for most HOC patterns.
import hoistNonReactStatics from 'hoist-non-react-statics';
// Good HOC with proper displayName, ref forwarding, and static hoisting
function withAnalytics(WrappedComponent, eventName) {
const WithAnalytics = forwardRef(function WithAnalytics(props, ref) {
useEffect(() => {
trackPageView(eventName);
}, []);
function trackInteraction(action) {
trackEvent(eventName, action);
}
return (
<WrappedComponent
ref={ref}
{...props}
onTrack={trackInteraction}
/>
);
});
// Set displayName for DevTools debugging
const wrappedName = WrappedComponent.displayName
|| WrappedComponent.name
|| 'Component';
WithAnalytics.displayName = `withAnalytics(${wrappedName})`;
// Copy static methods from wrapped component
hoistNonReactStatics(WithAnalytics, WrappedComponent);
return WithAnalytics;
}
// Usage
const TrackedCheckout = withAnalytics(CheckoutPage, 'checkout');
// In DevTools: withAnalytics(CheckoutPage)
// Instead of: Anonymous or ForwardRef
Production HOCs should forward refs (forwardRef), set displayName for debugging, and hoist static methods. These details are why hooks are preferred - they don't have these requirements.
HOC, Composition, Cross-cutting Concerns, Wrapper Pattern