React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

click-outsidedropdownmodalpopoverdismisscustom-hook

useClickOutside — Detect clicks outside a referenced element to dismiss UI

Recipe

import { useEffect, useRef, useCallback } from "react";
 
/**
 * useClickOutside
 * Calls `handler` when a click (or touch) occurs outside the referenced element.
 * Supports a single ref or an array of refs (e.g., trigger + popover).
 */
function useClickOutside<T extends HTMLElement = HTMLElement>(
  handler: (event: MouseEvent | TouchEvent) => void,
  refs?: React.RefObject<T | null>[]
): React.RefObject<T | null> {
  const singleRef = useRef<T | null>(null);
  const handlerRef = useRef(handler);
 
  // Keep handler fresh without re-subscribing
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);
 
  useEffect(() => {
    const allRefs = refs ? refs : [singleRef];
 
    const listener = (event: MouseEvent | TouchEvent) => {
      // Check if click is inside any of the referenced elements
      const isInside = allRefs.some((ref) => {
        return ref.current?.contains(event.target as Node);
      });
 
      if (!isInside) {
        handlerRef.current(event);
      }
    };
 
    // Use mousedown/touchstart for immediate response
    // (before the click event, which fires on mouse up)
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
 
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [refs]);
 
  return singleRef;
}

When to reach for this: You have a dropdown, modal, popover, or context menu that should close when the user clicks anywhere outside of it.

Working Example

"use client";
 
import { useState, useRef } from "react";
 
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
 
  return (
    <div ref={ref} style={{ position: "relative", display: "inline-block" }}>
      <button onClick={() => setIsOpen((o) => !o)}>
        Menu {isOpen ? "▲" : "▼"}
      </button>
      {isOpen && (
        <ul
          style={{
            position: "absolute",
            top: "100%",
            left: 0,
            background: "#fff",
            border: "1px solid #ccc",
            listStyle: "none",
            padding: 8,
            margin: 0,
            minWidth: 150,
            boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
          }}
        >
          <li>Profile</li>
          <li>Settings</li>
          <li>Logout</li>
        </ul>
      )}
    </div>
  );
}
 
// Multi-ref example: trigger button + popover are both "inside"
function PopoverWithTrigger() {
  const [isOpen, setIsOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);
 
  useClickOutside(() => setIsOpen(false), [triggerRef, popoverRef]);
 
  return (
    <>
      <button ref={triggerRef} onClick={() => setIsOpen((o) => !o)}>
        Open Popover
      </button>
      {isOpen && (
        <div
          ref={popoverRef}
          style={{
            position: "absolute",
            padding: 16,
            background: "#fff",
            border: "1px solid #e0e0e0",
            borderRadius: 8,
            boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
          }}
        >
          <p>Popover content</p>
          <button onClick={() => setIsOpen(false)}>Close</button>
        </div>
      )}
    </>
  );
}

What this demonstrates:

  • Single ref pattern: the hook returns a ref you attach to the container element
  • Multi-ref pattern: pass an array of refs so clicks on either the trigger or popover are considered "inside"
  • mousedown fires before click, giving snappier dismiss behavior
  • Touch events are handled for mobile devices

Deep Dive

How It Works

  • mousedown vs click: Using mousedown (and touchstart) lets the dismiss happen before the click completes. This prevents issues where a click handler on the trigger re-opens a just-closed dropdown.
  • contains check: Node.contains() returns true if the event target is the element itself or any descendant, covering nested children.
  • Handler ref pattern: Storing the handler in a ref avoids re-subscribing event listeners every time the handler identity changes (common with inline arrow functions).
  • Multiple refs: When the trigger and popover are separate DOM nodes (e.g., portals), passing an array of refs ensures clicks on either are treated as "inside."

Parameters & Return Values

ParameterTypeDefaultDescription
handler(event: MouseEvent or TouchEvent) => voidCalled when an outside click is detected
refsRefObject<T>[]Optional array of refs to treat as "inside"
ReturnsRefObject<T>A ref to attach (used when refs is not provided)

Variations

With escape key: Add keyboard dismiss alongside click-outside:

useEffect(() => {
  const handleEscape = (e: KeyboardEvent) => {
    if (e.key === "Escape") handlerRef.current(e as any);
  };
  document.addEventListener("keydown", handleEscape);
  return () => document.removeEventListener("keydown", handleEscape);
}, []);

Ignore certain elements: Accept a shouldIgnore predicate to skip dismiss for specific elements (e.g., toasts, modals that stack):

const listener = (event: MouseEvent) => {
  if (shouldIgnore?.(event.target as HTMLElement)) return;
  // ...existing logic
};

TypeScript Notes

  • The generic T extends HTMLElement defaults to HTMLElement but can be narrowed: useClickOutside<HTMLDivElement>(...).
  • The handler receives the union MouseEvent | TouchEvent since both event types are listened to.
  • The ref array uses React.RefObject<T | null>[] for compatibility with useRef<T>(null).

Gotchas

  • Portaled content — If the dropdown renders in a React portal, it may be outside the ref's DOM tree even though it is logically "inside." Fix: Use the multi-ref pattern, passing both the trigger ref and the portal content ref.
  • Third-party overlays — Click on a toast or notification can trigger dismiss. Fix: Use the shouldIgnore variation or check against a class name.
  • Event orderingmousedown fires before click. If the trigger uses onClick to toggle open, the outside-click handler may close it before the toggle re-opens it. Fix: Wrap the container and trigger in the same ref, or use multi-ref.
  • SVG elementscontains may not work correctly with SVG elements in some browsers. Fix: Use event.target.closest() as an alternative check.
  • iOS Safari — Touch events may behave differently. Fix: Listening to touchstart alongside mousedown handles this.

Alternatives

PackageHook NameNotes
usehooks-tsuseOnClickOutsideSimilar API, well-tested
@uidotdev/usehooksuseClickAwayMinimal implementation
ahooksuseClickAwaySupports multiple refs natively
react-ariauseInteractOutsideAccessible, handles focus and pointer
Headless UIBuilt-inDropdowns and popovers auto-dismiss

FAQs

Why does the hook listen for mousedown instead of click?

mousedown fires before click (which fires on mouse up). This gives snappier dismiss behavior and prevents a race condition where a click handler on the trigger could re-open a just-closed dropdown.

How does the multi-ref pattern work and when should I use it?
  • Pass an array of refs to treat multiple DOM nodes as "inside."
  • The handler only fires if the click is outside all refs.
  • Use it when the trigger and popover are separate DOM nodes (e.g., with portals).
What does Node.contains() check in the listener?

ref.current.contains(event.target) returns true if the click target is the element itself or any of its descendants. This covers clicks on nested children inside the referenced container.

Why is the handler stored in a ref instead of passed directly to the listener?

Inline arrow functions create a new reference every render. Storing the handler in a ref avoids re-subscribing the document event listeners on each render while always calling the latest version.

Gotcha: My dropdown renders in a React portal and clicks on it trigger dismiss. Why?

Portal content is outside the ref's DOM tree even though it is logically "inside" the component. Use the multi-ref pattern, passing both the trigger ref and the portal content ref to the hook.

Gotcha: Clicking a toast notification closes my dropdown. How do I prevent this?

Use the shouldIgnore variation to skip dismiss for specific elements:

const listener = (event: MouseEvent) => {
  if ((event.target as HTMLElement).closest(".toast")) return;
  handlerRef.current(event);
};
Does this hook work on mobile devices with touch events?

Yes. The hook listens for both mousedown and touchstart, so taps on mobile devices are handled. Touch events fire before mouse events on touch devices.

How do I also dismiss on Escape key press?

Add a keydown listener for the Escape key inside the same effect or use useKeyboardShortcut:

useKeyboardShortcut("Escape", () => setIsOpen(false), {
  enabled: isOpen,
});
How does the generic T extends HTMLElement constrain the ref type in TypeScript?

The generic defaults to HTMLElement but can be narrowed. For example, useClickOutside<HTMLDivElement>(handler) returns a RefObject<HTMLDivElement | null>, giving you type-safe access to div-specific properties.

What is the type of the event parameter in the handler?

The handler receives MouseEvent | TouchEvent since both mousedown and touchstart are listened to. You can narrow the type inside the handler with instanceof checks if needed.