React Click Outside (Away) Example

In this article, I will show you how to detect and handle when the user clicks outside a modal dialog box or popup.

React Click Outside (Away) Example

In this article, I will show you how to detect and handle when the user clicks outside a modal dialog box or popup.

Improving Usability

It's easy to create a popup or dialog box triggered by a button click. It's also easy to let another click of the button close the popup.  What's more difficult is figuring out when the user has clicked away from a popup, expecting it to close as they move on to another task.

ūüí°
In this article I use popup and dialog box interchangeably.

With the steps below I'll show you how to create an example and add "click outside" (also known as "click away") functionality.

These steps were tested on a Mac.

Step 1. Create a new React app

In this step, I will have you create a React app. To keep things simple it will just be a React JavaScript app (as opposed to a TypeScript app).

  • Open up a new terminal window
  • Switch to the root folder for your projects (like cd projects)
  • Run the following commands:
npx create-react-app react-click-away-demo

cd react-click-away-demo
  • Then open the project in your favorite code editor

Step 2. Define the dialog flags

In this example, there are going to be popup dialog boxes. Their visibility will be controlled by a flag for each one. To define those flags do the following:

  • Open src/App.js
  • Add this line to the top of it:
import { useState } from 'react';
  • Add the following to the top of the App function:
// Define dialog display flags

const [showDialogA, setShowDialogA] = useState(false);
const [showDialogB, setShowDialogB] = useState(false);
const [showDialogC, setShowDialogC] = useState(false);

This defines three values to track as many dialog box states, defaulting to false.

If the flag is true, that means show the dialog box and false means hide it.

Step 3. Add a function to clear all the dialog flags

When users click away, they expect all modal dialog boxes and popups to be closed. This can be done by simply turning all related flags false.

  • Add this below the last block of code:
// Define function to clear all dialogs

const clearDialogs = () => {
  setShowDialogA(false)
  setShowDialogB(false)
  setShowDialogC(false)
}

Calling the function will set all flags to false, closing any open dialog boxes.

Step 4. Define handlers for the dialog activation buttons

Each of the buttons will first clear all open dialog boxes (by calling the clearDialogs function, defined above).  Then they will toggle the display flag for their related dialog box.

  • Add this code after the previous block defined above:
// Define handler for clicking the first button

const handleClickAlpha = (event) => {
  clearDialogs()
  setShowDialogA(!showDialogA)
};

// Define handler for clicking the second button

const handleClickBeta = (event) => {
  clearDialogs()
  setShowDialogB(!showDialogB)
};

// Define handler for clicking the third button

const handleClickGamma = (event) => {
  clearDialogs()
  setShowDialogC(!showDialogC)
};

The three handlers will be wired to their respective buttons in a later step.

Step 5. Define dynamic styles

One way to control the visibility of a popup or dialog box is with a dynamic style that can be changed at runtime.

  • Add the following below the code above:
// Define a style to control the display of the first dialog

let styleA = {
  display: showDialogA ? 'block' : 'none',
}

// Define a style to control the display of the second dialog

let styleB = {
  display: showDialogB ? 'block' : 'none',
}

// Define a style to control the display of the third dialog

let styleC = {
  display: showDialogC ? 'block' : 'none',
}

If the flag for a dialog box is set to true, the display property will be set to block (display).  Otherwise, it will be set to none (hide).

In the next step, I will show you how to assign each style to its respective dialog box.

Step 6. Define the buttons and dialog boxes

Replace everything in the return statement with the following:

return (
  <div className="App">
    <div className="pair">
      <button className="item" onClick={handleClickAlpha}>A</button>
      <div id="childA" className="dialog" style={styleA}></div>
    </div>
    <div className="pair">
      <button className="item" onClick={handleClickBeta}>B</button>
      <div id="childB" className="dialog" style={styleB}></div>
    </div>
    <div className="pair">
      <button className="item" onClick={handleClickGamma}>C</button>
      <div id="childC" className="dialog" style={styleC}></div>
    </div>
  </div>
);

This code above replaces the contents of the App div with three divs representing each button + dialog pair (A, B, and C).

The div with class "pair" holds two children:

  • button - the button to click to toggle the dialog display state
  • div - the ¬†div representing the dialog box to show and hide with the paired button click

The buttons

  • The buttons map to an item class (defined later in an external .css file)
  • They also map to their button onClick handlers that were defined previously

The dialog divs

  • The dialog divs contain an id attribute which will be used to map a background color to make each dialog box unique
  • They also contain a className attribute ("dialog") that will be defined in the next step in an external .css file
  • Finally, they contain a style attribute that maps the dynamic style for each dialog box

The styles will be defined in the next step.

Step 7. Add more styles

  • At the top of App.js import a new style sheet:
import './demo.css';
  • Create a file called demo.css in the src folder and fill it with the following:
.pair {
    padding: 20px;
    width : 50px;
    height: 100px;
    border: 1px solid black;
    background-color: lightgray;
    margin: 5px;
    display: inline-block;
 }

 .item {
    font-size: 25px;
    width: 50px;
    height: 50px;
    border-radius: 50%;
 }

 .item:hover {
   background-color: lightpink;
   transition: 0.7s;
}

 .dialog {
    width: 200px;
    height: 200px;
    border: 1px solid black;
    position: relative;
    top: 20px;
    left: -75px;
 }

 #childA {
    background-color: lightgreen;
 }

 #childB {
    background-color: lightcyan;
 }

 #childC {
   background-color: lightblue;
}

The most important thing to note is how the .dialog style defines the div as being relative to the previous div (the button).  This is how you can position the dialog relative to its parent (the button).  The properties that control this are position, top, and left:

.dialog {
  /* ... */
  position: relative;
  top: 20px;
  left: -75px;
 }

Step 8. Run without the fix

The example so far should work.  But I haven't shown you yet how to deal with the usability issues.  To get a sense of that, run the current example as is.

  • From the terminal window do the following:
npm start
  • Browse to:
http://localhost:3000

In the browser click the various buttons. Notice the behavior. ¬†It works as expected ¬†‚Äď until you click outside of a dialog box. ¬†Instinctively you would expect the dialog box to just close. But it doesn't. ¬†It will only close if you click one of the buttons. To a user, this subtle but non-standard behavior can be a bit jarring.

Step 9. Update the import

To improve usability and add click-away capabilities we are going to need to use React's useEffect and useRef hooks.  Update the import line at the top of App.js to include them, like this:

import { useState, useEffect, useRef } from 'react';

Step 10. Add the refs

At the top of the App function insert this code to define some refs:

// Define button + dialog reference pairs

const RefPair = () => ({ button: useRef(), dialog: useRef() })

const refA = RefPair();
const refB = RefPair();
const refC = RefPair();

For each dialog box, it creates a pair of refs. One for the button.  One for the dialog box.

What is a ref?

A ref is a reference to a DOM node.  When used below it will return a reference to the DOM node that it is associated with. That way you can do things like compare if the current node associated with the ref is the one clicked on by the mouse.

Step 11. Add useEffect

Below where you defined the clearDialogs function, insert this code:

// Define useEffect to handle click events

useEffect(() => {

  const handleMouseDown = e => {

    // requires node 14 or greater
    let isOver = (rf) => rf.current?.contains(e.target);

    let checkOutside = function (flag, rp) {
      if (flag && !isOver(rp.button) && !isOver(rp.dialog)) {
        clearDialogs();
      }
    }

    // important to reference flags below for useEffect
    // if a flag isn't referenced this won't work for that flag
    checkOutside(showDialogA, refA);
    checkOutside(showDialogB, refB);
    checkOutside(showDialogC, refC);
  }

  document.addEventListener("mousedown", handleMouseDown);

  return () => {
    // Cleanup the event listener
    document.removeEventListener("mousedown", handleMouseDown);
  }

}, [
  showDialogA,
  refA,
  showDialogB,
  refB,
  showDialogC,
  refC,
])

The useEffect hook is described as allowing you to interact with external systems.  But what does that mean?  Think of it as an event monitor operating off to the side.  In this example app, the external system it interacts with is the mouse (in other cases it could be a timer, fetching data, etc.)

The main thing is that it sets up a mousedown event handler.  When the user clicks the mouse, the handler is called.

An arrow function is defined within the handler:

  • isOver returns true if the mousedown event.target equals the node associated with the ref passed to it

Note that the isOver function uses optional chaining - which requires Node 14 or higher.

ūüí°
Note that the isOver function uses optional chaining (?.) - which requires Node 14 or higher.

Next, a checkOutside function is also defined within the handler. It takes two arguments:

  • flag - the display flag for the dialog box
  • rp - an object containing the paired button and dialog refs

The function will call the clearDialogs function if:

  • The flag is true - indicating that the associated dialog box is visible
  • The mousedown event did not happen over the button
  • The mousedown event did not happen over the ¬†dialog box

Step 12. Add refs to the controls

The final piece is to update the App button and dialogs divs with refs for each button and dialog below.  For example, see new attributes such as ref={refA.button}:

// Layout with refs

return (
  <div className="App">
    <div className="pair">
      <button className="item" ref={refA.button} onClick={handleClickAlpha}>A</button>
      <div id="childA" className="dialog" ref={refA.dialog} style={styleA}></div>
    </div>
    <div className="pair">
      <button className="item" ref={refB.button} onClick={handleClickBeta}>B</button>
      <div id="childB" className="dialog" ref={refB.dialog} style={styleB}></div>
    </div>
    <div className="pair">
      <button className="item" ref={refC.button} onClick={handleClickGamma}>C</button>
      <div id="childC" className="dialog" ref={refC.dialog} style={styleC}></div>
    </div>
  </div>
);

Step 13. Test usability

Run the app:

npm start
  • Click the buttons to popup the dialog boxes
  • Click outside the buttons and the dialog boxes to verify that they close
  • Verify that you can still toggle the dialogs with the buttons
  • Verify that you can click inside the dialog box and it won't accidentally close

Complete code

You can find the complete code here:

Conclusion

In this article, I showed you how to handle events outside a dialog box. By managing those events you can use them to provide "click-away" (aka "click-outside") functionality and a better user experience.

References

  • useRef - React - [1]
  • Manipulating the DOM with Refs - [2]
  • Event: target property - [3]
  • Referencing Values with Refs - [4]
  • useEffect - React - [5]
  • Optional chaining (?.) - [6]