Skip to content
Codeloom
React

React Higher-Order Components vs Hooks: Migration Guide

Compare Higher-Order Components and hooks in React with side-by-side examples, migration strategies, and guidance on when each pattern still makes sense.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What Higher-Order Components are and how they work
  • Why hooks replaced most HOC use cases
  • Side-by-side comparison of the same logic in both patterns
  • How to migrate HOCs to hooks incrementally
  • When HOCs still make sense in modern React

Prerequisites

None — this post is self-contained.

Higher-Order Components were the dominant pattern for sharing logic in React before hooks arrived in version 16.8. Many codebases still use them. Understanding both patterns and knowing when to migrate helps you work with existing code and make better design decisions in new code.

What is a Higher-Order Component

A Higher-Order Component (HOC) is a function that takes a component and returns a new component with additional behavior. It does not modify the original. It wraps it.

function withAuth(WrappedComponent: React.ComponentType<any>) {
  return function AuthenticatedComponent(props: any) {
    const user = useCurrentUser();

    if (!user) {
      return <LoginRedirect />;
    }

    return <WrappedComponent {...props} user={user} />;
  };
}

// Usage
const ProtectedDashboard = withAuth(Dashboard);

The HOC adds authentication logic to any component. The wrapped component receives a user prop without knowing how it was obtained.

The same logic as a hook

The hook equivalent is simpler and more direct:

function useAuth() {
  const user = useCurrentUser();

  if (!user) {
    throw new AuthRedirectError();
  }

  return user;
}

// Usage
function Dashboard() {
  const user = useAuth();
  return <div>Welcome, {user.name}</div>;
}

The hook returns the user directly. The component uses it like any other value. No wrapper, no prop injection, no naming collisions.

Side-by-side: data fetching

Here is a data fetching HOC and its hook equivalent.

HOC version:

function withData<T>(
  WrappedComponent: React.ComponentType<{ data: T; loading: boolean }>,
  fetchFn: () => Promise<T>
) {
  return function DataComponent(props: any) {
    const [data, setData] = useState<T | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
      fetchFn().then((result) => {
        setData(result);
        setLoading(false);
      });
    }, []);

    return <WrappedComponent {...props} data={data} loading={loading} />;
  };
}

const UserListWithData = withData(UserList, fetchUsers);

Hook version:

function useData<T>(fetchFn: () => Promise<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchFn().then((result) => {
      setData(result);
      setLoading(false);
    });
  }, [fetchFn]);

  return { data, loading };
}

function UserList() {
  const { data: users, loading } = useData(fetchUsers);

  if (loading) return <Spinner />;
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

The hook version keeps the data fetching logic inside the component that uses it. The component decides how to handle loading and rendering. The HOC version forces a specific prop interface and hides the data source from the component.

Side-by-side: window size tracking

HOC version:

function withWindowSize(WrappedComponent: React.ComponentType<{ width: number; height: number }>) {
  return function WindowSizeComponent(props: any) {
    const [size, setSize] = useState({
      width: window.innerWidth,
      height: window.innerHeight,
    });

    useEffect(() => {
      function handleResize() {
        setSize({ width: window.innerWidth, height: window.innerHeight });
      }
      window.addEventListener('resize', handleResize);
      return () => window.removeEventListener('resize', handleResize);
    }, []);

    return <WrappedComponent {...props} width={size.width} height={size.height} />;
  };
}

const ResponsiveChart = withWindowSize(Chart);

Hook version:

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

function Chart() {
  const { width, height } = useWindowSize();
  return <svg width={width} height={height}>...</svg>;
}

Why hooks won

Hooks solved four problems that HOCs created.

Wrapper hell. Composing multiple HOCs creates deeply nested component trees. withAuth(withTheme(withRouter(withData(Component)))) produces four wrapper components in the React tree. Hooks compose flatly inside a single component.

Prop collisions. If two HOCs inject a prop with the same name, one overwrites the other silently. Hooks return values that the component assigns to its own variables, so there are no collisions.

Indirection. When debugging a component wrapped in three HOCs, you cannot tell which HOC provides which prop without reading each HOC’s source. Hooks make data sources explicit at the call site.

Static typing. Typing HOCs correctly in TypeScript is painful. The generic constraints for props-in, props-out, and the wrapped component are complex. Hook types are straightforward function signatures.

Migration strategy

Migrating from HOCs to hooks does not need to happen all at once. A gradual approach works well.

Step 1: Write the hook. Extract the logic from the HOC into a custom hook. Keep the HOC working by having it call the hook internally.

// New hook
function useAuth() {
  const user = useCurrentUser();
  return { user, isAuthenticated: !!user };
}

// HOC now delegates to the hook
function withAuth(WrappedComponent: React.ComponentType<any>) {
  return function AuthComponent(props: any) {
    const { user, isAuthenticated } = useAuth();
    if (!isAuthenticated) return <LoginRedirect />;
    return <WrappedComponent {...props} user={user} />;
  };
}

Step 2: Migrate consumers gradually. New components use the hook directly. Existing components continue using the HOC until they are refactored.

Step 3: Remove the HOC. Once all consumers have migrated, delete the HOC wrapper and keep only the hook.

When HOCs still make sense

Hooks did not make HOCs obsolete in every case. Three situations still favor HOCs.

Cross-cutting layout concerns. A HOC that wraps every page in a consistent layout with error boundaries, analytics tracking, and permission checks can be cleaner than repeating those wrappers in every page component.

function withPageWrapper(PageComponent: React.ComponentType) {
  return function WrappedPage(props: any) {
    return (
      <ErrorBoundary>
        <AnalyticsTracker page={PageComponent.displayName}>
          <PermissionGate>
            <PageComponent {...props} />
          </PermissionGate>
        </AnalyticsTracker>
      </ErrorBoundary>
    );
  };
}

Third-party library integration. Some libraries provide HOCs (like React Router’s legacy withRouter or Redux’s connect). These work fine and do not need to be migrated unless you are refactoring the component anyway.

Class components. If you have class components that cannot be converted to function components yet, HOCs are still the primary mechanism for sharing logic with them. Hooks only work in function components.

Decision framework

Use a hook when you need to share stateful logic, when the consuming component needs control over rendering, or when you want explicit data flow. Use a HOC when you need to wrap components with structural concerns (layout, error handling, analytics), when you are working with class components, or when you want to apply the same decoration to many components declaratively.

For new code, default to hooks. Reach for HOCs only when the wrapping pattern genuinely simplifies the code.