Front-end Engineering Lab
PatternsArchitecture Patterns

Higher-Order Components

Enhance components with reusable behavior using HOC pattern

Higher-Order Components (HOC)

A Higher-Order Component is a function that takes a component and returns a new component with enhanced functionality. While hooks are now preferred, HOCs are still used in many production codebases.

🎯 What is an HOC?

// HOC is a function that takes a component
function withAuth(Component) {
  // And returns a new component
  return function AuthenticatedComponent(props) {
    const { user } = useAuth();
    
    if (!user) {
      return <Redirect to="/login" />;
    }
    
    return <Component {...props} user={user} />;
  };
}

// Usage
const ProtectedPage = withAuth(Dashboard);

📊 HOC vs Hooks vs Render Props

FeatureHOCHooksRender Props
Reusability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Composability⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Type Safety⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Debugging⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Wrapper Hell❌ Yes✅ No❌ Yes
Props Collision⚠️ Possible✅ No✅ No

🔧 Basic HOC Pattern

Simple HOC

interface WithLoadingProps {
  loading: boolean;
}

function withLoading<P extends object>(
  Component: React.ComponentType<P>
) {
  return function WithLoadingComponent(
    props: P & WithLoadingProps
  ) {
    const { loading, ...rest } = props;
    
    if (loading) {
      return <div>Loading...</div>;
    }
    
    return <Component {...(rest as P)} />;
  };
}

// Usage
interface UserListProps {
  users: User[];
}

function UserList({ users }: UserListProps) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

const UserListWithLoading = withLoading(UserList);

// Now you can use it with loading prop
<UserListWithLoading 
  users={users} 
  loading={isLoading} 
/>

HOC with Options

interface WithDataOptions<TData = unknown> {
  url: string;
  transform?: (data: unknown) => TData;
}

function withData<P extends { data: unknown }>(
  options: WithDataOptions
) {
  return function (Component: React.ComponentType<P>) {
    return function WithDataComponent(props: Omit<P, 'data'>) {
      const [data, setData] = useState(null);
      const [loading, setLoading] = useState(true);
      
      useEffect(() => {
        fetch(options.url)
          .then(res => res.json())
          .then(data => {
            const transformed = options.transform 
              ? options.transform(data)
              : data;
            setData(transformed);
            setLoading(false);
          });
      }, []);
      
      if (loading) return <div>Loading...</div>;
      
      return <Component {...(props as P)} data={data} />;
    };
  };
}

// Usage
interface ProductListProps {
  data: Product[];
}

const ProductList = withData<ProductListProps>({
  url: '/api/products',
  transform: (data) => data.filter(p => p.inStock)
})(function ProductList({ data }) {
  return (
    <ul>
      {data.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
});

🎨 Common HOC Patterns

1. Authentication HOC

interface WithAuthProps {
  user: User;
  logout: () => void;
}

function withAuth<P extends WithAuthProps>(
  Component: React.ComponentType<P>
) {
  return function AuthenticatedComponent(
    props: Omit<P, keyof WithAuthProps>
  ) {
    const { user, logout } = useAuth();
    
    if (!user) {
      return <Navigate to="/login" replace />;
    }
    
    return (
      <Component 
        {...(props as P)} 
        user={user} 
        logout={logout} 
      />
    );
  };
}

// Usage
interface DashboardProps extends WithAuthProps {
  title: string;
}

const Dashboard = withAuth<DashboardProps>(
  function Dashboard({ user, logout, title }) {
    return (
      <div>
        <h1>{title}</h1>
        <p>Welcome, {user.name}</p>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }
);

2. Permission-Based HOC

function withPermission<P extends object>(
  requiredPermission: string
) {
  return function (Component: React.ComponentType<P>) {
    return function PermissionComponent(props: P) {
      const { user } = useAuth();
      
      if (!user?.permissions.includes(requiredPermission)) {
        return <div>Access Denied</div>;
      }
      
      return <Component {...props} />;
    };
  };
}

// Usage
const AdminPanel = withPermission('admin')(
  function AdminPanel() {
    return <div>Admin Content</div>;
  }
);

const ModeratorPanel = withPermission('moderator')(
  function ModeratorPanel() {
    return <div>Moderator Content</div>;
  }
);

3. Analytics HOC

interface WithAnalyticsOptions {
  eventName: string;
  category?: string;
}

function withAnalytics<P extends object>(
  options: WithAnalyticsOptions
) {
  return function (Component: React.ComponentType<P>) {
    return function AnalyticsComponent(props: P) {
      const { eventName, category = 'general' } = options;
      
      useEffect(() => {
        // Track page view
        analytics.track(eventName, {
          category,
          timestamp: Date.now(),
          path: window.location.pathname
        });
      }, []);
      
      return <Component {...props} />;
    };
  };
}

// Usage
const ProductPage = withAnalytics({
  eventName: 'product_page_view',
  category: 'ecommerce'
})(function ProductPage({ productId }) {
  return <div>Product {productId}</div>;
});

4. Error Boundary HOC

function withErrorBoundary<P extends object>(
  FallbackComponent?: React.ComponentType<{ error: Error }>
) {
  return function (Component: React.ComponentType<P>) {
    return class ErrorBoundary extends React.Component<
      P,
      { hasError: boolean; error: Error | null }
    > {
      state = { hasError: false, error: null };
      
      static getDerivedStateFromError(error: Error) {
        return { hasError: true, error };
      }
      
      componentDidCatch(error: Error, info: React.ErrorInfo) {
        console.error('Error caught:', error, info);
      }
      
      render() {
        if (this.state.hasError) {
          if (FallbackComponent) {
            return <FallbackComponent error={this.state.error!} />;
          }
          return <div>Something went wrong</div>;
        }
        
        return <Component {...this.props} />;
      }
    };
  };
}

// Usage
const SafeComponent = withErrorBoundary(
  function ErrorFallback({ error }) {
    return <div>Error: {error.message}</div>;
  }
)(function UnsafeComponent() {
  // Component that might throw
  return <div>Content</div>;
});

🔄 Composing Multiple HOCs

Manual Composition

const EnhancedComponent = withAuth(
  withAnalytics({
    eventName: 'dashboard_view'
  })(
    withErrorBoundary()(
      Dashboard
    )
  )
);

// Hard to read! 😱

Using compose Utility

import { compose } from 'redux'; // or create your own

const enhance = compose(
  withAuth,
  withAnalytics({ eventName: 'dashboard_view' }),
  withErrorBoundary()
);

const EnhancedDashboard = enhance(Dashboard);

// Much better! ✨

Custom compose Function

type Fn = (x: unknown) => unknown;

function compose<T>(...fns: Fn[]) {
  return (x: T): unknown => fns.reduceRight((acc, fn) => fn(acc), x as unknown);
}

// Or left-to-right (pipe)
function pipe<T>(...fns: Fn[]) {
  return (x: T): unknown => fns.reduce((acc, fn) => fn(acc), x as unknown);
}

// Usage
const enhance = pipe(
  withAuth,
  withData({ url: '/api/user' }),
  withAnalytics({ eventName: 'view' })
);

⚠️ Common Pitfalls

1. Don't Mutate Original Component

// ❌ BAD: Mutating original
function withExtra(Component) {
  Component.prototype.extra = function() {
    // DON'T DO THIS
  };
  return Component;
}

// ✅ GOOD: Return new component
function withExtra(Component) {
  return function Enhanced(props) {
    const extra = () => {
      // Extra functionality
    };
    return <Component {...props} extra={extra} />;
  };
}

2. Copy Static Methods

import hoistNonReactStatics from 'hoist-non-react-statics';

function withAuth(Component) {
  function AuthComponent(props) {
    // ... auth logic
    return <Component {...props} />;
  }
  
  // Copy static methods
  hoistNonReactStatics(AuthComponent, Component);
  
  return AuthComponent;
}

3. Pass Through Refs

function withLogging<P extends object>(
  Component: React.ComponentType<P>
) {
  function LoggingComponent(props: P & { forwardedRef?: React.Ref<unknown> }) {
    const { forwardedRef, ...rest } = props;
    
    useEffect(() => {
      console.log('Component rendered');
    });
    
    return <Component ref={forwardedRef} {...(rest as P)} />;
  }
  
  return React.forwardRef((props: P, ref) => {
    return <LoggingComponent {...props} forwardedRef={ref} />;
  });
}

4. Display Name for Debugging

function withAuth<P extends object>(Component: React.ComponentType<P>) {
  function AuthComponent(props: P) {
    // ... implementation
  }
  
  // Set display name for React DevTools
  const displayName = Component.displayName || Component.name || 'Component';
  AuthComponent.displayName = `withAuth(${displayName})`;
  
  return AuthComponent;
}

🆚 HOC vs Hooks Migration

Before (HOC)

const UserProfile = withAuth(
  withData({ url: '/api/user' })(
    withTheme(
      function UserProfile({ user, data, theme }) {
        return (
          <div style={{ background: theme.background }}>
            <h1>{user.name}</h1>
            <p>{data.bio}</p>
          </div>
        );
      }
    )
  )
);

After (Hooks)

function UserProfile() {
  const { user } = useAuth();
  const { data } = useFetch('/api/user');
  const theme = useTheme();
  
  if (!user) return <Navigate to="/login" />;
  
  return (
    <div style={{ background: theme.background }}>
      <h1>{user.name}</h1>
      <p>{data.bio}</p>
    </div>
  );
}

// Cleaner, easier to understand! ✨

🏢 Real-World Examples

Redux connect (Legacy HOC)

import { connect } from 'react-redux';

const mapStateToProps = (state) => ({
  user: state.user,
  posts: state.posts
});

const mapDispatchToProps = (dispatch) => ({
  login: () => dispatch(loginAction())
});

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Dashboard);

React Router withRouter (Legacy)

import { withRouter } from 'react-router-dom';

function MyComponent({ history, location, match }) {
  return <div>Current path: {location.pathname}</div>;
}

export default withRouter(MyComponent);

// Now use hooks instead:
import { useHistory, useLocation, useParams } from 'react-router-dom';

📚 Key Takeaways

  1. Hooks are preferred - Use for new code
  2. HOCs still valid - Legacy codebases use them
  3. Composition is tricky - Can create wrapper hell
  4. Type safety is harder - With TypeScript
  5. Props collision - Be careful with prop names
  6. Use for cross-cutting concerns - Auth, logging, etc
  7. Consider alternatives - Hooks, render props, compound components

Modern approach: Convert HOCs to hooks when refactoring. Only use HOCs for cross-cutting concerns where hooks don't fit.

On this page