React's patterns for managing state and side effects have undergone a profound evolution over the past five years. From render props to hooks, we've shifted from wrapping components to injecting logic directly where it's needed. Today, I want to walk you through this journey—not just to understand where we came from, but to appreciate why hooks represent a genuine leap forward in how we structure reusable React code.
Higher-order components — Wrap components to inject props
Before hooks, higher-order components (HOCs) were the standard pattern for sharing stateful logic. An HOC is a function that takes a component and returns a new component with additional props or behavior. It's a wrapper pattern, and it works—but it comes with friction.
Let me show you a real scenario: imagine you're building a dashboard where multiple components need to fetch user data and track loading state. You might create an HOC like this:
const withUserData = (WrappedComponent) => {
return (props) => {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
fetch('/api/user')
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
return (
<WrappedComponent
user={user}
loading={loading}
error={error}
{...props}
/>
);
};
};
// Usage
const Dashboard = ({ user, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Welcome, {user.name}</div>;
};
export default withUserData(Dashboard);This works, but there's a problem: the wrapping is invisible in the component tree. When you're debugging a React DevTools trace, you see wrapper layers obscuring the actual component hierarchy. If you compose multiple HOCs—withUserData(withTheme(withRouter(Dashboard)))—your component tree becomes deeply nested and hard to trace. Props can conflict. Performance suffers because each HOC creates a new component instance on every render.
In production e-commerce platforms I've worked on, HOCs became maintenance nightmares as feature requests piled up. You'd want to combine logic from three different HOCs, but the composition order mattered, and bugs were subtle.
Render props — Pass a function as children. Flexible but nested.
The React community responded with render props, a pattern where you pass a function as a child or prop, and that function receives the stateful logic. It's more flexible than HOCs because there's no wrapping—you control where and how the logic flows into your component.
Here's how the user data scenario looks with render props:
class UserData extends React.Component {
state = {
user: null,
loading: true,
error: null,
};
componentDidMount() {
fetch('/api/user')
.then((res) => res.json())
.then((data) => this.setState({ user: data, loading: false }))
.catch((err) => this.setState({ error: err, loading: false }));
}
render() {
return this.props.children(this.state);
}
}
// Usage
const Dashboard = () => (
<UserData>
{({ user, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Welcome, {user.name}</div>;
}}
</UserData>
);Render props solved the component hierarchy problem—you see exactly what's happening in DevTools. It's also more composable: if you need user data and theme context, you can nest render props deliberately.
But render props introduced a different kind of pain: callback nesting. When you need multiple pieces of stateful logic, you end up with deeply nested function renders, often called "the pyramid of doom." Readability suffers. Performance can still be an issue if the child function is defined inline and recreated on every render. And for developers familiar with class components' lifecycle methods, the mental model shift to function-as-child feels awkward.
I've seen render props in real CRM systems where we were managing customer data, filter state, and pagination together. Three levels of nesting made refactoring a chore, and new team members found the pattern unintuitive.
Hooks — Reusable stateful logic without wrappers
React 16.8 introduced hooks, and they fundamentally changed how we think about reusable logic. Hooks let you extract stateful logic into a function, call it directly from within your component, and compose multiple hooks without any wrapping machinery.
Let's see the user data pattern with a custom hook:
const useUserData = () => {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
fetch('/api/user')
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
return { user, loading, error };
};
// Usage
const Dashboard = () => {
const { user, loading, error } = useUserData();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Welcome, {user.name}</div>;
};This is cleaner. The logic is separated into a reusable hook, the component is straightforward, and there's no wrapping or nesting. You call the hook, destructure what you need, and use it directly. If you need multiple hooks—useUserData, useTheme, useRouter—you just call them all at the top of your component. No composition order issues, no prop drilling, no pyramid of doom.
In my SaaS and trading platform work, hooks made sharing logic between components dramatically simpler. A custom hook for managing form state, another for API calls with retry logic, another for tracking analytics events—each can be composed independently and tested in isolation. The component itself remains focused on rendering.
Hooks also enable fine-grained performance optimization. You control exactly which dependencies trigger re-runs using dependency arrays in useEffect and useMemo. This level of control was harder to achieve cleanly with HOCs or render props.
Today — Hooks for logic, composition for structure
The modern React philosophy is to use hooks for reusable logic and composition (children, context) for structural relationships. This is a cleaner separation of concerns.
Consider a real-world example: building a modal system for a ride-hailing app. You might have:
// Custom hook: manages modal visibility state
const useModal = (initialOpen = false) => {
const [isOpen, setIsOpen] = React.useState(initialOpen);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return { isOpen, open, close };
};
// Composition: structural components
const Modal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
};
// In your ride booking component
const RideBooking = () => {
const confirmModal = useModal();
const [selectedRide, setSelectedRide] = React.useState(null);
return (
<div>
<button onClick={() => {
setSelectedRide(rides[0]);
confirmModal.open();
}}>
Book Ride
</button>
<Modal isOpen={confirmModal.isOpen} onClose={confirmModal.close}>
<h3>Confirm Booking</h3>
<p>Price: ${selectedRide?.price}</p>
<button onClick={() => {
submitBooking(selectedRide);
confirmModal.close();
}}>
Confirm
</button>
</Modal>
</div>
);
};Here, useModal is a hook that manages state and behavior. Modal is a compositional component that handles presentation. They work together naturally. The hook is reusable across any component that needs modal-like behavior. The component is reusable for any content you want to show modally.
This pattern scales beautifully. In the travel platform I built, we used custom hooks for API state management, authentication, form handling, and analytics. Structural composition with context and components handled theme, routing, and layout. Each concern stayed in its lane.
Common mistakes
- Mixing logic patterns: Don't wrap a hook-based component in an HOC if you can just call the hook directly. Avoid the temptation to recreate HOC or render prop patterns with hooks—they're less necessary now.
- Forgetting the rules of hooks: Hooks must be called unconditionally and at the top level of a component or another hook. Violating this breaks the order and causes subtle bugs. Static analysis tools like eslint-plugin-react-hooks catch this, but the mistake is common.
- Overfetching or refetching unnecessarily: A
useEffectwithout a proper dependency array will run on every render. Use an empty array for "run once on mount," or list specific dependencies. Test this carefully in production. - Creating custom hooks without clear contracts: If your custom hook has complex internal state or side effects, document what it does, what dependencies it expects, and what it returns. A vague hook name like
useDatais a red flag. - Not extracting custom hooks early enough: If you find yourself copying
useEffectlogic between components, extract a custom hook immediately. This is how you keep components focused and logic reusable.
Key takeaways
- Higher-order components solved code reuse but introduced wrapper nesting and component hierarchy obfuscation—they're rarely the best choice today.
- Render props eliminated HOC wrapping but created callback nesting and a less intuitive API—still useful in specific scenarios, but hooks are usually clearer.
- Hooks enable direct, composable, dependency-aware stateful logic extraction without any wrapping overhead or nesting complexity.
- Modern React code separates logic (via hooks) from structure (via composition and components), making codebases easier to navigate, test, and refactor.
- Custom hooks are the bridge between reusable logic and component-level simplicity—invest in naming them clearly and documenting their contracts.
- The evolution from HOCs to render props to hooks reflects a deeper understanding of React's strengths: functional composition and explicit dependency management.






Comments
Loading comments…