DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on • Originally published at github.com

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 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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

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)