React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

hochigher-order-componentwrapperreusereact-patterns

Higher-Order Components — Enhance components with shared behavior by wrapping them in a function

Recipe

// HOC that adds authentication gating
function withAuth<P extends object>(
  WrappedComponent: React.ComponentType<P>
) {
  function AuthenticatedComponent(props: P) {
    const { user, isLoading } = useAuth();
 
    if (isLoading) return <LoadingSpinner />;
    if (!user) return <Navigate to="/login" />;
 
    return <WrappedComponent {...props} />;
  }
 
  AuthenticatedComponent.displayName =
    `withAuth(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
 
  return AuthenticatedComponent;
}
 
// Usage
const ProtectedDashboard = withAuth(Dashboard);

When to reach for this: When you need to apply the same cross-cutting concern (auth, logging, theming, data fetching) to many components without modifying them. Less common today thanks to hooks, but still useful for route-level wrappers and third-party library integration.

Working Example

import { useEffect, useRef, type ComponentType } from "react";
 
// HOC that tracks component visibility and reports analytics
function withVisibilityTracking<P extends { id: string }>(
  WrappedComponent: ComponentType<P>,
  eventName: string
) {
  function TrackedComponent(props: P) {
    const ref = useRef<HTMLDivElement>(null);
    const reported = useRef(false);
 
    useEffect(() => {
      const element = ref.current;
      if (!element) return;
 
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting && !reported.current) {
            reported.current = true;
            analytics.track(eventName, { componentId: props.id });
          }
        },
        { threshold: 0.5 }
      );
 
      observer.observe(element);
      return () => observer.disconnect();
    }, [props.id]);
 
    return (
      <div ref={ref}>
        <WrappedComponent {...props} />
      </div>
    );
  }
 
  TrackedComponent.displayName =
    `withVisibilityTracking(${WrappedComponent.displayName ?? WrappedComponent.name ?? "Component"})`;
 
  return TrackedComponent;
}
 
// Usage
interface ProductCardProps {
  id: string;
  name: string;
  price: number;
}
 
function ProductCard({ id, name, price }: ProductCardProps) {
  return (
    <div className="p-4 border rounded">
      <h3>{name}</h3>
      <p>${price}</p>
    </div>
  );
}
 
const TrackedProductCard = withVisibilityTracking(ProductCard, "product_viewed");
 
// In a page
function ProductGrid({ products }: { products: ProductCardProps[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((p) => (
        <TrackedProductCard key={p.id} {...p} />
      ))}
    </div>
  );
}

What this demonstrates:

  • HOC that adds IntersectionObserver tracking without modifying the original component
  • Generic type constraint (P extends { id: string }) ensures the wrapped component has required props
  • displayName set for React DevTools debugging
  • The original ProductCard remains pure and testable on its own

Deep Dive

How It Works

  • A HOC is a function that takes a component and returns a new component with enhanced behavior.
  • The pattern follows the mathematical concept of function composition: enhance(Component) => EnhancedComponent.
  • The HOC does not mutate the original component — it wraps it in a new one.
  • The wrapper component can intercept props, inject new props, conditionally render, or add lifecycle effects.
  • Multiple HOCs can be composed: withAuth(withTheme(withAnalytics(Component))).

Parameters & Return Values

ParameterTypePurpose
WrappedComponentComponentType<P>The component to enhance
Config (optional)VariesConfiguration for the HOC behavior
Return valueComponentType<P> (or modified)A new component with added behavior

Variations

Props injection HOC — adds new props to the wrapped component:

interface WithThemeProps {
  theme: Theme;
}
 
function withTheme<P extends WithThemeProps>(
  WrappedComponent: ComponentType<P>
) {
  function ThemedComponent(props: Omit<P, keyof WithThemeProps>) {
    const theme = useTheme();
    return <WrappedComponent {...(props as P)} theme={theme} />;
  }
 
  ThemedComponent.displayName =
    `withTheme(${WrappedComponent.displayName ?? WrappedComponent.name})`;
 
  return ThemedComponent;
}

Composed HOCs — combine multiple enhancements:

// Manual composition
const EnhancedComponent = withAuth(withTheme(withAnalytics(BaseComponent)));
 
// With a compose utility
import { compose } from "redux"; // or write your own
const enhance = compose(withAuth, withTheme, withAnalytics);
const EnhancedComponent = enhance(BaseComponent);

TypeScript Notes

  • Use ComponentType<P> to accept both function and class components.
  • Use Omit<P, keyof InjectedProps> to remove injected props from the external API.
  • Always constrain P extends object at minimum to avoid primitive types.
  • Set displayName for every HOC to make React DevTools usable.
  • Consider using React.forwardRef inside HOCs if the wrapped component needs ref forwarding.

Gotchas

  • Ref forwarding — Refs do not pass through HOCs automatically because ref is not a regular prop. Fix: Use React.forwardRef inside the HOC wrapper.

  • Static methods lost — Static properties on the original component are not copied to the wrapper. Fix: Use hoist-non-react-statics or manually copy needed statics.

  • Prop name collisions — If the HOC injects a prop with the same name as an existing prop, it silently overwrites. Fix: Namespace injected props or use a unique prefix.

  • Re-creating the HOC on every render — Calling a HOC inside a component body creates a new component type each render, destroying all state. Fix: Always call HOCs at module scope or in a useMemo with extreme caution.

  • Debugging difficulty — Deeply nested HOCs create long component trees in DevTools. Fix: Set displayName on every HOC and consider whether hooks would be clearer.

Alternatives

ApproachTrade-off
Higher-order componentsTransparent enhancement; typing is complex, debugging harder
Custom hooksSimpler, composable, better types; cannot wrap rendering
Render propsExplicit data flow; more verbose at call site
Middleware (Next.js)Better for route-level concerns like auth in Next.js apps
Decorators (stage 3)Syntactic sugar; not yet widely supported in React ecosystem

FAQs

What exactly is a higher-order component (HOC)?
  • A HOC is a function that takes a component and returns a new, enhanced component.
  • It follows the pattern: enhance(Component) => EnhancedComponent.
  • The HOC wraps the original component without mutating it.
Why are HOCs less common in modern React?
  • Custom hooks can achieve the same behavior reuse with a simpler, more composable API.
  • Hooks are easier to type in TypeScript and produce cleaner component trees in DevTools.
  • HOCs are still useful for route-level wrappers and third-party library integration.
How do you compose multiple HOCs together?
// Manual composition
const Enhanced = withAuth(withTheme(withAnalytics(Base)));
 
// With a compose utility
const enhance = compose(withAuth, withTheme, withAnalytics);
const Enhanced = enhance(Base);
  • HOCs can be nested or combined with a compose utility.
  • Composition reads right-to-left: withAnalytics is applied first, then withTheme, then withAuth.
Why is displayName important for HOCs?
  • Without displayName, React DevTools shows the wrapper component as "Anonymous" or the wrapper's internal name.
  • Setting displayName to withAuth(Dashboard) makes the component tree readable.
  • Always set it using the wrapped component's displayName or name property.
Gotcha: Why does calling a HOC inside a component body destroy state?
  • Calling a HOC inside render creates a new component type on every render.
  • React treats each new type as a different component and unmounts/remounts, destroying all state.
  • Fix: always call HOCs at module scope, outside any component.
Gotcha: Why don't refs pass through HOCs automatically?
  • ref is not a regular prop in React; it is handled specially and stripped before reaching the wrapped component.
  • The HOC wrapper receives the ref, not the inner component.
  • Fix: use React.forwardRef inside the HOC to forward refs to the wrapped component.
How do you type a HOC that injects props in TypeScript?
interface WithThemeProps { theme: Theme; }
 
function withTheme<P extends WithThemeProps>(
  WrappedComponent: ComponentType<P>
) {
  function Themed(props: Omit<P, keyof WithThemeProps>) {
    const theme = useTheme();
    return <WrappedComponent {...(props as P)} theme={theme} />;
  }
  return Themed;
}
  • Use Omit<P, keyof InjectedProps> to remove injected props from the external API.
  • Constrain P extends InjectedProps so TypeScript knows the wrapped component accepts those props.
How do you use Omit to exclude injected props from a HOC's external API in TypeScript?
  • Omit<P, keyof InjectedProps> removes the injected prop names from the type the consumer sees.
  • The consumer does not need to pass theme when using withTheme(Component) because the HOC provides it.
  • Always constrain P extends object at minimum to prevent primitive type parameters.
What happens to static methods when you wrap a component with a HOC?
  • Static properties on the original component are not automatically copied to the HOC wrapper.
  • Fix: use the hoist-non-react-statics package or manually copy needed statics.
  • This is a common source of bugs when the wrapped component has static methods like getStaticProps.
What are the alternatives to HOCs for applying cross-cutting concerns?
  • Custom hooks: simpler, composable, better TypeScript support, but cannot wrap rendering.
  • Render props: explicit data flow, more verbose at the call site.
  • Next.js middleware: better for route-level concerns like auth.
  • Context providers: good for dependency injection without wrapping.
How can prop name collisions cause silent bugs in HOCs?
  • If a HOC injects a prop with the same name as an existing prop, the injected value silently overwrites the consumer's value.
  • This is hard to debug because there is no error or warning.
  • Fix: namespace injected props (e.g., themeConfig instead of config) or use a unique prefix.
  • Render Props — Alternative that gives consumers rendering control
  • Composition — Preferred over HOCs when behavior is not cross-cutting
  • Context Patterns — Often replaces HOCs for dependency injection