If you’ve been in the React game for more than five years, you remember the “Wrapper Hell.” You remember opening the React DevTools and seeing a component tree that looked like a jagged mountain range of Connect(WithRouter(WithAuth(Component))).
It’s 2025. We have evolved. Or have we?
While Custom Hooks have largely democratized logic reuse, the ghost of the Higher-Order Component (HOC) lingers in many legacy codebases and, surprisingly, in some modern architectural decisions where “injection” is preferred over “consumption.”
As a senior engineer, your job isn’t just to use the newest shiny toy; it’s to understand why we shifted paradigms and identify the edge cases where the old patterns might still hold water. Today, we aren’t just comparing syntax. We are comparing mental models. We’re going to build a robust data-fetching layer using both patterns, benchmark them, and define exactly when to use which.
The Environment #
Before we dive into the architectural battle, let’s ensure our workstations are aligned. We are targeting a modern TypeScript environment.
Prerequisites:
- Node.js: v20+ (LTS)
- React: v19.x
- TypeScript: v5.x
- Package Manager: pnpm or npm
If you are spinning up a new playground for this article:
npm create vite@latest logic-reuse-demo -- --template react-ts
cd logic-reuse-demo
npm installWe don’t need heavy external libraries for this deep dive; standard React APIs are sufficient to demonstrate the architectural differences.
Round 1: The Higher-Order Component (HOC) #
Let’s rewind the clock. HOCs are based on a functional programming pattern: a function that takes a component and returns a new component. They were the primary way to share behavior in the Class Component era.
The Scenario: We need to track the window size to conditionally render layouts (e.g., switching from Sidebar to Hamburger menu).
The Implementation #
Here is how we architect this using a rigorous HOC pattern, complete with TypeScript generics to ensure we don’t break prop contracts.
// src/hocs/withWindowSize.tsx
import React, { ComponentType, useEffect, useState } from 'react';
// Define the injected props
export interface WithWindowSizeProps {
windowWidth: number;
windowHeight: number;
}
/**
* A Higher-Order Component that injects window dimensions.
*
* @param WrappedComponent The component to wrap
* @returns A new component with windowWidth and windowHeight props
*/
export function withWindowSize<P extends WithWindowSizeProps>(
WrappedComponent: ComponentType<P>
) {
// We separate the incoming props (P) from the injected props
const ComponentWithWindowSize = (props: Omit<P, keyof WithWindowSizeProps>) => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// Debouncing is recommended here in prod, simplified for demo
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// We cast props back to P because we are supplying the missing parts
return (
<WrappedComponent
{...(props as P)}
windowWidth={windowSize.width}
windowHeight={windowSize.height}
/>
);
};
// Best Practice: Set a display name for easier debugging in DevTools
const wrappedName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
ComponentWithWindowSize.displayName = `withWindowSize(${wrappedName})`;
return ComponentWithWindowSize;
}Usage #
To use this, we wrap our presentation component. Notice the inversion of control: the component receives data passively via props.
// src/components/ResponsiveLayoutHOC.tsx
import React from 'react';
import { withWindowSize, WithWindowSizeProps } from '../hocs/withWindowSize';
interface LayoutProps extends WithWindowSizeProps {
title: string;
}
const ResponsiveLayoutBase: React.FC<LayoutProps> = ({ title, windowWidth }) => {
const isMobile = windowWidth < 768;
return (
<div style={{ padding: '2rem', border: '1px solid #ccc' }}>
<h2>{title} (HOC Version)</h2>
<p>Current Width: <strong>{windowWidth}px</strong></p>
<div style={{
background: isMobile ? '#ffcccc' : '#ccffcc',
padding: '10px'
}}>
Mode: {isMobile ? 'MOBILE' : 'DESKTOP'}
</div>
</div>
);
};
// Export the wrapped version
export const ResponsiveLayout = withWindowSize(ResponsiveLayoutBase);The Critique #
This works, but look closely at the friction points:
- Prop Collisions: If
ResponsiveLayoutBasealready had a prop namedwindowWidthpassed from a parent, the HOC would overwrite it silently. - Indirection: You have to look at the export statement at the bottom of the file to understand where the data comes from.
- Typing Complexity: TypeScript generics for HOCs are notoriously difficult to get right, especially when wrapping generic components.
Round 2: The Custom Hook #
Now, let’s implement the exact same logic using the modern Hooks paradigm. This represents a shift from “Component Wrapping” to “Function Composition.”
The Implementation #
// src/hooks/useWindowSize.ts
import { useState, useEffect, useDebugValue } from 'react';
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Best Practice: Custom label in React DevTools
useDebugValue(`Size: ${windowSize.width}x${windowSize.height}`);
return windowSize;
}Usage #
// src/components/ResponsiveLayoutHook.tsx
import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';
interface LayoutProps {
title: string;
}
export const ResponsiveLayoutHook: React.FC<LayoutProps> = ({ title }) => {
// Direct consumption
const { width } = useWindowSize();
const isMobile = width < 768;
return (
<div style={{ padding: '2rem', border: '1px solid #333' }}>
<h2>{title} (Hook Version)</h2>
<p>Current Width: <strong>{width}px</strong></p>
<div style={{
background: isMobile ? '#ffcccc' : '#ccffcc',
padding: '10px'
}}>
Mode: {isMobile ? 'MOBILE' : 'DESKTOP'}
</div>
</div>
);
};This is drastically simpler. The dependency is explicit (import { useWindowSize }), the data flow is internal, and there is zero risk of prop collision.
Architectural Visualization #
It helps to visualize the data flow to understand the structural impact on your application.
The HOC wraps the component, creating a shell. The Hook lives inside, acting as a “plugin” for logic.
The Comparison Matrix #
As an architect, you need to make trade-offs. Let’s look at the hard data.
| Feature | Higher-Order Components (HOC) | Custom Hooks |
|---|---|---|
| Logic Reuse | High (via composition) | High (via function calls) |
| Debugging | Difficult. Creates “False” components in the tree. | Easy. Visible in DevTools hooks inspector. |
| Prop Naming | Risky. Collisions possible. | Safe. Variables can be renamed on destructuring. |
| Code Verbosity | High (Boilerplate required). | Low. |
| Composition | Static (Wraps at definition time). | Dynamic (Can use values from other hooks). |
| Testing | Requires rendering the wrapper or exporting the inner component. | Can be tested in isolation using renderHook. |
| Legacy Support | Works with Class & Functional components. | Functional components only. |
Performance Analysis #
In React 19, the performance difference is usually negligible for small apps, but it scales differently.
- HOCs increase the depth of your React Component Tree. React has to reconcile more fiber nodes. If you have 10 HOCs wrapping a component, that’s 10 extra levels of reconciliation depth.
- Hooks increase the memory usage within a single fiber node (linked list of hooks), but they do not increase the tree depth.
Winner: Custom Hooks generally result in a leaner virtual DOM.
Common Pitfalls & Solutions #
Even with Hooks being the standard, things can go wrong.
1. The “Hook Spaghetti” Anti-Pattern #
Just because you can put everything in a hook doesn’t mean you should shove 500 lines of logic into useEverything().
- Solution: Compose hooks.
useUsercan calluseAuth, which callsuseSession. Keep them atomic.
2. HOC Ref Forwarding #
If you stick with HOCs, a common bug is that refs don’t pass through automatically. You can’t put a ref on the exported HOC and expect it to reach the inner DOM node.
- Solution: You must use
React.forwardRefinside your HOC implementation.
// HOC with Ref Forwarding support
export function withRefSupport(WrappedComponent) {
const WithRef = React.forwardRef((props, ref) => {
return <WrappedComponent {...props} forwardedRef={ref} />;
});
// ... display name logic
return WithRef;
}3. When to actually use HOCs in 2026? #
Is the HOC dead? Not entirely. There are specific use cases where Injection is cleaner than Consumption:
- External Library Integration: Wrapping a component to provide context that the component shouldn’t know about (e.g., specific logging or analytics wrappers where you don’t want to touch the component code).
- Class Component Migration: If you are incrementally migrating a massive legacy app, you might write a HOC wrapper that uses Hooks internally and passes props down to an old Class Component. This is a vital “Strangler Fig” pattern strategy.
Conclusion #
In the battle of logic reuse, Custom Hooks are the clear winner for 95% of modern React development. They offer better type safety, cleaner component trees, and solve the “prop drilling” and “prop collision” issues that plagued early React architecture.
However, dismissing HOCs entirely is a mistake. They represent a powerful functional programming concept. Understanding how to write a type-safe HOC makes you a better TypeScript developer and prepares you for complex refactoring challenges in large-scale enterprise applications.
Key Takeaways:
- Default to Custom Hooks for logic sharing (data fetching, subscriptions, form handling).
- Use HOCs sparingly, primarily for adapting legacy Class Components or injecting cross-cutting concerns (like analytics) without modifying source code.
- Always visualize your component tree. If it looks like a deep valley of wrappers, it’s time to refactor.
Further Reading #
Happy Coding!