Cancel a React Modal with Escape Key or External Click

Lou Mauget Development Technologies, React, Tutorial Leave a Comment

Attention: The following article was published over 2 years ago, and the information provided may be aged or outdated. Please keep that in mind as you read the post.

Web application users are accustomed to canceling a popup (aka dialog or modal) by pressing the escape key, and many modals can even cancel if the user clicks outside it. How does a React developer code that without a plumbing mess between the modal and every visible component beneath it? How do you cancel a React modal with an escape key or external click?

I’m glad you asked because I have an answer. In this blog, I’ll show a pair of easy-to-use custom React hooks that simplify the task.

Modal HTML

A modal usually consists of two parts:

  1. A translucent container that covers the entire view
  2. An interactive dialog box floating atop the container

The Z-order of the modal is higher than that of its container, either by rendering order or by explicit z-index CSS.

Modals have a button that closes them without changing data state. For example, a confirmation may have Yes and No buttons. The No button is a cancel. Another example is a form input modal having buttons labeled Submit and Cancel. Some modals present complex forms that way.

The following example shows a simple confirmation modal. Notice the translucent container layer floating atop the view behind the modal.

How to cancel a React modal

A button onClick handler either submits an action that changes state, or it cancels, hiding the modal with no change. The React hooks we present here cause a call to a Cancel handler or a handler that can set show = false;. They’re convienient alternate means of calling code that carries out a Cancel or No to hide the modal.

Escape Keyup React Hook

A component that invokes a modal could insert the useEscapeKey hook, passing a modal Cancel handler as its argument.

If the cancel handler needed an argument that distinguishes Cancel from Submit,  then supply a no-argument wrapper function for the Cancel and pass the hook a ref to that wrapper function.

Using the Escape Key Hook

When you use a React hook, follow the rules of React hooks found in the React Documentation. Insert the hook early in the modal component function and pass it the cancel function reference (i.e. supply no parentheses suffix).

The following example shows the hook placed in a Yes/No confirmation box.

export default function ConfirmationModalImpl(props) {
  const {
    handleClose, // renderProp fn returns true or false
    show,        // boolean - visible/invisible
    headerText,  // text
    detailText,  // html / inner text
    openPos      // symbol for placement
  } = { ...props };

  const sendYes = () => handleClose(true);
  const sendNo = () => handleClose(false);

  useEscapeKey(sendNo);

  // Rest of modal implementation follows:

Let’s look at the internals of the useEscapeKey hook.

Implement useEscapeKey

const KEY_NAME_ESC = 'Escape';
const KEY_EVENT_TYPE = 'keyup';

function useEscapeKey(handleClose) {
    const handleEscKey = useCallback((event) => {
    if (event.key === KEY_NAME_ESC) {
      handleClose();
    }
  }, [handleClose]);

  useEffect(() => {
    document.addEventListener(KEY_EVENT_TYPE, handleEscKey, false);

    return () => {
      document.removeEventListener(KEY_EVENT_TYPE, handleEscKey, false);
    };
  }, [handleEscKey]);
}

Two standard hooks comprise our custom hook:

  1. useCallback
  2. useEffect

The HTML document orchestrates all keyboard events. It represents the page DOM. Thus, we want to listen to keyup document events for the escape key, calling a Close handler in our listener. The custom hook watches the escape keyup event, calling the modal Close handler on the passed listener event.

When we add a listener, we also need to remove it when the listening component dismounts. Our hook uses the React useEffect hook to add the listener. The useEffect automatically calls our returned cleanup function when the useEffect dismounts. Thanks!

We’re not quite there yet, though. We need to filter for escape in the listener. To prevent an infinite loop, a useCallback hook caches our filter function so that it doesn’t continually become a new function at each render.

The hook operates like this:

    1. Hook the escape key
    2. Cache the keyup filter
    3. Set the cleanup function
    4. Listen and evaluate keystrokes, calling the close handler on any escape keyup
    5. When the useEscapeKey hook’s container dismounts, detach the escape key listener in the cleanup function

Outside Click React Hook

We encounter modals in the wild that close when we click outside the modal dialog popup. In React, how would we easily identify a click outside the modal’s dialog DOM structure?

In JQuery, we could listen for a mouse-up event, and then check if the click target element does not contain the modal popup component root element. There’s a DOM function for that: element1.contains(element2). We’ll use this function to detect an outside click. If its value is True, then the click was outside of the modal’s popup.

React provides the ref construct that enables us to drop down to DOM-aware coding. Our useOutsideClick hook leverages the built-in React useRef hook.

Let’s look at how to use the useOutsideClick hook, and then view its implementation. The trick is to place a ref attribute on the border of modal component. That ref attribute argument is a reference to our useOutsideClick hook. React kindly sets the current value to the DOM reference of the modal component. There’s the magic – when used with the contains DOM function.

A click outside the modal will close the modal itself. Clicks inside the modal cause normal behavior within the modal.

// Extract of modal component useRef and return

const ref = useRef(null);
useOutsideClick(sendNo, ref);

return (
  <Model show={show}>
    <Container openPos={openPos} ref={ref}>
      <Header>{headerText}</Header>
      <HBar/>
      <Slot>{detailText}</Slot>
      <ButtonBar>
        <Button onClick={sendYes} primary={true}>Yes</Button>
        <Button onClick={sendNo} primary={false}>No</Button>
      </ButtonBar>
    </Container>
  </Model>
);

How does it work? Let us look at the implementation of the useOutsideClick hook.

Implement useOutsideClick

const MOUSE_UP = 'mouseup';

function useOutsideClick(handleClose, ref) {
  const handleClick = useCallback((event) => {
    if (ref?.current?.contains && !ref.current.contains(event.target)) {
        handleClose();
    }
  },[handleClose, ref]);

  useEffect(() => {
    document.addEventListener(MOUSE_UP, handleClick);

    return () => { document.removeEventListener(MOUSE_UP, handleClick); };
  }, [handleClick]);
}

The form of this hook mimics that of useEscapeKey, except that it listens for a mouse-up event and the callback filters on our ref value link set by React.

The DOM domElement.contains(event.target) carries out the outside vs. inside click determination. If the event contains our ref, then it’s an inside click. If not, it’s an outside click. The rest is like the useEscapeKey hook. Only the event and the filter expression differ.

GitHub Example

I added both hooks to an example of coding a React modal using Styled Components. I captured the sample screenshot from that application.

Check it out on my GitHub repository, here. Open a terminal on the project root, then issue yarn, followed by yarn start to open it in your default browser.

Variations

I can think of several variations…

Delayed Close

Wrap the call to the Close handler with a one-second delay in the callback. This could mitigate a possible startle factor for a new user:

setTimeout(() => { handleClose(); }, 1000);

onBlur Close

You may want onBlur, with a timeout, to close a modal. You could create a new custom hook that listens for onBlur. We’d recommend using a delay on that handler call.

Stacked Modals

Perhaps there is a modal dialog that calls a confirmation popup or a modal that calls a modal. Maybe there’s also an error popup involved. We see this behavior from time to time in development frameworks. To code for escape closing a nest modal you may need to disable a current useEscapeKey as you invoke a popup that contains a useEscapeKey hook.

We’d add a second disabling function reference to the hook signature. That passed-in function would refer to a Boolean that gates its useEscapeKey action. Without this, an escape key pressed while a popup was open over a nested modal would close the stack of modals having a useEscapeKey hook.

Summary

That’s a wrap! Now to recap what we’ve covered in this post…

I showed a couple of React hooks that each enable a use case to cancel a modal dialog without interacting directly with the viewed modal. React provides scant direct assistance for this. Thus, I dropped down to DOM-level event listeners. A listener needs to be removed when no longer needed. React gave us a leg up with its useEffect. That hook’s clean-up function allows us to remove a DOM listener when the hook’s host component dismounts — a huge help.

React gives us the useRef for a DOM element click reference to aid detection of clicks outside the modal as well. The DOM contains function easily determines “inside” vs “outside”.

Next, I presented an executable GitHub-resident example using both of those custom hooks. Finally, I suggested variations of these kinds of hooks.

Thank you for being here to read this post. If you have questions or comments, share them in the comments below, and if you enjoyed the read, check out the many others we have on the Keyhole Dev Blog.

4.5 4 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments