Skip to main content

🎁 Higher-Order Components Basics

A coffee shop turns one espresso into a latte, a mocha, or a macchiato by wrapping it with different extras. A higher-order component does the same to a React component: it's a function that takes a component and hands you back an enhanced version, with extra props, state, or guards wrapped around it β€” without you rewriting the original.

Week 4 · Day 5 (Friday: Component Composition) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define what a higher-order component (HOC) is and write the basic pattern
  • Build practical HOCs that add loading, state, and side effects
  • Configure HOCs with options and compose several together
  • Apply the three key conventions: pass props through, set a display name, hoist statics
  • Avoid the classic pitfalls, especially creating a HOC inside render
  • Explain why custom hooks are today's preferred way to share logic

Estimated Time: 60 minutes

Practice: Write a withLoading HOC, then refactor it into a useLoading-style hook comparison.

In This Lesson

What Is a HOC?

A higher-order component is a function with this shape:

// takes a component  β†’  returns a new component
const Enhanced = withSomething(Original);

The name borrows from "higher-order function" (a function that takes or returns functions). A HOC is not itself a component β€” it's a factory that produces one. Its job is to bundle up some cross-cutting behavior β€” authentication, logging, loading states β€” and wrap it around any component you feed it.

graph LR A[Original Component] --> B["withSomething( )"] B --> C[Enhanced Component] D[original props] --> C E[injected props / guards] --> C

Why it matters: before hooks existed, HOCs were the way to reuse stateful logic across many components. You'll meet them everywhere in libraries and existing codebases β€” Redux's old connect(), router guards, analytics wrappers β€” so reading and writing them fluently is a real skill. We'll also be honest, later in the lesson, about where hooks have taken over.

The Basic Pattern

Every HOC follows the same skeleton: an outer function that accepts WrappedComponent, and an inner component that renders it with some added props.

function withEnhancement(WrappedComponent) {
    // Return a brand-new component
    return function Enhanced(props) {
        const extra = { enhanced: true };

        // Render the original, forwarding its props PLUS our additions
        return <WrappedComponent {...props} {...extra} />;
    };
}

// Usage β€” call it once, at module scope
const EnhancedButton = withEnhancement(Button);

Two details make or break a HOC, and we'll return to both: the inner component must spread {...props} so nothing the caller passed gets dropped, and you must create the enhanced component once (not inside another component's render).

Practical HOCs

1. Adding a loading state

A very common need: "show a spinner while data loads, otherwise show the real component." A HOC captures that once and reuses it everywhere.

function withLoading(WrappedComponent) {
    return function WithLoading({ isLoading, ...props }) {
        if (isLoading) {
            return <div className="loading">Loading…</div>;
        }
        // Pass every remaining prop straight through
        return <WrappedComponent {...props} />;
    };
}

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

const UserListWithLoading = withLoading(UserList);

function App() {
    const [users, setUsers] = React.useState([]);
    const [isLoading, setIsLoading] = React.useState(true);
    // ...fetch users, then setIsLoading(false)
    return <UserListWithLoading users={users} isLoading={isLoading} />;
}

2. Adding state

A HOC can own state and pass it down. Here withToggle supplies an isOpen flag plus a toggle function to whatever it wraps.

function withToggle(WrappedComponent) {
    return function WithToggle(props) {
        const [isOpen, setIsOpen] = React.useState(false);
        const toggle = () => setIsOpen((prev) => !prev);
        return <WrappedComponent {...props} isOpen={isOpen} toggle={toggle} />;
    };
}

function Panel({ isOpen, toggle, title, children }) {
    return (
        <div className="panel">
            <button onClick={toggle}>{title} {isOpen ? 'β–Ό' : 'β–Ά'}</button>
            {isOpen && <div className="panel-content">{children}</div>}
        </div>
    );
}

const TogglePanel = withToggle(Panel);

3. Adding a side effect

function withLogging(WrappedComponent) {
    return function WithLogging(props) {
        React.useEffect(() => {
            const name = WrappedComponent.displayName || WrappedComponent.name;
            console.log(`${name} mounted`);
            return () => console.log(`${name} unmounted`);
        }, []);
        return <WrappedComponent {...props} />;
    };
}

Console output

UserList mounted
UserList unmounted   // logged when the component leaves the tree

Configurable HOCs & Composition

Sometimes a plain withX(Component) isn't flexible enough β€” you want to pass options. The trick is one more layer: a function that takes options and returns a HOC.

// withData(url) returns a HOC; that HOC wraps a component
function withData(url) {
    return function (WrappedComponent) {
        return function WithData(props) {
            const [state, setState] = React.useState({ data: null, loading: true, error: null });

            React.useEffect(() => {
                let active = true;
                fetch(url)
                    .then((r) => r.json())
                    .then((data) => active && setState({ data, loading: false, error: null }))
                    .catch((error) => active && setState({ data: null, loading: false, error }));
                return () => { active = false; };
            }, []);

            return <WrappedComponent {...props} {...state} />;
        };
    };
}

// Apply the outer options first, then wrap the component
const UserListWithData = withData('/api/users')(UserList);

Composing multiple HOCs

Because each HOC returns a component, you can stack them. Nesting works but reads inside-out; a small compose helper is tidier.

// Utility: compose(a, b, c)(X) === a(b(c(X)))
const compose = (...hocs) => (Base) =>
    hocs.reduceRight((acc, hoc) => hoc(acc), Base);

// Reads top-to-bottom as an "enhancement stack"
const EnhancedList = compose(
    withLogging,
    withLoading,
)(UserList);

// Equivalent to: withLogging(withLoading(UserList))
graph TD A[UserList] --> B[withLoading wraps it] B --> C[withLogging wraps that] C --> D[EnhancedList]

Three Key Conventions

HOCs have a few well-established rules. Follow them and your HOCs stay predictable and debuggable.

1. Always pass props through

// ❌ Bad β€” silently drops every prop except one
function withBad(Wrapped) {
    return (props) => <Wrapped title={props.title} />;
}

// βœ… Good β€” forward everything, then add your own
function withGood(Wrapped) {
    return (props) => <Wrapped {...props} extra="value" />;
}

2. Set a display name (for DevTools)

function withExample(Wrapped) {
    function WithExample(props) {
        return <Wrapped {...props} />;
    }
    const name = Wrapped.displayName || Wrapped.name || 'Component';
    WithExample.displayName = `withExample(${name})`;
    return WithExample;
}
// React DevTools now shows "withExample(UserList)" instead of "Anonymous"

3. Hoist non-React statics

Wrapping a component doesn't copy its static methods. If the original had, say, a fetchData() static, the wrapper won't β€” unless you copy them over. The hoist-non-react-statics package does this correctly.

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

function withStatics(Wrapped) {
    function WithStatics(props) {
        return <Wrapped {...props} />;
    }
    hoistNonReactStatics(WithStatics, Wrapped); // copy statics across
    return WithStatics;
}

Common Pitfalls

⚠️ Never create a HOC inside render

Calling a HOC produces a new component type. Do it inside another component's render and React sees a different type on every render β€” it throws away the old subtree and remounts, losing state and hammering performance.

// ❌ Bad β€” withLoading(MyComponent) runs on EVERY render
function Parent() {
    const Enhanced = withLoading(MyComponent); // new type each time!
    return <Enhanced isLoading={loading} />;
}

// βœ… Good β€” build the enhanced component ONCE at module scope
const EnhancedMyComponent = withLoading(MyComponent);

function Parent() {
    return <EnhancedMyComponent isLoading={loading} />;
}

Don't mutate the original component

// ❌ Bad β€” reaching in and modifying the input
function withMutation(Wrapped) {
    Wrapped.prototype.extra = () => {}; // never do this
    return Wrapped;
}

// βœ… Good β€” a HOC composes; it returns something new and leaves the input alone
function withEnhancement(Wrapped) {
    return (props) => <Wrapped {...props} />;
}

HOCs vs Custom Hooks

Here's the honest, modern picture. Most jobs HOCs used to do are now handled more cleanly by custom hooks, introduced in React 16.8. Compare the two side by side for the same "track window size" logic:

// The HOC way β€” wraps a component, injects a prop
function withWindowSize(Wrapped) {
    return function WithWindowSize(props) {
        const [size, setSize] = React.useState({ w: window.innerWidth, h: window.innerHeight });
        React.useEffect(() => {
            const onResize = () => setSize({ w: window.innerWidth, h: window.innerHeight });
            window.addEventListener('resize', onResize);
            return () => window.removeEventListener('resize', onResize);
        }, []);
        return <Wrapped {...props} windowSize={size} />;
    };
}

// The hook way (preferred today) β€” same logic, no wrapper component
function useWindowSize() {
    const [size, setSize] = React.useState({ w: window.innerWidth, h: window.innerHeight });
    React.useEffect(() => {
        const onResize = () => setSize({ w: window.innerWidth, h: window.innerHeight });
        window.addEventListener('resize', onResize);
        return () => window.removeEventListener('resize', onResize);
    }, []);
    return size;
}

function Header() {
    const { w } = useWindowSize(); // just call it β€” no wrapping, no prop plumbing
    return <div>Width: {w}</div>;
}

πŸ“– So should you still learn HOCs? Yes.

For sharing logic, prefer a custom hook: no wrapper components, no "wrapper hell" in DevTools, no prop-name collisions. HOCs still shine in a few spots β€” wrapping the rendered output (error boundaries, layout injection) and integrating with older libraries whose APIs are HOCs. And you must be able to read them, because a huge amount of existing React code is built on them. Know the pattern; choose hooks first.

ConcernHOCCustom hook
Shares logicYesYes βœ… (cleaner)
Adds wrapper componentsYes (nesting in tree)No
Prop-name collisionsPossibleNone
Wraps rendered outputYes βœ…No
Modern first choiceRarelyUsually βœ…

Practice & Quiz

πŸ‹οΈ Exercise 1: Write withLoading

Goal: Complete a HOC that renders a spinner when isLoading is true, otherwise renders the wrapped component with all remaining props forwarded.

function withLoading(WrappedComponent) {
    // TODO: return a component that:
    //  - shows <div className="spinner">Loading…</div> when isLoading is true
    //  - otherwise renders WrappedComponent with every OTHER prop passed through
}
πŸ’‘ Hint

Destructure { isLoading, ...props } so you can forward everything except isLoading with {...props}.

βœ… Solution
function withLoading(WrappedComponent) {
    function WithLoading({ isLoading, ...props }) {
        if (isLoading) return <div className="spinner">Loading…</div>;
        return <WrappedComponent {...props} />;
    }
    const name = WrappedComponent.displayName || WrappedComponent.name || 'Component';
    WithLoading.displayName = `withLoading(${name})`;
    return WithLoading;
}

πŸ‹οΈ Exercise 2: Turn it into a hook

Goal: Rewrite the loading idea as a plain conditional inside a component using a useUsers() hook β€” showing why a hook removes the wrapper entirely.

βœ… Solution
function useUsers() {
    const [users, setUsers] = React.useState([]);
    const [isLoading, setIsLoading] = React.useState(true);
    React.useEffect(() => {
        fetch('/api/users')
            .then((r) => r.json())
            .then((data) => { setUsers(data); setIsLoading(false); });
    }, []);
    return { users, isLoading };
}

function UserList() {
    const { users, isLoading } = useUsers();
    if (isLoading) return <div className="spinner">Loading…</div>;
    return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
// No HOC, no wrapper component β€” the logic lives in the hook.

🎯 Quick Quiz

Question 1: A higher-order component is best described as…

Question 2: Why must you avoid creating a HOC-wrapped component inside render?

Question 3: For sharing reusable logic in new React code, the preferred tool today is…

Best Practices

βœ… Do

  • Spread {...props} through so the wrapped component keeps everything it was given
  • Set a displayName like withLoading(UserList) for readable DevTools
  • Create the enhanced component once, at module scope
  • Hoist non-React statics when wrapping components that have them
  • Keep each HOC focused on a single concern so they compose cleanly

❌ Don't

  • Build the wrapped component inside render β€” it remounts and drops state every time
  • Mutate the component you were handed; return a new one instead
  • Reach for a HOC when a custom hook would share the logic more simply
  • Stack so many HOCs that the DevTools tree becomes "wrapper hell"

βœ… Rule of thumb

Need to share logic? Write a hook. Need to wrap rendered output (an error boundary, a layout, a route guard) or integrate a HOC-based library? A HOC is the right tool.

Summary

πŸŽ‰ Key Takeaways

  • A HOC is a function that takes a component and returns an enhanced component
  • Use HOCs for cross-cutting concerns β€” loading, auth, logging, analytics
  • Always pass props through, set a display name, and hoist statics
  • Create HOC-wrapped components once, never inside render
  • Custom hooks are the modern first choice for sharing logic; keep HOCs for wrapping output

πŸ“š Additional Resources

πŸš€ What's Next?

You've completed the component-composition arc β€” composition vs inheritance, the children prop, and HOCs. Time to put it all to work: next you'll Build a React To-Do Application with CRUD operations, wiring state, forms, and composed components into a real, interactive app.

πŸŽ‰ Enhancement unlocked!

You can now read and write HOCs with confidence β€” and you know exactly when a humble custom hook is the better call.