javascript
41 lines · 8 steps
Closing a dropdown on outside click in React
A dropdown component that listens for outside clicks and the Escape key to close itself, cleaning up its listeners as it goes.
Explained by
highlit
1import { useEffect, useRef, useState } from "react";
2
3export function Dropdown({ label, children }) {
4 const [open, setOpen] = useState(false);
5 const containerRef = useRef(null);
6
7 useEffect(() => {
8 if (!open) return;
9
10 function handlePointerDown(event) {
11 if (containerRef.current && !containerRef.current.contains(event.target)) {
12 setOpen(false);
13 }
14 }
15
16 function handleKeyDown(event) {
17 if (event.key === "Escape") setOpen(false);
18 }
19
20 document.addEventListener("mousedown", handlePointerDown);
21 document.addEventListener("keydown", handleKeyDown);
22
23 return () => {
24 document.removeEventListener("mousedown", handlePointerDown);
25 document.removeEventListener("keydown", handleKeyDown);
26 };
27 }, [open]);
28
29 return (
30 <div className="dropdown" ref={containerRef}>
31 <button
32 type="button"
33 aria-expanded={open}
34 onClick={() => setOpen((prev) => !prev)}
35 >
36 {label}
37 </button>
38 {open && <div className="dropdown__menu" role="menu">{children}</div>}
39 </div>
40 );
41}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Attaching document-level listeners inside useEffect and returning a cleanup function prevents duplicate handlers and memory leaks.
- 2A ref lets you test whether a click landed outside the component by comparing against the DOM subtree with contains.
- 3Gating the effect on open means listeners only exist while they're needed, and re-run whenever that state flips.
Related explainers
javascript
async function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text);
Copying to the clipboard with a fallback
clipboard
progressive-enhancement
dom
Intermediate
8 steps
javascript
'use server' import { z } from 'zod' import { redirect } from 'next/navigation'
How a Next.js Server Action validates a form
server-actions
form-validation
zod
Intermediate
7 steps
javascript
const cache = new Map(); const inflight = new Map(); async function fetchResults(query) {
Building a typeahead with an LRU cache
caching
lru-eviction
request-deduplication
Intermediate
9 steps
javascript
const DIVISIONS = [ { amount: 60, unit: 'seconds' }, { amount: 60, unit: 'minutes' }, { amount: 24, unit: 'hours' },
Building a human-friendly timeAgo formatter
internationalization
relative-time
lookup-table
Intermediate
6 steps
javascript
export function createSearchClient(baseUrl) { let inFlight = null; async function search(query, { signal } = {}) {
Cancelling stale requests in a search client
closures
abortcontroller
async
Intermediate
8 steps
javascript
export function cloneState(state) { if (typeof structuredClone !== "function") { throw new Error("structuredClone is not available in this runtime"); }
Deep cloning with structuredClone
deep-copy
error-handling
immutability
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/closing-a-dropdown-on-outside-click-in-react-explained-javascript-5d1f/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.