#653·vaul

Overlay crashes with "Rendered fewer hooks than expected" when modal changes on an already-mounted drawer

Author: stephane-segningCreated Aug 14, 2026Updated Aug 14, 2026

Bug report

<Drawer.Overlay>'s render body calls React.useCallback after an if (!modal) return null early return, so changing the modal prop on the Drawer.Root while an already-mounted Overlay is on screen throws React's "Rendered fewer hooks than expected. This may be caused by an accidental early return statement." and crashes the whole tree.

Source of the bug

dist/index.js (v1.1.2), Overlay's definition:

javascript
const Overlay = /*#__PURE__*/ React.forwardRef(function({ ...rest }, ref) {
    const { overlayRef, snapPoints, onRelease, shouldFade, isOpen, modal, shouldAnimate } = useDrawerContext();
    const composedRef = useComposedRefs(ref, overlayRef);
    const hasSnapPoints = snapPoints && snapPoints.length > 0;
    // Overlay is the component that is locking scroll, removing it will unlock the scroll without having to dig into Radix's Dialog library
    if (!modal) {
        return null;
    }
    const onMouseUp = React.useCallback((event)=>onRelease(event), [onRelease]);
    return /*#__PURE__*/ React.createElement(DialogPrimitive.Overlay, { ... });
});

useCallback is only reached when modal is (and, on every prior render, has always been) truthy. As long as modal never changes for a given mounted Overlay instance this never trips — but the moment a consumer flips modal on an already-mounted Drawer.Root/Overlay (true → false or the reverse), the same component fiber renders with a different hook count across two consecutive renders, which React's rules of hooks explicitly forbid.

Reproduction

Minimal repro (React 19, [email protected]):

typescript
import { useState } from "react";
import { Drawer } from "vaul";

export default function App() {
  const [open, setOpen] = useState(false);
  const [modal, setModal] = useState(true);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      <button onClick={() => setModal((m) => !m)}>Toggle modal ({String(modal)})</button>

      <Drawer.Root open={open} onOpenChange={setOpen} modal={modal}>
        <Drawer.Portal>
          {modal && <Drawer.Overlay className="overlay" />}
          <Drawer.Content className="content">
            <p>Drawer content</p>
          </Drawer.Content>
        </Drawer.Portal>
      </Drawer.Root>
    </>
  );
}
  1. Click "Open" — drawer opens fine, modal is true, Overlay mounts.
  2. Click "Toggle modal" while the drawer is still open.
  3. React throws: Uncaught Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement., and React logs An error occurred in the <Drawer.Overlay> component. The whole tree unmounts (no error boundary set up, but even with one, Overlay's own local hook state is gone).

Why this came up

We're using vaul's Root's modal prop as a lever to temporarily stand down the drawer's own Radix FocusScope/DismissableLayer while an unrelated, independently-portaled modal library (Headless UI's Dialog, in our case) is opened from inside the drawer — vaul's Content renders @radix-ui/react-dialog's own Content underneath, and modal is what that library keys FocusScope's trapped behaviour on (DialogContentModal vs. DialogContentNonModal). That workaround needed modal to change on an already-open drawer, which is what surfaced this.

Workaround we used

Don't hand Overlay a new modal value while it's mounted — unmount the element outright instead, so React tears the fiber down cleanly rather than re-rendering it with a different hook count:

typescript
{dimmed && !modal /* your own condition */ && <Drawer.Overlay className="..." />}

i.e. drop Overlay from the JSX tree entirely for the duration modal would otherwise be false, rather than rendering it unconditionally and letting it observe the prop change.

Suggested fix

Move the useCallback above the if (!modal) return null early return (its result is unused when modal is falsy, so this is a no-op change in behaviour, just correct hook ordering):

javascript
const Overlay = React.forwardRef(function({ ...rest }, ref) {
    const { overlayRef, snapPoints, onRelease, shouldFade, isOpen, modal, shouldAnimate } = useDrawerContext();
    const composedRef = useComposedRefs(ref, overlayRef);
    const hasSnapPoints = snapPoints && snapPoints.length > 0;
    const onMouseUp = React.useCallback((event) => onRelease(event), [onRelease]);
    if (!modal) {
        return null;
    }
    return React.createElement(DialogPrimitive.Overlay, { onMouseUp, ... });
});

Environment

  • vaul: 1.1.2
  • react / react-dom: 19.x
  • Reproduced in a minimal Vite + React harness, isolated from any other library (no Headless UI, no other dialog implementation involved in the repro above) — this is purely a vaul-internal hook-order bug, independent of what else is nested inside the drawer.