import { RefObject, useEffect, useRef } from "react";
type Handler = (event: MouseEvent | TouchEvent) => void;
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T>,
handler: Handler
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref.current;
if (!el || el.contains(event.target as Node)) {
return;
}
handlerRef.current(event);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener, { passive: true });
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref]);
}
import { useRef, useState } from "react";
import { useClickOutside } from "./useClickOutside";
type Option = { label: string; value: string };
type DropdownProps = {
options: Option[];
onSelect: (value: string) => void;
};
export function Dropdown({ options, onSelect }: DropdownProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useClickOutside(containerRef, () => setOpen(false));
const choose = (value: string) => {
onSelect(value);
setOpen(false);
};
return (
<div ref={containerRef} className="dropdown">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
>
Select option
</button>
{open && (
<ul className="dropdown__menu" role="listbox">
{options.map((opt) => (
<li
key={opt.value}
role="option"
aria-selected={false}
onClick={() => choose(opt.value)}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown__menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
margin: 0;
padding: 4px 0;
list-style: none;
background: #fff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
z-index: 20;
}
.dropdown__menu li {
padding: 6px 12px;
cursor: pointer;
}
.dropdown__menu li:hover,
.dropdown__menu li[aria-selected="true"] {
background: #f3f4f6;
}
Dropdowns, popovers, and context menus all share one interaction: clicking anywhere outside the panel should dismiss it. Reimplementing that listener in every component leads to leaked event handlers and inconsistent behavior. The useClickOutside hook centralizes the logic into a single, typed, reusable primitive.
In useClickOutside hook, the hook accepts a generic RefObject<T> pointing at the container element plus a handler callback, and it returns nothing — its only job is a side effect. The generic constraint T extends HTMLElement keeps the ref type honest so consumers still get the correct element type back from useRef. Inside the useEffect, a listener inspects event.target: if the ref is unmounted or the target lives inside ref.current, the event is ignored via an early return; otherwise handler(event) fires.
A subtle but important detail is the use of mousedown and touchstart rather than click. Listening on the down phase closes the dropdown before a nested click handler swallows the event, and it avoids a race where the same click that opened the menu immediately closes it. The handler is stored in a handlerRef and read inside the listener so the effect does not need handler in its dependency array — this prevents re-subscribing on every render when callers pass an inline arrow function, a common cause of stale-closure and churn bugs.
The listeners are registered on document and torn down in the cleanup function, guaranteeing no leaks when the component unmounts. passive: true on the touch listener is a small performance hint for mobile scrolling.
In Dropdown component, the hook is wired up in about one line: a containerRef wraps the trigger button and the menu, and useClickOutside(containerRef, () => setOpen(false)) handles dismissal. The component still manages its own open state and toggles it on button click, so the hook stays purely concerned with the outside-click concern. Because the ref covers both the button and the panel, clicking the trigger to close does not double-fire. This separation is what makes the pattern scale: any future popover reuses the same tested hook instead of copy-pasting document listeners.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.