
Higher-Order Components: Are They Still Relevant?
If you open a React codebase that's more than a few years old, you'll probably find exports like export default withRouter(connect(mapState)(withStyles(styles)(UserPage))). That's a stack of higher-order components, or HOCs, the main way React developers shared logic before hooks arrived. Each with function wraps a component and injects something into it: router props, Redux state, theme styles.
Hooks replaced most of those uses. useNavigate, useSelector, and useTheme give you the same data without wrapping anything. So it's fair to ask whether HOCs are dead. They're not, but their job has narrowed. They're still a good fit for a specific set of problems: wrapping components you don't own, cross-cutting concerns at module boundaries, and library APIs that need to decorate a component rather than run inside it.
In this post I'll explain what a HOC is, show how to write one correctly in TypeScript, walk through the problems that pushed the community toward hooks, and then look at where HOCs are still the right tool in React 19.
What Is a Higher-Order Component?
A higher-order component is a function that takes a component and returns a new component. The name comes from higher-order functions, which take or return functions:
const EnhancedComponent = withSomething(BaseComponent);
The returned component usually renders the original, passing through its props and adding or changing some. Here's the simplest useful HOC, one that logs when a component mounts and unmounts:
import { useEffect, type ComponentType } from "react";
export function withMountLogging<P extends object>(Wrapped: ComponentType<P>) {
function WithMountLogging(props: P) {
const name = Wrapped.displayName ?? Wrapped.name ?? "Component";
useEffect(() => {
console.log(`${name} mounted`);
return () => console.log(`${name} unmounted`);
}, [name]);
return <Wrapped {...props} />;
}
WithMountLogging.displayName = `withMountLogging(${Wrapped.displayName ?? Wrapped.name})`;
return WithMountLogging;
}
Usage:
function Dashboard({ userId }: { userId: string }) {
return <h1>Dashboard for {userId}</h1>;
}
export default withMountLogging(Dashboard);
Notice the wrapper itself is a function component that uses hooks. HOCs and hooks aren't opposites. A modern HOC is often a thin wrapper around a hook.
Injecting Props: The Classic Use Case
Most historical HOCs injected props. connect injected Redux state, withRouter injected history and match, and so on. Here's a modern example that injects the current user and handles the loading state, so the wrapped component can assume the user exists:
import type { ComponentType } from "react";
import { useCurrentUser, type User } from "./auth";
type WithUserProps = { user: User };
export function withUser<P extends WithUserProps>(Wrapped: ComponentType<P>) {
function WithUser(props: Omit<P, keyof WithUserProps>) {
const { user, status } = useCurrentUser();
if (status === "loading") return <p>Loading...</p>;
if (!user) return <p>Please sign in.</p>;
return <Wrapped {...(props as P)} user={user} />;
}
WithUser.displayName = `withUser(${Wrapped.displayName ?? Wrapped.name})`;
return WithUser;
}
The typing is the interesting part. P extends WithUserProps requires the wrapped component to accept a user prop. The returned component's props are Omit<P, "user">, so callers don't pass user, because the HOC provides it:
type ProfileProps = { user: User; showEmail: boolean };
function Profile({ user, showEmail }: ProfileProps) {
return (
<section>
<h2>{user.name}</h2>
{showEmail && <p>{user.email}</p>}
</section>
);
}
const ProfileWithUser = withUser(Profile);
// Callers pass showEmail but not user
<ProfileWithUser showEmail />;
The props as P cast is needed because TypeScript can't prove that Omit<P, "user"> plus user equals P for a generic P. It's a known limitation, and the cast is safe because we add exactly the omitted key.
The Same Thing with a Hook
For comparison, here's the hook version, which is what most code should use today:
function Profile({ showEmail }: { showEmail: boolean }) {
const { user, status } = useCurrentUser();
if (status === "loading") return <p>Loading...</p>;
if (!user) return <p>Please sign in.</p>;
return (
<section>
<h2>{user.name}</h2>
{showEmail && <p>{user.email}</p>}
</section>
);
}
No wrapper, no Omit gymnastics, no cast. The trade-off is that the loading and sign-in checks now live inside Profile. If ten components need the same checks, the HOC removes that repetition, though a layout route or a wrapper component often does it better.
Why the Community Moved Away from HOCs
HOCs work, but they have real problems that become painful at scale. The React team cited several of them when introducing hooks.
Wrapper Hell
Each HOC adds a component to the tree. Stack five of them and React DevTools shows five wrapper layers above every real component. Debugging means clicking through withRouter(connect(withStyles(...))) to find where a prop actually came from.
Prop Name Collisions
Two HOCs that both inject a prop called data or loading will silently overwrite each other. The last one wins, and nothing warns you. Hooks avoid this because you name the returned values yourself:
const { data: orders } = useOrders();
const { data: customer } = useCustomer(id);
Indirection
With a HOC, a component's props come from two places: its caller and its wrappers. Reading function Profile({ user }), you can't tell where user comes from without finding the export statement at the bottom of the file. A hook call at the top of the component makes the data source explicit.
Static Methods and Refs
A wrapped component loses any static properties of the original, like Profile.preload. Libraries like hoist-non-react-statics existed solely to copy them over. Refs had a similar issue: before React 19, a ref passed to a HOC would attach to the wrapper, not the inner component, unless the HOC used forwardRef. In React 19, ref is a normal prop for function components, so a HOC that spreads props passes it through automatically. That removes one long-standing pain point.
Static Composition Only
HOCs are applied once, at module level. You can't easily decide at render time which HOCs to apply or pass them per-instance arguments that change. Hooks run during render, so they can take current props and state as arguments.
Rules for Writing HOCs Correctly
If you do write a HOC, these rules prevent the most common bugs.
Never Create HOCs During Render
This is the big one. Calling a HOC inside a component creates a new component type on every render, so React unmounts and remounts the subtree each time, losing all state:
// Wrong: new component type every render, state is lost
function Page() {
const LoggedDashboard = withMountLogging(Dashboard);
return <LoggedDashboard userId="42" />;
}
// Right: apply once at module level
const LoggedDashboard = withMountLogging(Dashboard);
function Page() {
return <LoggedDashboard userId="42" />;
}
Pass Through Unrelated Props
A HOC should forward every prop it doesn't use. Spreading ...props onto the wrapped component does this, and it now forwards ref too.
Set a displayName
Without it, DevTools shows every wrapper as WithUser or an anonymous function. A displayName like withUser(Profile) makes the tree readable and error messages useful.
Don't Mutate the Wrapped Component
Never modify Wrapped.prototype or assign properties to the input component. Return a new component that composes it instead.
Compose with a Helper
If you apply several HOCs, a small compose function makes the order explicit and readable. Note that it reads right to left, so the last HOC listed wraps the component first:
export function compose<T>(...fns: Array<(arg: T) => T>) {
return (arg: T) => fns.reduceRight((acc, fn) => fn(acc), arg);
}
In practice, typing a compose chain of HOCs with different prop transformations is hard, so many teams just nest the calls.
Where HOCs Are Still Relevant
Here's where HOCs still pull their weight in modern React.
Wrapping Components You Don't Own
When you need to add behavior to a third-party component without editing it, a HOC is natural. A common example is adding an error boundary to any component:
import { Component, type ComponentType, type ErrorInfo, type ReactNode } from "react";
type BoundaryProps = { fallback: ReactNode; children: ReactNode };
type BoundaryState = { hasError: boolean };
class ErrorBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { hasError: false };
static getDerivedStateFromError(): BoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(error, info.componentStack);
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
export function withErrorBoundary<P extends object>(Wrapped: ComponentType<P>, fallback: ReactNode) {
function WithErrorBoundary(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<Wrapped {...props} />
</ErrorBoundary>
);
}
WithErrorBoundary.displayName = `withErrorBoundary(${Wrapped.displayName ?? Wrapped.name})`;
return WithErrorBoundary;
}
// Usage
export const SafeChart = withErrorBoundary(ThirdPartyChart, <p>Chart failed to load.</p>);
There's no hook equivalent, because error boundaries must be class components that wrap their children. The react-error-boundary package ships exactly this kind of withErrorBoundary helper. For more on boundaries, see error boundaries: gracefully handling crashes in React.
Cross-Cutting Concerns at Module Boundaries
Some behavior belongs to the export of a component, not its body: feature flags, analytics tracking, permission checks on route components, or lazy-loading wrappers. A HOC lets you apply it in one line without touching the component's code:
import type { ComponentType } from "react";
import { useFeatureFlag } from "./flags";
export function withFeatureFlag<P extends object>(
flag: string,
Wrapped: ComponentType<P>,
Fallback: ComponentType<P> | null = null,
) {
function WithFeatureFlag(props: P) {
const enabled = useFeatureFlag(flag);
if (enabled) return <Wrapped {...props} />;
return Fallback ? <Fallback {...props} /> : null;
}
WithFeatureFlag.displayName = `withFeatureFlag(${flag})`;
return WithFeatureFlag;
}
export default withFeatureFlag("new-checkout", NewCheckout, LegacyCheckout);
The same component can be flagged in one app and not another, and removing the flag later is a one-line change.
Library APIs That Decorate Components
Several current APIs are HOCs, even if they're not called that. React.memo takes a component and returns a memoized one. React.lazy returns a component that loads another. MobX's observer wraps a component to track observable reads, and Storybook decorators wrap stories. These work as HOCs because they need to control how a component is rendered or when it re-renders, which a hook running inside the component can't do.
Gradual Migration from Class Components
If you're modernizing a codebase with class components that can't call hooks, a HOC can bridge the gap. Wrap the class in a function component that calls the hook and passes the result as a prop. This lets you share new hook-based logic with old classes until you convert them. The post on render props vs custom hooks covers the other pre-hooks pattern you'll meet during such migrations.
A Practical Decision Guide
- Sharing stateful logic between your own components? Write a custom hook.
- Need to control how a component renders or re-renders from the outside? A HOC or a library API like
memo. - Wrapping a third-party component with an error boundary, flag, or tracking? A HOC is clean and explicit.
- Gating many routes on auth? Prefer a layout route or wrapper component. See protected routes and authentication flows.
- Old code full of stacked HOCs? Convert the ones that cause pain, starting with prop collisions and deep wrappers. Leave stable ones alone.
Frequently Asked Questions (FAQ) About Higher-Order Components
It's a function that takes a component and returns a new component, usually one that renders the original with extra props or behavior. Common examples include React.memo, Redux's connect, and withErrorBoundary from the react-error-boundary package.
No. React never deprecated the pattern, and APIs like memo and lazy work the same way. The React docs simply recommend custom hooks for sharing logic, so HOCs are now used for narrower cases like decorating components you don't control.
Yes, as long as the HOC spreads its props onto the wrapped component. In React 19, ref is a regular prop for function components, so it passes through with the rest. Older versions needed forwardRef inside the HOC.
Each call creates a new component type. When React sees a different type in the same position, it unmounts the old subtree and mounts a new one, which discards all state and DOM. Always apply HOCs once at module level.
Constrain the wrapped component's props with a generic like P extends InjectedProps, and type the returned component's props as Omit<P, keyof InjectedProps>. When rendering, cast the props back to P and add the injected values.
Only where the HOC causes real problems, like prop collisions, deep wrapper trees, or confusing data flow. Many HOCs, especially for error boundaries, feature flags, and memoization, are fine as they are.
Conclusion
Higher-order components are functions that take a component and return a new one. They were the main way to share logic before hooks, and their weaknesses, wrapper hell, prop collisions, hidden data sources, and static-only composition, are the reason custom hooks took over. For sharing stateful logic between your own components, a hook is almost always simpler.
But HOCs still have a clear place. Use them to decorate components from the outside: adding error boundaries to third-party widgets, gating exports behind feature flags, bridging class components to hook-based logic, and building library APIs like memo. When you write one, apply it at module level, forward all props, set a displayName, and type injected props with Omit. Used that way, they're a small, sharp tool rather than the default.


