Before hooks, sharing stateful logic between React components was the hardest part of the framework. There were two answers — render props and higher-order components — and both had the same flaw. Hooks fixed it. I built an interactive demo that shows the same mouse-tracking logic written all three ways, and the "wrapper hell" that made hooks win.
▶ Live demo: https://render-props-vs-hooks.pages.dev/
Source: https://github.com/dev48v/render-props-vs-hooks
Move your mouse and three readouts update identically — same logic, three packagings.
The same logic, three ways
Custom hook — share a function:
function useMouse() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const h = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", h);
return () => window.removeEventListener("mousemove", h);
}, []);
return pos;
}
const { x, y } = useMouse(); // that's it. no wrapper.
Render prop — share a component whose child is a function:
function MouseTracker({ children }) {
const pos = useMouse(); // (originally its own state+effect)
return children(pos);
}
<MouseTracker>
{({ x, y }) => <p>{x}, {y}</p>}
</MouseTracker>
HOC — wrap a component and inject a prop:
function withMouse(Component) {
return function (props) {
const mouse = useMouse();
return <Component {...props} mouse={mouse} />;
};
}
const DotWithMouse = withMouse(Dot);
All three deliver the same {x, y}. So what was wrong with the first two?
Wrapper hell
Render props and HOCs both wrap your component in another component. One is fine. Stack a few shared concerns and the tree turns into a pyramid:
<MouseTracker>{mouse => (
<WindowSize>{size => (
<Toggle>{[on, tog] => (
<Auth>{user => (
<Dashboard mouse={mouse} size={size} on={on} user={user} />
)}</Auth>
)}</Toggle>
)}</WindowSize>
)}</MouseTracker>
Every shared concern adds a nesting level and a callback. HOCs hide the nesting but stack invisibly (withMouse(withAuth(withTheme(Dashboard)))), which brings its own problems: name collisions on injected props, a mystery displayName chain in DevTools, and no easy way to use one wrapper's value inside another.
Hooks compose flat:
function Dashboard() {
const mouse = useMouse();
const size = useWindowSize();
const [on, toggle] = useToggle();
const user = useAuth();
// ...one component, no nesting, and each hook can call other hooks
}
No wrappers, no pyramid, and useAuth can call useMouse internally if it wants. That composability is the whole ballgame.
Are render props dead?
For sharing stateful logic — yes, hooks win decisively, and most withX HOCs are now legacy. But render props aren't gone; they just do a different job now: injecting what to render. When a component owns the behaviour but wants the caller to decide the markup, a function-as-child or a renderItem / renderRow prop is still the right tool. It's how virtualized lists, data tables, tooltips, and headless component libraries expose their internals:
<VirtualList
items={rows}
renderItem={(row) => <OrderRow order={row} />} // you decide the markup
/>
The clean split:
- Hooks → reuse logic (state, effects, subscriptions).
- Render props → let the caller supply rendering.
- HOCs → reach for a hook first.
Move the mouse in the demo, watch all three stay in lockstep, then compare the wrapper-hell pyramid to the flat hooks beside it. If it clarified the pattern history, a star helps others find it: https://github.com/dev48v/render-props-vs-hooks
Top comments (0)