Higher-Order Components — Enhance components with shared behavior by wrapping them in a function
Recipe
// HOC that adds authentication gating
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
function AuthenticatedComponent(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <LoadingSpinner />;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} />;
}
AuthenticatedComponent.displayName =
`withAuth(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
return AuthenticatedComponent;
}
// Usage
const ProtectedDashboard = withAuth(Dashboard);When to reach for this: When you need to apply the same cross-cutting concern (auth, logging, theming, data fetching) to many components without modifying them. Less common today thanks to hooks, but still useful for route-level wrappers and third-party library integration.
Working Example
import { useEffect, useRef, type ComponentType } from "react";
// HOC that tracks component visibility and reports analytics
function withVisibilityTracking<P extends { id: string }>(
WrappedComponent: ComponentType<P>,
eventName: string
) {
function TrackedComponent(props: P) {
const ref = useRef<HTMLDivElement>(null);
const reported = useRef(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !reported.current) {
reported.current = true;
analytics.track(eventName, { componentId: props.id });
}
},
{ threshold: 0.5 }
);
observer.observe(element);
return () => observer.disconnect();
}, [props.id]);
return (
<div ref={ref}>
<WrappedComponent {...props} />
</div>
);
}
TrackedComponent.displayName =
`withVisibilityTracking(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
return TrackedComponent;
}
// Usage
interface ProductCardProps {
id: string;
name: string;
price: number;
}
function ProductCard({ id, name, price }: ProductCardProps) {
return (
<div className="p-4 border rounded">
<h3>{name}</h3>
<p>${price}</p>
</div>
);
}
const TrackedProductCard = withVisibilityTracking(ProductCard, "product_viewed");
// In a page
function ProductGrid({ products }: { products: ProductCardProps[] }) {
return (
<div className="grid grid-cols-3 gap-4">
{products.map((p) => (
<TrackedProductCard key={p.id} {...p} />
))}
</div>
);
}What this demonstrates:
- HOC that adds IntersectionObserver tracking without modifying the original component
- Generic type constraint (
P extends { id: string }) ensures the wrapped component has required props displayNameset for React DevTools debugging- The original
ProductCardremains pure and testable on its own
Deep Dive
How It Works
- A HOC is a function that takes a component and returns a new component with enhanced behavior.
- The pattern follows the mathematical concept of function composition:
enhance(Component) => EnhancedComponent. - The HOC does not mutate the original component — it wraps it in a new one.
- The wrapper component can intercept props, inject new props, conditionally render, or add lifecycle effects.
- Multiple HOCs can be composed:
withAuth(withTheme(withAnalytics(Component))).
Parameters & Return Values
| Parameter | Type | Purpose |
|---|---|---|
WrappedComponent | ComponentType<P> | The component to enhance |
| Config (optional) | Varies | Configuration for the HOC behavior |
| Return value | ComponentType<P> (or modified) | A new component with added behavior |
Variations
Props injection HOC — adds new props to the wrapped component:
interface WithThemeProps {
theme: Theme;
}
function withTheme<P extends WithThemeProps>(
WrappedComponent: ComponentType<P>
) {
function ThemedComponent(props: Omit<P, keyof WithThemeProps>) {
const theme = useTheme();
return <WrappedComponent {...(props as P)} theme={theme} />;
}
ThemedComponent.displayName =
`withTheme(${WrappedComponent.displayName ?? WrappedComponent.name})`;
return ThemedComponent;
}Composed HOCs — combine multiple enhancements:
// Manual composition
const EnhancedComponent = withAuth(withTheme(withAnalytics(BaseComponent)));
// With a compose utility
import { compose } from "redux"; // or write your own
const enhance = compose(withAuth, withTheme, withAnalytics);
const EnhancedComponent = enhance(BaseComponent);TypeScript Notes
- Use
ComponentType<P>to accept both function and class components. - Use
Omit<P, keyof InjectedProps>to remove injected props from the external API. - Always constrain
P extends objectat minimum to avoid primitive types. - Set
displayNamefor every HOC to make React DevTools usable. - Consider using
React.forwardRefinside HOCs if the wrapped component needs ref forwarding.
Gotchas
-
Ref forwarding — Refs do not pass through HOCs automatically because
refis not a regular prop. Fix: UseReact.forwardRefinside the HOC wrapper. -
Static methods lost — Static properties on the original component are not copied to the wrapper. Fix: Use
hoist-non-react-staticsor manually copy needed statics. -
Prop name collisions — If the HOC injects a prop with the same name as an existing prop, it silently overwrites. Fix: Namespace injected props or use a unique prefix.
-
Re-creating the HOC on every render — Calling a HOC inside a component body creates a new component type each render, destroying all state. Fix: Always call HOCs at module scope or in a
useMemowith extreme caution. -
Debugging difficulty — Deeply nested HOCs create long component trees in DevTools. Fix: Set
displayNameon every HOC and consider whether hooks would be clearer.
Alternatives
| Approach | Trade-off |
|---|---|
| Higher-order components | Transparent enhancement; typing is complex, debugging harder |
| Custom hooks | Simpler, composable, better types; cannot wrap rendering |
| Render props | Explicit data flow; more verbose at call site |
| Middleware (Next.js) | Better for route-level concerns like auth in Next.js apps |
| Decorators (stage 3) | Syntactic sugar; not yet widely supported in React ecosystem |
FAQs
What exactly is a higher-order component (HOC)?
- A HOC is a function that takes a component and returns a new, enhanced component.
- It follows the pattern:
enhance(Component) => EnhancedComponent. - The HOC wraps the original component without mutating it.
Why are HOCs less common in modern React?
- Custom hooks can achieve the same behavior reuse with a simpler, more composable API.
- Hooks are easier to type in TypeScript and produce cleaner component trees in DevTools.
- HOCs are still useful for route-level wrappers and third-party library integration.
How do you compose multiple HOCs together?
// Manual composition
const Enhanced = withAuth(withTheme(withAnalytics(Base)));
// With a compose utility
const enhance = compose(withAuth, withTheme, withAnalytics);
const Enhanced = enhance(Base);- HOCs can be nested or combined with a
composeutility. - Composition reads right-to-left:
withAnalyticsis applied first, thenwithTheme, thenwithAuth.
Why is displayName important for HOCs?
- Without
displayName, React DevTools shows the wrapper component as "Anonymous" or the wrapper's internal name. - Setting
displayNametowithAuth(Dashboard)makes the component tree readable. - Always set it using the wrapped component's
displayNameornameproperty.
Gotcha: Why does calling a HOC inside a component body destroy state?
- Calling a HOC inside render creates a new component type on every render.
- React treats each new type as a different component and unmounts/remounts, destroying all state.
- Fix: always call HOCs at module scope, outside any component.
Gotcha: Why don't refs pass through HOCs automatically?
refis not a regular prop in React; it is handled specially and stripped before reaching the wrapped component.- The HOC wrapper receives the ref, not the inner component.
- Fix: use
React.forwardRefinside the HOC to forward refs to the wrapped component.
How do you type a HOC that injects props in TypeScript?
interface WithThemeProps { theme: Theme; }
function withTheme<P extends WithThemeProps>(
WrappedComponent: ComponentType<P>
) {
function Themed(props: Omit<P, keyof WithThemeProps>) {
const theme = useTheme();
return <WrappedComponent {...(props as P)} theme={theme} />;
}
return Themed;
}- Use
Omit<P, keyof InjectedProps>to remove injected props from the external API. - Constrain
P extends InjectedPropsso TypeScript knows the wrapped component accepts those props.
How do you use Omit to exclude injected props from a HOC's external API in TypeScript?
Omit<P, keyof InjectedProps>removes the injected prop names from the type the consumer sees.- The consumer does not need to pass
themewhen usingwithTheme(Component)because the HOC provides it. - Always constrain
P extends objectat minimum to prevent primitive type parameters.
What happens to static methods when you wrap a component with a HOC?
- Static properties on the original component are not automatically copied to the HOC wrapper.
- Fix: use the
hoist-non-react-staticspackage or manually copy needed statics. - This is a common source of bugs when the wrapped component has static methods like
getStaticProps.
What are the alternatives to HOCs for applying cross-cutting concerns?
- Custom hooks: simpler, composable, better TypeScript support, but cannot wrap rendering.
- Render props: explicit data flow, more verbose at the call site.
- Next.js middleware: better for route-level concerns like auth.
- Context providers: good for dependency injection without wrapping.
How can prop name collisions cause silent bugs in HOCs?
- If a HOC injects a prop with the same name as an existing prop, the injected value silently overwrites the consumer's value.
- This is hard to debug because there is no error or warning.
- Fix: namespace injected props (e.g.,
themeConfiginstead ofconfig) or use a unique prefix.
Related
- Render Props — Alternative that gives consumers rendering control
- Composition — Preferred over HOCs when behavior is not cross-cutting
- Context Patterns — Often replaces HOCs for dependency injection