48 lines
1.8 KiB
Markdown
48 lines
1.8 KiB
Markdown
---
|
|
title: "React useState Dropdown — CSS group-hover vs useState for Playwright"
|
|
source: daily/2026-05-10.md
|
|
updated: 2026-05-10
|
|
---
|
|
|
|
## Problem
|
|
|
|
Playwright `hover()` on a trigger element does not reliably open a React dropdown that uses `useState` for visibility:
|
|
|
|
```tsx
|
|
// ❌ Playwright hover misses this
|
|
const [open, setOpen] = useState(false)
|
|
<button onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
|
|
Menu
|
|
</button>
|
|
{open && <DropdownMenu />}
|
|
```
|
|
|
|
Root cause: `useState` updates are asynchronous — they schedule a re-render. By the time Playwright checks for the dropdown element, the render cycle may not have completed, so `getByRole('menu')` returns nothing.
|
|
|
|
## Fix — CSS `group-hover:`
|
|
|
|
Replace JS state with a pure-CSS hover using Tailwind's `group` / `group-hover:` utilities:
|
|
|
|
```tsx
|
|
// ✅ Playwright hover works reliably
|
|
<div className="group relative">
|
|
<button>Menu</button>
|
|
<div className="hidden group-hover:block absolute top-full left-0">
|
|
<DropdownMenu />
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
The dropdown is always in the DOM; `group-hover:block` just changes `display`. No render cycle, no timing issues — Playwright sees the element immediately after hover.
|
|
|
|
## Secondary Benefit
|
|
|
|
CSS hover-driven dropdowns also fix the `overflow: hidden` clipping issue — the dropdown is rendered at the right DOM level and can be placed outside the clipping ancestor. See [[wiki/concepts/overflow-hidden-clips-absolute-children]].
|
|
|
|
## When useState Is Still Needed
|
|
|
|
- Keyboard accessibility (Escape to close, focus trapping)
|
|
- Mobile (no hover events) with click-to-toggle
|
|
- Complex open/close animations that require knowing state
|
|
|
|
In those cases, add an explicit `await page.waitForSelector('[role=menu]')` in Playwright tests rather than relying on hover timing.
|