Render props vs hooks: the same logic three ways, and the wrapper hell that made hooks win
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
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
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.