typescript
39 lines · 7 steps
A dismiss-on-outside-click hook in React
A reusable React hook that closes a menu or popover when the user clicks elsewhere or presses Escape.
Explained by
highlit
1import { useEffect, useRef, useState } from "react";
2
3export function useClickOutside<T extends HTMLElement>() {
4 const ref = useRef<T>(null);
5 const [isOpen, setIsOpen] = useState(false);
6
7 useEffect(() => {
8 if (!isOpen) return;
9
10 function handlePointerDown(event: PointerEvent) {
11 const target = event.target as Node;
12 if (ref.current && !ref.current.contains(target)) {
13 setIsOpen(false);
14 }
15 }
16
17 function handleKeyDown(event: KeyboardEvent) {
18 if (event.key === "Escape") {
19 setIsOpen(false);
20 }
21 }
22
23 document.addEventListener("pointerdown", handlePointerDown);
24 document.addEventListener("keydown", handleKeyDown);
25
26 return () => {
27 document.removeEventListener("pointerdown", handlePointerDown);
28 document.removeEventListener("keydown", handleKeyDown);
29 };
30 }, [isOpen]);
31
32 return {
33 ref,
34 isOpen,
35 open: () => setIsOpen(true),
36 close: () => setIsOpen(false),
37 toggle: () => setIsOpen((prev) => !prev),
38 };
39}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Gating an effect on open state avoids attaching global listeners you don't need.
- 2Always return a cleanup function from useEffect that mirrors every listener you added.
- 3A generic ref parameter lets one hook serve any HTML element while keeping types accurate.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 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/a-dismiss-on-outside-click-hook-in-react-explained-typescript-c577/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.