React Dropdown: The Two Defects a useState Toggle Ships With
A production dropdown, defect by defect: why one `open` boolean breaks the click, and why the 8px gap has to be padding rather than margin.
Most React dropdown tutorials stop at useState(false) and an outside-click handler. That version works in a demo and breaks in production, and this post is about the two specific ways it broke here — both caught after shipping, both fixed in the component that is still running on every product card in this storefront.
The short answer
A production dropdown needs four things a useState(false) toggle does not give you: separate tracking for hover and click (one boolean makes clicking close what hovering just opened), a hover-safe gap implemented as padding rather than margin, Escape handling that returns focus, and aria-expanded + aria-controls so it announces as a disclosure.
The component this is drawn from
src/components/molecules/DownloadMenu.tsx is the edition picker an owner sees on a template they have bought. It renders in two shapes — a round icon button on a card's cover art, and the Download button inside the preview modal — and both open the same panel of framework editions. It is one of 24 "use client" files out of 92 component files in this repo, so it is a deliberate island of interactivity rather than the default.
Start with the version everyone writes:
const [open, setOpen] = useState(false);
// onMouseEnter={() => setOpen(true)}
// onMouseLeave={() => setOpen(false)}
// onClick={() => setOpen(!open)}
Defect one: one boolean makes the click read as broken
That code has a bug you cannot see until you use it with a mouse. The pointer is, by definition, over the button when you click it — hover has already set open to true. So the click toggles it to false, and the panel you just opened closes under your cursor. The button reads as broken even though every handler fired exactly as written.
The fix is to stop modelling two different intents as one boolean:
// Hover and click are tracked separately, and the panel is open when either
// is. Folding them into one `open` boolean looks equivalent and isn't: the
// pointer is by definition over the button when you click it, so a single
// toggle would close the panel hover had just opened — the click would read
// as broken. Pinning also means the panel survives the trip from the button
// down to the framework you're reaching for.
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(false);
const open = hovered || pinned;
hovered is transient and belongs to the pointer. pinned is intent and belongs to the user. open is derived. Closing then has to clear both, and the reason is subtle enough to have its own comment in the source:
const close = () => {
setPinned(false);
// Also clear hover: the pointer may still be sitting on the button, and
// `mouseenter` won't fire again until it leaves and comes back.
setHovered(false);
};
Defect two: the gap that ate the pointer
Dropdown panels are conventionally offset a few pixels below their trigger. The obvious way to do that is a margin on the panel. It is wrong, and the failure is maddening to diagnose because the code looks correct.
A margin is dead space owned by neither the button nor the panel. Move the pointer down toward the framework you are reaching for, cross those 8 pixels, and mouseleave fires on the wrapper — the panel vanishes exactly as you arrive. The fix is to make the gap belong to the hover area by moving it into the container's padding:
{/*
The 8px offset from the button is this container's PADDING, not a
margin, so the gap belongs to the hover area. As a margin it was dead
space owned by neither the button nor the panel: crossing it fired
`mouseleave` and the panel vanished just as you reached for it.
*/}
<div className={`absolute top-full right-0 z-30 pt-2 ${open ? "" : "hidden"}`} id={panelId}>
pt-2 on the positioned wrapper, not mt-2 on the panel. Visually identical, behaviourally the difference between a usable menu and one that runs away.
Note hidden rather than unmounting, too. The panel keeps its place in the tab-order calculation and its links stay measurable for tests, which is worth more than the handful of DOM nodes you would save.
Accessibility: a disclosure, not a menu
The trigger carries the two attributes that matter, and nothing more:
<button
aria-controls={panelId}
aria-expanded={open}
aria-label={isIcon ? label : undefined}
onClick={() => (open ? close() : setPinned(true))}
ref={buttonRef}
type="button"
>
panelId comes from React's useId(), so two instances on the same page never collide — and on a templates grid there is one per owned card.
What is deliberately absent is role="menu". That role is a promise: it commits you to roving arrow-key navigation, role="menuitem" children, and focus management within the panel. This panel is a list of ordinary <a> links, so Tab reaching them one by one is the correct interaction, and claiming to be an ARIA menu would announce a keyboard contract the component does not implement. A disclosure that behaves like a disclosure beats a menu that lies.
That question — what does this component actually promise the assistive-technology user? — is the one worth asking before any of the state management. It resolves differently for each neighbouring pattern: a sidebar turns out to need no client component at all, and a tooltip is usually an aria-label that someone reached for a library to write.
Escape closes and returns focus, with one capture-phase detail:
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
// Stop the preview modal from closing too when the panel is open inside it.
e.stopPropagation();
close();
buttonRef.current?.focus();
};
document.addEventListener("pointerdown", onPointerDown);
// Capture phase: the modal's own Escape handler is on `document` too, and
// the innermost layer is the one that should close first.
document.addEventListener("keydown", onKey, true);
Both this panel and the preview modal around it listen on document. Without the capture phase and the stopPropagation, one Escape closes both layers — the user meant to dismiss the dropdown and loses the modal underneath it.
When a dropdown item does work instead of navigating
Most rows in this panel are plain links. One is not: "Download all N editions" fires a request per edition, and getting that right needed three decisions that generalise to any dropdown whose items do something rather than go somewhere.
/** Gap between bulk requests — the rate-limit RPC serializes per user anyway. */
const STAGGER_MS = 700;
/** How long a download iframe is kept before it's cleaned out of the DOM. */
const FRAME_LIFETIME_MS = 120_000;
const downloadEverything = () => {
setBulkRunning(true);
options.forEach((option, index) => {
window.setTimeout(() => {
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = option.href;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), FRAME_LIFETIME_MS);
}, index * STAGGER_MS);
});
};
Hidden iframes rather than link clicks. On the happy path the response is a file attachment, but a 403, 429 or 404 comes back as JSON — and a plain navigation would replace the page the user is standing on with raw error text, once per edition. In an iframe a failure is simply inert, and the rows below stay there to retry one at a time.
Staggered, because the server serialises anyway. The rate-limit routine takes an advisory lock per user; firing four requests at once would only make them queue. A 700ms gap costs nothing and avoids a burst that looks like abuse. The forEach here is also one of only four in the whole src/ tree — why this loop is forEach and almost every other one isn't takes the census and explains the rule it follows.
Not cancelled on unmount. The timers deliberately outlive the panel. Closing the dropdown — or the modal around it — must not abandon downloads the user explicitly asked for. Only the "still running" state check is guarded, with a mountedRef, so React is not asked to set state on a component that is gone.
The general rule: a dropdown item that performs an action needs a visible in-progress state, a failure mode that does not destroy the page, and a clear answer to what happens when the menu closes mid-flight.
The security note that applies to every dropdown
This panel is only rendered for people who own the product. That is a rendering convenience and never a grant:
SECURITY: rendering this is a convenience, never a grant. Each row is an ordinary
<a>;/api/downloadre-derives the session and re-runsauthorizeDownloadper request, so a non-owner who forces the panel open gets a 403.
Anything a dropdown reveals — admin actions, gated files, destructive operations — must be re-authorized on the server when the item is activated. Conditional rendering is a UI affordance, and the DOM is editable by anyone with devtools.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Clicking the trigger appears to do nothing | Hover already set open; the click toggled it back off | Track hovered and pinned separately, derive open |
| Panel closes while moving the pointer toward it | The offset is a margin — dead space outside the hover area | Move the gap into the container's padding (pt-2, not mt-2) |
| Panel reopens immediately after closing | mouseenter won't re-fire under a stationary pointer | Clear hover state inside close(), not just the pinned state |
| Escape closes the modal behind the dropdown too | Both listeners are on document, bubble phase | Listen in the capture phase and stopPropagation() |
| Screen reader announces a menu with no arrow-key nav | role="menu" promises a contract you did not implement | Use aria-expanded + aria-controls on the trigger; keep the links plain |
Duplicate ids with several dropdowns on a page | Hard-coded panel id | useId() |
| Non-owner reaches a gated link via devtools | Conditional rendering treated as authorization | Re-authorize on the server per request |
Frequently asked questions
Should I reach for a library instead? Often, yes — Radix, React Aria and Headless UI all solve this well. This repo ships zero UI-library dependencies, so it owns the primitives; that trade-off is weighed in full in React UI libraries vs. hand-rolling. If you do hand-roll, the two defects above are the ones you will hit.
Is opening on hover a mistake? On its own it is an accessibility trap, because touch screens have no hover and hover-only reveals are unreachable. Here hover is a shortcut and never the only way in: click toggles, Escape closes, Tab reaches every link, and the panel is a disclosure a keyboard can operate completely.
Why hidden instead of unmounting the panel?
Keeping it mounted preserves its position in the tab-order calculation and keeps its links measurable in tests. Unmounting saves a few nodes and costs you both.
How do I stop the panel closing before a click registers on a link?
Use pointerdown on document for outside-dismissal but check containment first — if (!wrapperRef.current?.contains(e.target as Node)) close();. A blur-based approach fires before the click lands and eats the activation.
Templates in this post
ASoc Pip, ASoc Press and ASoc Quest are Next.js + Tailwind landing page templates from the same catalog this component ships in — every interactive piece in them is hand-rolled the same way, with no UI-library dependency to configure.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
