typescript css 107 lines · 3 tabs

Reusable useClickOutside Hook for Closing Dropdowns in React

Shared by codesnips Aug 2026
3 tabs
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]);
}
3 files · typescript, css Explain with highlit

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

typescript
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

typescript reliability retry
by codesnips 2 tabs
html
<!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

html html5 semantics
by Alex Chang 2 tabs
javascript
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

rails hotwire stimulus
by codesnips 4 tabs
typescript
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

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Form Validation Example</title>
  <style>

HTML forms with validation and accessibility

html forms validation
by Alex Chang 1 tab

Share this code

Here's the card — post it anywhere.

Reusable useClickOutside Hook for Closing Dropdowns in React — share card
Link copied