Hone logo
Hone
Problems

React useClickAway Hook Challenge

Build a custom React hook, useClickAway, that allows you to trigger a callback function when a user clicks anywhere outside of a specified DOM element. This is a common pattern used for closing dropdowns, modals, or tooltips when the user interacts with the rest of the page.

Problem Description

You need to create a reusable React hook named useClickAway that takes a DOM element reference and a callback function as arguments. The hook should listen for mousedown and touchstart events on the entire document. When such an event occurs, it should check if the event target is outside the provided DOM element. If it is, the provided callback function should be executed.

Key Requirements:

  • Hook Signature: The hook should have a signature similar to useClickAway(ref: React.RefObject<HTMLElement>, callback: () => void).
  • Event Handling: The hook must attach event listeners for mousedown and touchstart to the global document.
  • Click Away Detection: The logic should correctly identify if a click (or touch) originated from outside the referenced element.
  • Callback Execution: The provided callback function should be invoked only when a click-away event occurs.
  • Cleanup: The event listeners must be properly removed when the component unmounts to prevent memory leaks.
  • TypeScript: The solution must be implemented in TypeScript, ensuring type safety.

Expected Behavior:

When a component uses this hook, it should pass a ref to the element that should not trigger the callback, and a callback function to be executed when a click happens elsewhere.

Edge Cases to Consider:

  • The ref might not be attached or might be null initially.
  • The callback function might be undefined or change during the component's lifecycle.
  • Clicks on the ref element itself should not trigger the callback.
  • Clicks on elements inside the ref element should also not trigger the callback.

Examples

Example 1: Closing a Dropdown

Consider a simple dropdown component that opens when a button is clicked and should close when the user clicks anywhere outside the dropdown content.

Component Usage:

import React, { useRef, useState } from 'react';
import useClickAway from './useClickAway'; // Assuming your hook is in this file

function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  // Close the dropdown when clicking away
  useClickAway(dropdownRef, () => {
    setIsOpen(false);
  });

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle Dropdown</button>
      {isOpen && (
        <div ref={dropdownRef} style={{ border: '1px solid black', padding: '10px' }}>
          <p>Dropdown Content</p>
          <ul>
            <li>Item 1</li>
            <li>Item 2</li>
          </ul>
        </div>
      )}
    </div>
  );
}

Scenario:

  1. The Dropdown component renders. isOpen is false.
  2. The user clicks the "Toggle Dropdown" button. isOpen becomes true, and the dropdown div is rendered. The dropdownRef is attached to this div.
  3. The user clicks on the "Dropdown Content" text (which is inside the div). Nothing happens.
  4. The user clicks on an empty area of the page outside the dropdown div. Output: The useClickAway hook detects the click is outside dropdownRef, and the setIsOpen(false) callback is executed. The dropdown closes.

Example 2: Handling Initial State

What happens if the ref is not yet attached when an event occurs?

Component Usage:

import React, { useRef } from 'react';
import useClickAway from './useClickAway';

function DelayedDropdown() {
  const [isVisible, setIsVisible] = useState(false);
  const contentRef = useRef<HTMLDivElement>(null);

  // Simulate a delay before the ref is available
  useEffect(() => {
    const timer = setTimeout(() => {
      setIsVisible(true);
    }, 1000);
    return () => clearTimeout(timer);
  }, []);

  useClickAway(contentRef, () => {
    console.log('Clicked away!');
  });

  return (
    <div>
      <button>Click Me</button>
      {isVisible && (
        <div ref={contentRef}>
          This content might appear after a delay.
        </div>
      )}
    </div>
  );
}

Scenario:

  1. The DelayedDropdown component renders. isVisible is false. contentRef is initially null.
  2. A setTimeout is scheduled to set isVisible to true after 1 second.
  3. Before the timeout, the user clicks somewhere on the document. Output: The useClickAway hook should safely handle the null ref and not call the callback. The callback will only be active once contentRef.current is non-null.
  4. After 1 second, isVisible becomes true, and the div with contentRef is rendered. The ref is now attached.
  5. The user clicks outside the div. Output: "Clicked away!" is logged to the console.

Constraints

  • The useClickAway hook must be a functional React hook.
  • The hook must use useEffect for managing side effects (event listeners).
  • The ref argument will be a React.RefObject<HTMLElement>.
  • The callback argument will be a () => void function.
  • The implementation should be performant; avoid unnecessary re-renders or computations.

Notes

  • Consider using useRef within your hook to store the callback function to ensure you always have the latest version without needing to re-attach listeners every time the callback changes.
  • Think about the order of operations: when should the listeners be added and removed?
  • The mousedown event is often preferred over click for useClickAway because it fires earlier in the event lifecycle, which can be crucial for preventing unexpected behavior, especially when elements are conditionally rendered or their state changes rapidly. touchstart is included for mobile compatibility.
Loading editor...
typescript