Search code examples
javascriptreactjsdropdownaddeventlistener

React custom dropdown with event listener


I created a Dropdown that when I click outside of it the dropdown disappears. I used a click event listener to determine if I clicked outside the dropdown.

After a few clicks, the page slows down and crashes. Perhaps the state is being rendered in a loop or too many events are being fired at once?

How do I fix this? Also, is there a more React way to determine if I clicked outside an element? (Instead of using a document.body event listener)

Here is the codepen:

const items = [
    {
        value: 'User1'
    },
    {
        value: 'User2'
    },
    {
        value: 'User3'
    },
    {
        value: 'User4'
    },
    {
        value: 'User5'
    }
];
    
class Dropdown extends React.Component {
  state = {
    isActive: false,
  }

  render() {
    const { isActive } = this.state;
    document.addEventListener('click', (evt) => {
        if (evt.target.closest('#dropdownContent')) {
          //console.warn('clicked inside target do nothing');
          return;
        }

        if (evt.target.closest('#dropdownHeader')) {
          //console.warn('clicked the header toggle');
          this.setState({isActive: !isActive});
        }

        //console.warn('clicked outside target');
        if (isActive) {
          this.setState({isActive: false});
        }
      });
    
      return (
          <div id="container">
              <div id="dropdownHeader">select option</div>
              {isActive && (
                <div id="dropdownContent">
                  {items.map((item) => (
                    <div id="item" key={item.value}>
                      {item.value}
                    </div>
                  ))}
                </div>
              )}
          </div>
      );
  };
}
  ReactDOM.render(
    <Dropdown items={items} />,
    document.getElementById('root')
  );
#container {
    position: relative;
    height: 250px;
    border: 1px solid black;
}

#dropdownHeader {
    width: 100%;
    max-width: 12em;
    padding: 0.2em 0 0.2em 0.2em;
    margin: 1em;
    cursor: pointer;
    box-shadow: 0 1px 4px 3px rgba(34, 36, 38, 0.15);
}

#dropdownContent {
    display: flex;
    flex-direction: column;
    position: absolute;
    top: 3em;
    width: 100%;
    max-width: 12em;
    margin-left: 1em;
    box-shadow: 0 1px 4px 0 rgba(34, 36, 38, 0.15);
    padding: 0.2em;
}

#item {
    font-size: 12px;
    font-weight: 500;
    padding: 0.75em 1em 0.75em 2em;
    cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

<div id="root">
    <!-- This element's contents will be replaced with your component. -->
</div>


Solution

  • There's a pretty simple explanation for what you're experiencing. :)

    The way I was able to figure it out was the number of warnings that were showing up in the terminal every time I clicked somewhere was getting higher and higher, especially when the state changed.

    The answer though is that since you were adding the event listener code in the render function, every time the code re-rendered it would add more and more event listeners slowing down your code.

    Basically the solution is that you should move the adding of event listeners to componentDidMount so it's only run once.

    Updated working javascript:

        const items = [
            {
                value: 'User1'
            },
            {
                value: 'User2'
            },
            {
                value: 'User3'
            },
            {
                value: 'User4'
            },
            {
                value: 'User5'
            }
        ];
    
    class Dropdown extends React.Component {
      state = {
        isActive: false,
      }
    
      // added component did mount here
      componentDidMount(){
        const { isActive } = this.state;
        document.addEventListener('click', (evt) => {
            if (evt.target.closest('#dropdownContent')) {
              console.warn('clicked inside target do nothing');
              return;
            }
    
            if (evt.target.closest('#dropdownHeader')) {
              console.warn('clicked the header toggle');
              this.setState({isActive: !isActive});
            }
    
            console.warn('clicked outside target');
            if (isActive) {
              this.setState({isActive: false});
            }
          });
      }
    
      render() {
        const { isActive } = this.state;
        //removed event listener here
          return (
              <div id="container">
                  <div id="dropdownHeader">select option</div>
                  {isActive && (
                    <div id="dropdownContent">
                      {items.map((item) => (
                        <div id="item" key={item.value}>
                          {item.value}
                        </div>
                      ))}
                    </div>
                  )}
              </div>
          );
      };
    }
      ReactDOM.render(
        <Dropdown items={items} />,
        document.getElementById('root')
      );