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
const express = require('express'); const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); const router = express.Router();
Building an API gateway proxy in Express
reverse-proxy
middleware
api-gateway
Intermediate
6 steps
javascript
import { useCallback, useRef, useState } from 'react'; export function ColorPicker({ initialColor = '#3b82f6', onCommit }) { const [committed, setCommitted] = useState(initialColor);
A validated color picker in React
uncontrolled-inputs
refs
validation
Intermediate
7 steps
javascript
import { useEffect, useRef } from 'react'; export function useRefetchOnFocus(refetch, { staleTime = 30_000 } = {}) { const lastFetchedAt = useRef(Date.now());
A React hook that refetches on tab focus
custom-hooks
refs
event-listeners
Intermediate
6 steps
typescript
import { Component, HostBinding, Input } from '@angular/core'; type ProgressVariant = 'success' | 'warning' | 'danger';
A CSS-driven progress ring in Angular
host-bindings
css-custom-properties
input-setters
Intermediate
8 steps
javascript
import { NextResponse } from 'next/server'; import { Redis } from '@upstash/redis'; const redis = Redis.fromEnv();
Sliding-window rate limiting in a Next.js route
rate-limiting
redis
sorted-set
Advanced
8 steps
javascript
const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = { 'image/jpeg': ['jpg', 'jpeg'],
Validating file uploads by content, not just claims
input-validation
security
magic-bytes
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.