React|Custom Hooks

useOnClickOutside: The Aesthetics of Event Delegation for Closing Modals and Dropdowns

3
useOnClickOutside: The Aesthetics of Event Delegation for Closing Modals and Dropdowns

Complaints have been pouring in from users of the site Chris built.

"To close the popup, do I really have to click that tiny 'X' button in the corner? Please make it close just by clicking the background!"

Come to think of it, they are right. Menus on Google or Facebook close smoothly whenever you click anywhere outside of them.

To implement this, Chris opened useEffect and started attaching event listeners to the document.

typescript
// ❌ Chris's Hardcoding (Copy-pasting into every component)
function Dropdown({ onClose }) {
  const dropdownRef = useRef(null);

  useEffect(() => {
    const handleClick = (e) => {
      // Check if the click happened outside my area (dropdownRef)
      if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
        onClose();
      }
    };

    document.addEventListener('mousedown', handleClick);
    return () => {
      document.removeEventListener('mousedown', handleClick);
    };
  }, [onClose]);

  return <div ref={dropdownRef}>Menu Content</div>;
}

The feature works. However, there are numerous components that need this feature: dropdowns, modals, sidebars, tooltips, etc. Copy-pasting useEffect and contains logic every single time is a nightmare.

Let's extract this logic into a single, elegant hook.

1. Designing the Hook: "Let Me Know If It's Not Me"

The useOnClickOutside hook we are building needs to accept two arguments:

  • ref: The specific DOM element to watch (My Area).
  • handler: The callback function to execute when a click occurs outside.
  • typescript
    // useOnClickOutside.ts
    import { useEffect, RefObject } from 'react';
    
    type Handler = (event: MouseEvent | TouchEvent) => void;
    
    export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
      ref: RefObject<T>,
      handler: Handler
    ) {
      useEffect(() => {
        const listener = (event: MouseEvent | TouchEvent) => {
          const el = ref.current;
    
          // 1. Ignore if element doesn't exist or if the clicked target is inside my element
          if (!el || el.contains(event.target as Node)) {
            return;
          }
    
          // 2. Execute handler if it's outside
          handler(event);
        };
    
        // Listen for both mouse clicks (mousedown) and mobile touches (touchstart)
        document.addEventListener('mousedown', listener);
        document.addEventListener('touchstart', listener);
    
        return () => {
          document.removeEventListener('mousedown', listener);
          document.removeEventListener('touchstart', listener);
        };
      }, [ref, handler]); // Re-register listener if ref or handler changes
    }

    The core logic is the Node.contains() method. parent.contains(child) returns true if the child is contained within the parent. We inverted this to react only when it is !contains.

    2. Practical Application: Closing the Dropdown

    Now Chris can wipe out the complex useEffect and implement the feature with a single line of hook code.

    typescript
    // Dropdown.tsx
    import { useRef } from 'react';
    import { useOnClickOutside } from './useOnClickOutside';
    
    function Dropdown({ onClose }: { onClose: () => void }) {
      const ref = useRef<HTMLDivElement>(null);
    
      // βœ… onClose runs when clicking outside the ref area.
      useOnClickOutside(ref, () => {
        onClose();
      });
    
      return (
        <div ref={ref} className="dropdown-menu">
          <ul>
            <li>My Profile</li>
            <li>Settings</li>
            <li>Logout</li>
          </ul>
        </div>
      );
    }

    Now, if the user opens the menu and clicks somewhere else, the menu closes naturally.

    3. Deep Dive: Why mousedown instead of click?

    Many beginners use document.addEventListener('click', ...). However, most UI libraries prefer mousedown. Why?

    It is because of the Drag Issue.

  • User presses the mouse inside the dropdown (mousedown).
  • Drags the cursor and releases the mouse outside the dropdown (mouseup).
  • The browser considers this a single click event, and the click location is determined to be 'outside'.
  • Result: The menu closes even though the user just wanted to select some text inside.
  • Using mousedown determines the action the moment the button is pressed, preventing such drag malfunctions.

    4. Mobile Support: touchstart

    In mobile environments, click events can have a 300ms delay or might not trigger correctly on specific elements.

    Therefore, listening to the touchstart event together is much more advantageous for mobile Responsiveness.

    5. Caveat: Portals and Event Bubbling

    What happens if the modal is rendered directly under document.body using a React Portal?

    Although the modal is outside the main app in the DOM tree structure, React's Event Bubbling propagates through the Portal, so the feature works without issues.

    However, if there is code in the middle that stops the event, such as e.stopPropagation(), the event might not reach the document, causing the hook to fail. Remember that this hook operates on the premise that events bubble up to the document.

    Key Takeaways

  • Problem: Implementing the "close on click outside" feature for modals or dropdowns manually every time is cumbersome.
  • Solution: Create a useOnClickOutside hook to encapsulate the ref.current.contains(e.target) logic.
  • UX Tip: Using mousedown and touchstart instead of click prevents drag malfunctions and improves mobile responsiveness.
  • Usage: Versatile for closing dropdown menus, search autocomplete lists, sidebars, tooltips, etc.

  • We have now mastered UI interaction hooks.

    However, there is a chronic problem in React. If you use setInterval inside useEffect, the timer freezes because it cannot read the latest state value.

    Let's look at the magical pattern by Dan Abramov that solves this so-called "Stale Closure" problem.

    Continuing in: "useInterval & useTimeout: Escaping React's Closure Trap."

    πŸ”— References

  • usehooks-ts: useOnClickOutside
  • MDN - Node.contains
  • Comments (0)

    0/1000 characters
    Loading comments...