DEV Community

Cover image for Create a custom hook returning a callback ref
Phuoc Nguyen
Phuoc Nguyen

Posted on • Edited on • Originally published at phuoc.ng

Create a custom hook returning a callback ref

In the previous post, we explored a way to store a node passed to a callback ref as component state. This allows us to reference it easily throughout our component without using complex callbacks or workarounds.

Moreover, creating a custom hook to manage the node and internal states enables us to reuse it across multiple components, making our code more modular and easier to maintain.

To demonstrate how useful this pattern is, we'll walk through real-life examples that have the same functionality: checking whether users click outside of an element. Stay tuned!

Detecting clicks outside an element

In web applications, it's often necessary to detect whether a user has clicked outside an element. For example, a dropdown menu should close when the user clicks anywhere outside of it, or a modal dialog should disappear when the user clicks on the overlay or any area outside of its content.

By detecting these "outside clicks", we can trigger actions and update our component's state accordingly. This behavior is crucial for creating interactive and responsive interfaces that users expect.

In the next section, we'll create a custom hook to detect outside clicks, but first, let's see how we can check if the clicked target is outside a given element in vanilla JavaScript.

To detect whether a user has clicked outside a specific element, we can use an event listener on the document. We'll create a function that takes in the event object and checks if the target of the click is inside or outside our desired element.

Here's how we handle the click event:

const handleClick = (e) => {
    if (!ele.contains(e.target)) {
        // Clicked outside the element
    }
};

document.addEventListener("click", handleClick, true);
Enter fullscreen mode Exit fullscreen mode

It's important to note that we added an event listener to the document with capturing set to true. This ensures that it runs before any other click handlers on inner elements.

To check clicks against a specific element, we use the contains() method. This method helps determine whether the clicked target is inside or outside our element.

Now that you understand how to check if users click outside of a given element in vanilla JavaScript, let's use that knowledge to develop a custom hook that serves the same purpose. Our custom hook will return a callback ref that we can attach to any element we want to check against.

To store the reference of the element, we use a node state. This is similar to what we did in the previous post.

const useClickOutside = (handler: () => void) => {
    const [node, setNode] = React.useState<HTMLElement>(null);

    const ref = React.useCallback((nodeEle: HTMLElement | null) => {
        setNode(nodeEle);
    }, []);

    return [ref];
};
Enter fullscreen mode Exit fullscreen mode

Our custom hook needs a parameter named handler that will execute when users click outside of the element. Now, it's time for our hook to take action and handle the click event of the document.

const useClickOutside = (handler: () => void) => {
    const handleClick = React.useCallback((e) => {
        if (node && !node.contains(e.target)) {
            handler();
        }
    }, [node]);
};
Enter fullscreen mode Exit fullscreen mode

In this example, the handleClick function is used in the useClickOutside hook to detect whether a user has clicked outside of a specific element. It takes an event object as input and checks if the clicked target is inside or outside the desired element using the contains() method. If the target is outside, the handler function passed to the custom hook is called.

To improve performance, we use useCallback to memoize the handleClick function, so it doesn't get recreated on every render. By passing [node] as a dependency array, we ensure that the function always has access to the most recent node reference, which is important because we want to make sure our event listener is always checking against the correct element.

Lastly, we use the useEffect() hook to add and remove the click event listener of the document when the component mounts and unmounts respectively.

React.useEffect(() => {
    document.addEventListener("click", handleClick, true);
    return () => {
        document.removeEventListener("click", handleClick, true);
    };
}, [handleClick]);
Enter fullscreen mode Exit fullscreen mode

We need to pass handleClick as a dependency to the useEffect() hook because it relies on the current value of node, which could change if another element is passed into our custom hook.

To make sure everything stays up-to-date, we pass [handleClick] as a dependency array. This ensures that the effect only runs when handleClick or any other dependencies change. This avoids unnecessary re-renders and ensures our event listener always uses the most current version of the handleClick function.

In order to ensure that our code works on touch devices, we need to add a touchstart event listener in addition to the click event listener. Some mobile browsers don't support the click event on certain elements like <div> or <span>. Therefore, it's crucial to include the touchstart event listener to ensure that our code works seamlessly on all devices.

Here's how we can modify the previous code:

React.useEffect(() => {
    document.addEventListener("touchstart", handleClick, true);

    return () => {
        document.removeEventListener("touchstart", handleClick, true);
    };
}, [handleClick]);
Enter fullscreen mode Exit fullscreen mode

Our hook now works seamlessly on both desktop and mobile devices, making it not only accessible but also more user-friendly.

Automatically closing menus when users click outside

Dropdown menus are a common and useful UI element in web applications. They help us display a list of options or actions in a neat and organized way. When users click on the dropdown button, the menu expands, revealing its contents. This feature is intuitive and user-friendly.

However, once the dropdown menu is open, we need to provide users with a way to close it. One common approach is to add an "X" button or icon that allows users to close the menu explicitly. Another approach is to close the menu automatically when users click outside of it.

In this section, we'll explore how to implement the latter approach using a custom hook that detects clicks outside of an element. By doing so, we can easily trigger actions and update our component's state accordingly.

The useClickOutside hook we developed above can also be used for dropdown menus. We can attach it to the dropdown element and pass in a handler function that closes the menu when called.

Now let's see how we can use our custom hook with a simple example of opening and closing a dropdown menu.

const DropdownMenu = () => {
    const [isOpen, setIsOpen] = React.useState(false);

    const handleClick = () => setIsOpen(!isOpen);

    const closeMenu = () => setIsOpen(false);

    const [ref] = useClickOutside(() => {
        closeMenu();
    });

    return (
        <div>
            <button onClick={handleClick}>Format</button>
            {isOpen && (
                <div className="dropdown__content" ref={ref}>
                    <div className="dropdown__body">
                        <div className="dropdown__item">Bold</div>
                        <div className="dropdown__item">Italic</div>
                        <div className="dropdown__item">Underline</div>
                        ...
                    </div>
                </div>
            )}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

The code above demonstrates a DropdownMenu component that automatically closes when the user clicks outside of it. When the user clicks on the Toggle Menu button, the isOpen state is toggled and the menu appears. The hook is utilized to detect when a click occurs outside of the menu, allowing it to close automatically.

Let's take a look at how it works in action:

If you notice that you're using the same logic to maintain the isOpen state in multiple places, it might be a good idea to include it in our custom hook.

const useClickOutside = () => {
    const [isOpen, setIsOpen] = React.useState(false);

    const handleClick = React.useCallback((e) => {
        if (node && !node.contains(e.target)) {
            close();
        }
    }, [node]);

    const open = () => setIsOpen(true);

    const close = () => setIsOpen(false);

    return [ref, isOpen, open, close];
};
Enter fullscreen mode Exit fullscreen mode

The updated version of the hook now includes the isOpen state, which tells us whether the target element is open or closed, as well as the open and close functions that can be used to toggle the element's visibility. We no longer need to pass the handler option, as clicking outside the element will automatically trigger the close function and set the isOpen state to false.

Here's how the dropdown menu uses the updated hook:

const DropdownMenu = () => {
    const [ref, isOpen, open] = useClickOutside();

    return (
        <div>
            <button onClick={open}>Format</button>
            {isOpen && (
                <div ref={ref}>
                    ...
                </div>
            )}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

The dropdown menu in the example below functions similarly to the one introduced in the previous example.

Implementing click outside behavior for modal dialogs

Modal dialogs are an essential UI element that displays content or prompts users for input. They appear in the center of the screen, overlaying other content, providing a focused experience.

One crucial feature of modals is that they should disappear when users click outside of their content. This allows users to dismiss the modal quickly and return to their previous task.

In this section, we'll show you how to use our useClickOutside hook to implement this behavior in a simple example of a modal dialog.

const Modal = ({ children, onClose }) => {
    const [ref = useClickOutside(onClose);
    return (
        <div className="modal">
            <div className="modal-content" ref={ref}>
                {children}
            </div>
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

In this example, we've created a Modal component that uses our custom hook to detect clicks outside of its content. When the user clicks outside of the modal's content area, the onClose function passed as a prop is called.

Using the Modal component is a breeze. Just plug and play!

const App = () => {
    const [isOpen, setIsOpen] = React.useState(false);

    const openModal = () => setIsOpen(!isOpen);

    const closeModal = () => setIsOpen(false);

    return (
        <div>
            <button onClick={openModal}>Open Modal</button>
            {isOpen && (
                <Modal onClose={closeModal}>
                    {/* Content of the modal */}
                </Modal>
            )}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

The App component is responsible for rendering a button that toggles the visibility of a modal. When the button is clicked, it sets the showModal state to true, which causes the modal to appear. The Modal component receives the handleCloseModal function as a prop and calls it when the user clicks outside of the modal's content area.

Check it out in action:

With our custom hook, implementing a common behavior in our modal dialogs becomes a breeze. This not only makes them more user-friendly but also more accessible.

Conclusion

To sum up, creating a custom hook that returns a callback ref is a powerful technique that allows us to add complex functionality to our components in a reusable and modular way.

By bundling the logic for handling events of a corresponding node, represented by a ref, in a custom hook, we can easily integrate this behavior into any component that requires it. This means we don't have to repeat code or concern ourselves with implementation details.

What's more, by returning extra state and functions from the custom hook, we can further simplify our components and make them more expressive. This leads to cleaner code and easier maintenance in the long run.

Overall, using callback refs with custom hooks is an excellent technique for building flexible and scalable UI components. It's definitely worth adding to your toolkit if you haven't already.

See also


It's highly recommended that you visit the original post to play with the interactive demos.

If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks ๐Ÿ˜. Your support would mean a lot to me!

If you want more helpful content like this, feel free to follow me:

Top comments (0)