React Higher-Order Components: The Pattern That Still Powers Half Your Favorite Libraries
HOCs didn't die with hooks. React.memo, Redux connect, and half your favorite libraries still use them. Learn to write type-safe HOCs and when to reach for the pattern.
React Higher-Order Components: The Pattern That Still Powers Half Your Favorite Libraries
You already use higher-order components every day. React.memo(MyComponent) — that's an HOC. Redux's connect(mapState)(Component) — HOC. React Router's old withRouter — HOC. The MUI theming system, Relay's createFragmentContainer, react-i18next's withTranslation — all HOCs.
The pattern didn't disappear with hooks. It shape-shifted into the parts of the ecosystem that needed to wrap components at a structural level, not just share stateful logic inside them. Understanding HOCs means understanding how the tools you depend on actually work.
The Problem: Cross-Cutting Concerns Across Components
Say you're building a dashboard. Every protected page needs an auth check. You could copy this logic into every component:
function AnalyticsPage() {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) {
redirect("/sign-in");
return null;
}
return <div>... analytics content ...</div>;
}
function BillingPage() {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) {
redirect("/sign-in");
return null;
}
return <div>... billing content ...</div>;
}This works, technically. But you've scattered the same guard across every page. Add a new redirect condition (premium plan check, email verification) and you're touching a dozen files. This is the problem HOCs exist to solve.
The Pattern
A higher-order component is a function that takes a component and returns a new component. That's it.
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
function WithAuth(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) {
redirect("/sign-in");
return null;
}
return <WrappedComponent {...props} />;
}
WithAuth.displayName = `WithAuth(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;
return WithAuth;
}Now every protected page becomes:
function AnalyticsPage() {
return <div>... analytics content ...</div>;
}
export default withAuth(AnalyticsPage);The page component doesn't know about auth. It just renders. The concern is handled exactly once, in one place.
How the Wrapping Works
The HOC factory runs once at module load. It produces a new component class (or function). Every time React renders that component, it runs the guard logic before deciding whether to render the wrapped component at all.
TypeScript: Getting the Generics Right
The tricky part with HOCs in TypeScript is the generic constraint. You want the returned component to accept the same props as the wrapped component, no more, no less.
// Generic constraint: P must be an object (component props always are)
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
): React.FC<P> {
function WithAuth(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) { redirect("/sign-in"); return null; }
return <WrappedComponent {...props} />;
}
WithAuth.displayName = `WithAuth(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
return WithAuth;
}If your HOC adds props to the wrapped component (like injecting a currentUser prop), you need to subtract those from what the outer component expects:
interface WithUserProps {
currentUser: User;
}
function withUser<P extends WithUserProps>(
WrappedComponent: React.ComponentType<P>
): React.FC<Omit<P, keyof WithUserProps>> {
function WithUser(props: Omit<P, keyof WithUserProps>) {
const { user } = useAuth();
if (!user) return null;
return <WrappedComponent {...(props as P)} currentUser={user} />;
}
WithUser.displayName = `WithUser(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
return WithUser;
}
// Usage — no need to pass currentUser, the HOC handles it
const ProfileCard = withUser(function ProfileCardInner({ currentUser, title }: WithUserProps & { title: string }) {
return <div>{currentUser.name} — {title}</div>;
});
<ProfileCard title="My Profile" /> // ✅ currentUser is injectedThe Omit<P, keyof WithUserProps> strips the injected props from the public API. Consumers don't see them.
displayName: The Thing Everyone Forgets
Without displayName, React DevTools shows a wall of anonymous components:
<Unknown>
<Unknown>
<Unknown>
<AnalyticsPage />
With it:
<WithAuth(WithLogging(AnalyticsPage))>
<WithLogging(AnalyticsPage)>
<AnalyticsPage />
Always set it. The formula:
HOC.displayName = `WithSomething(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;Composing Multiple HOCs
When you have several cross-cutting concerns, you can compose HOCs. The naive version looks like this:
export default withAuth(withLogging(withErrorBoundary(AnalyticsPage)));This works but reads inside-out. A compose utility fixes the reading order:
// Right-to-left composition (standard functional programming order)
type Enhancer = (C: React.ComponentType<any>) => React.ComponentType<any>;
function compose(...fns: Enhancer[]): Enhancer {
return (x) => fns.reduceRight((acc, fn) => fn(acc), x);
}
// Every argument must be a single-argument enhancer, so a configurable HOC
// has to be curried or partially applied before it goes in here.
const enhance = compose(
withAuth,
(C) => withLogging(C, { eventName: 'analytics_page_view' }),
withErrorBoundary,
);
export default enhance(AnalyticsPage);Note the typing: compose<T>(...fns: Array<(arg: T) => T>) is tempting but wrong for real HOCs, because an HOC generally changes the component's prop type — the input and output are not the same T. Chained HOCs are one of the places TypeScript genuinely gives up, and ComponentType<any> at the seam is the honest answer.
The leftmost HOC in compose is the outermost wrapper at runtime. So withAuth runs first — if auth fails, withLogging and withErrorBoundary never execute.
A Real Example: Request Logging
Here's an HOC that reports how long a component took to become interactive — from its first render to its mount effect — to an analytics service. It's a reasonable HOC because you want to attach it to arbitrary components from the outside without editing them:
interface LoggingConfig {
eventName: string;
}
function withLogging<P extends object>(
WrappedComponent: React.ComponentType<P>,
config: LoggingConfig
) {
function WithLogging(props: P) {
const renderStart = useRef(Date.now());
useEffect(() => {
const duration = Date.now() - renderStart.current;
// First render -> mount effect. Includes children and commit, so it is
// "time to mounted", not this component's own render duration.
analytics.track(config.eventName, { timeToMountMs: duration });
}, []);
return <WrappedComponent {...props} />;
}
WithLogging.displayName = `WithLogging(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;
return WithLogging;
}
// Apply it
const TrackedAnalyticsPage = withLogging(AnalyticsPage, {
eventName: "analytics_page_view",
});HOCs can take configuration arguments by returning a curried function — withLogging(config)(Component) — or by accepting the config as a second argument alongside the component. Both are valid; pick the one that reads better at the call site.
Variations
Conditional rendering HOC — render a fallback when some condition isn't met:
function withFeatureFlag<P extends object>(
WrappedComponent: React.ComponentType<P>,
flagName: string
) {
function WithFeatureFlag(props: P) {
const flags = useFeatureFlags();
if (!flags[flagName]) return null;
return <WrappedComponent {...props} />;
}
WithFeatureFlag.displayName = `WithFeatureFlag(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;
return WithFeatureFlag;
}
const NewCheckoutFlow = withFeatureFlag(CheckoutFlow, "new_checkout_v2");Prop injection HOC — libraries use this to inject context-derived props into class components that can't call hooks:
function withTheme<P extends { theme: Theme }>(
WrappedComponent: React.ComponentType<P>
): React.FC<Omit<P, "theme">> {
function WithTheme(props: Omit<P, "theme">) {
const theme = useTheme();
return <WrappedComponent {...(props as P)} theme={theme} />;
}
WithTheme.displayName = `WithTheme(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;
return WithTheme;
}This is exactly how MUI's legacy withStyles and emotion's withTheme work under the hood.
Pitfalls
Don't create HOCs inside render. This is the biggest footgun:
// ❌ New component identity on every render — kills reconciliation
function BrokenParent() {
const Enhanced = withAuth(ChildComponent); // Created inside render!
return <Enhanced />;
}
// ✅ Define HOC output at module scope
const Enhanced = withAuth(ChildComponent);
function ParentComponent() {
return <Enhanced />;
}Creating a component inside render causes React to unmount and remount it every time, destroying any internal state. Measured: type into the wrapped child, bump the parent's state once, and the child recorded 2 mounts / 1 unmount and reverted to its initial value. Hoist the same HOC call to module scope and it's 1 mount / 0 unmounts with the typed value intact. Always call the HOC factory outside of any render function.
Prop name collisions. If your HOC passes a data prop but the wrapped component also has a data prop, you'll silently overwrite it. Either namespace your injected props or document them clearly.
Wrapper hell in DevTools. Five composed HOCs produce five extra layers in the component tree. This isn't a dealbreaker, but it does slow down debugging. Custom hooks don't add component tree nodes at all — relevant when you're weighing the two approaches.
Refs used to need explicit handling. On React 18 and earlier, an HOC swallowed refs: ref wasn't a prop, so {...props} couldn't carry it and you had to wrap the enhanced component in forwardRef. On React 19 that is no longer true. ref is a regular prop, so the plain HOC at the top of this article already forwards it, provided the wrapped component accepts ref and puts it somewhere:
// React 19: nothing special needed. `ref` rides along inside {...props}.
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) {
function WithAuth(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) { redirect("/sign-in"); return null; }
return <WrappedComponent {...props} />;
}
WithAuth.displayName = `WithAuth(${
WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"
})`;
return WithAuth;
}
const AuthedInput = withAuth(function Input({ ref, placeholder }: {
ref?: React.Ref<HTMLInputElement>;
placeholder?: string;
}) {
return <input ref={ref} placeholder={placeholder} />;
});Mounted on React 19.2.3: <AuthedInput ref={r} /> through that plain HOC gives r.current === <input>. No forwardRef anywhere. This also sidesteps the forwardRef<unknown, P> version, which does not typecheck — spreading a generic P alongside ref gives you Type 'PropsWithoutRef<P> & { ref: ForwardedRef<unknown>; }' is not assignable to type 'IntrinsicAttributes & P'.
You'll see the ordering rule React.memo(React.forwardRef(...)) repeated a lot. It's real, but it isn't a subtle memoisation bug — get it backwards and React rejects it outright: "forwardRef requires a render function but received a memo component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))", followed by a Component is not a function throw. On React 19 the whole question is avoidable: memo() over a plain component that takes ref as a prop both memoises and forwards the ref, with no sandwich. Verified — 1 child render across 3 parent renders, ref populated, no warnings.
When NOT to Use HOCs
Be honest with yourself about whether you actually need one:
Use a custom hook instead when you're sharing stateful logic and don't need to intercept rendering. useAuth(), useFeatureFlag(), useTheme() — all of these are better as hooks. Hooks don't add wrapper nodes, they're easier to test in isolation, and TypeScript handles them with less ceremony.
Use composition over HOCs when the variation is in layout or JSX, not behavior. If you're tempted to write withLoadingState(Component), ask whether <Suspense fallback={<Spinner />}> or a conditional render in the parent serves better.
Don't wrap library components with HOCs unless you own the component. Wrapping <Button> from a UI library adds a layer of indirection that makes upgrades painful, and any prop the library adds later that collides with one of yours becomes a silent bug.
The honest rule of thumb: if the behavior could be a hook, make it a hook. Reach for an HOC when you genuinely need to intercept or transform the component itself — conditional rendering based on external state, prop injection for class components, or matching a library API that expects this pattern.
What's Still Alive
React.memo is an HOC. You'll use it. connect() in Redux codebases is an HOC — legacy codebases have thousands of them. If you're building a library that needs to wrap consumer components (an analytics SDK, an auth package, a feature flag system), HOCs are still the right tool because they compose at the component boundary, not inside component logic.
The pattern isn't dead — it graduated. It's now infrastructure-level React, living inside the libraries you import, occasionally worth reaching for when hooks genuinely can't do the job.
Comments (0)
No comments yet. Be the first to share your thoughts!