Skip to main content
  1. Frontends/
  2. React Guides/

Logic Reuse Architectures: Custom Hooks vs. Higher-Order Components

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

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 install

We 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:

  1. Prop Collisions: If ResponsiveLayoutBase already had a prop named windowWidth passed from a parent, the HOC would overwrite it silently.
  2. Indirection: You have to look at the export statement at the bottom of the file to understand where the data comes from.
  3. 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.

graph TD subgraph "HOC Pattern (Wrapper Hell Risk)" A[Parent Component] -->|Props| B(withWindowSize HOC) B -->|Injects windowWidth| C[Wrapped Component] B -->|Passes through other props| C end subgraph "Hook Pattern (Co-located Logic)" D[Parent Component] -->|Props| E[Component] F[useWindowSize Hook] -.->|Internal State| E E -->|Renders| G[UI] end style B fill:#f9f,stroke:#333,stroke-width:2px style F fill:#bbf,stroke:#333,stroke-width:2px

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. useUser can call useAuth, which calls useSession. 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.forwardRef inside 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:

  1. 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).
  2. 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:

  1. Default to Custom Hooks for logic sharing (data fetching, subscriptions, form handling).
  2. Use HOCs sparingly, primarily for adapting legacy Class Components or injecting cross-cutting concerns (like analytics) without modifying source code.
  3. Always visualize your component tree. If it looks like a deep valley of wrappers, it’s time to refactor.

Further Reading
#

Happy Coding!