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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Attaching document-level listeners inside useEffect and returning a cleanup function prevents duplicate handlers and memory leaks.
  2. 2A ref lets you test whether a click landed outside the component by comparing against the DOM subtree with contains.
  3. 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.

Closing a dropdown on outside click in React — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code