React: Dropdown Selector w/ useRef Hook

Intro

We are going to build a re-usable dropdown component that gets it’s options passed down to it in the form of props.

Scaffolding

First let us create our App and import a dropdown component (yet to be created), and we can define a set of dropdown options in a static array and pass that array into the component as a prop.

App.js
import React from "react"

import Dropdown from "./components/Dropdown"

const dropdownOptions = [
  {
    label: "The Color Red",
    value: "red",
  },
  {
    label: "The Color Green",
    value: "green",
  },
  {
    label: "The Color Blue",
    value: "blue",
  },
]

export default () => {
  return (
    <div>
      <Dropdown dropdownOptions={dropdownOptions} />
    </div>
  )
}

and in our dropdown component we will just start with the following blank functional component

components/Dropdown.js
import React from "react"

const Dropdown = ({ dropdownOptions }) => {
  return <div>The Dropdown Menu Will Go Here</div>
}

export default Dropdown

which we can then map through the dropdown options

components/Dropdown.js
import React from "react"

const Dropdown = ({ dropdownOptions }) => {
  const mappedDropdownOptions = dropdownOptions.map(option => {
    return (
      <div key={option.value} className="item">
        {option.label}
      </div>
    )
  })

  return { mappedDropdownOptions }
}

export default Dropdown

Creating the Dropdown

Because the focus of this article is not on CSS, we are going to be using Semantic UI to create the dropdown styling. Here we can lay out the basics of the dropdown.

"components/Dropdown.js"
import React from "react"

const Dropdown = ({ dropdownOptions }) => {
  const mappedDropdownOptions = dropdownOptions.map(option => {
    return (
      <div key={option.value} className="item">
        {option.label}
      </div>
    )
  })

  return (    <div className="ui form">      <div className="field">        <label className="label">Select a Color</label>        <div className="ui selection dropdown visible active">          <i className="dropdown icon"></i>          <div className="text">Select Color</div>          <div className="menu visible transition">{mappedDropdownOptions}</div>        </div>      </div>    </div>  )}

export default Dropdown

Which will give us the following dropdown with all of the options visible.

dropdown with all options visible

Using State To Manage Dropdown Selection

Initializing State

We will be using the useState hook to keep track of state inside of our dropdown widget. First we must initialize the hook in our App, give a name to the state and the state update function (selected & setSelected) and then pass those items into the dropdown as props.

App.js
import React, { useState } from "react"
import Dropdown from "./components/Dropdown"

const dropdownOptions = [
  {
    label: "The Color Red",
    value: "red",
  },
  {
    label: "The Color Green",
    value: "green",
  },
  {
    label: "The Color Blue",
    value: "blue",
  },
]

export default () => {
  // state to manage dropdown selection
  const [selected, setSelected] = useState(dropdownOptions[0])
  return (
    <div>
      <Dropdown
        dropdownOptions={dropdownOptions}        selected={selected}        onSelectedChange={setSelected}      />
    </div>
  )
}

Note that we have passed in the first item of our index [0] as the default prop.

Because setSelected is a function being passed in as a prop, and is therefore a callback, we name that prop with the convention onSomethingChange.

Next we will go into our dropdown component, receive these props and then wire them up to our JSX.

Settings Default Selection

To start we can pass in our default selection as the text.

components/Dropdown.js
import React from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  const mappedDropdownOptions = dropdownOptions.map(option => {
    return (
      <div key={option.value} className="item">
        {option.label}
      </div>
    )
  })

  return (
    <div className="ui form">
      <div className="field">
        <label className="label">Select a Color</label>
        <div className="ui selection dropdown visible active">
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>          <div className="menu visible transition">{mappedDropdownOptions}</div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

default selection

Click Event Handler

Next we need to set up a click event handler on each of our dropdown options that will use our callback function to set the state to the option we just clicked.

components/Dropdown.js
import React from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  const mappedDropdownOptions = dropdownOptions.map(option => {
    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}      >
        {option.label}
      </div>
    )
  })

  return (
    <div className="ui form">
      <div className="field">
        <label className="label">Select a Color</label>
        <div className="ui selection dropdown visible active">
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div className="menu visible transition">{mappedDropdownOptions}</div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

And now when we click on the dropdown items it updates the state of selected which updates the selection shown in the text div.

dropdown selection updating state

Filtering The Option List

There are some improvements that we can make to this dropdown. For starters, we don’t want the options to be visible until we click on the dropdown. And secondly, we don’t want the currently selected option to be visible in the selection AND in the list, so we want to filter the current selection out of the list.

The way we are going to do this, is we are going to intervene when the options are being mapped.

We can create a simple check to see if the selected option is the same as the option that is currently being mapped over. If it is, instead of returning a div as usual, we just return null, effectively skipping this item.

components/Dropdown.js
import React from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  const renderedOptions = dropdownOptions.map(option => {
    // if current selection is equal to option do not generate div
    if (option.value === selected.value) {      return null    }
    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}
      >
        {option.label}
      </div>
    )
  })

  return (
    <div className="ui form">
      <div className="field">
        <label className="label">Select a Color</label>
        <div className="ui selection dropdown visible active">
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div className="menu visible transition">{renderedOptions}</div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

dropdown selection filtered working

Hiding and Showing the Option List

The way that we toggle the visibility of the dropdown is by toggling the application of a couple of CSS classes. Again, we are using Semantic UI here, so we aren’t going to go into writing out these various classes, but simply toggling what classes are applied.

This is going to be the same concept as we have covered here: Ncoughlin: React - Conditional Styling and here Ncoughllin: Gatsby - Conditional Table of Contents except this time we will be using hooks, template literals and ternary operators.

components/Dropdown.js
import React, { useState } from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  // state to manage toggle visibility
  const [open, setOpen] = useState(false)
  const renderedOptions = dropdownOptions.map(option => {
    // if current selection is equal to option do not generate div
    if (option.value === selected.value) {
      return null
    }

    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}
      >
        {option.label}
      </div>
    )
  })

  return (
    <div className="ui form">
      <div className="field">
        <label className="label">Select a Color</label>
        <div
          // on click set value of open to opposite of current value
          onClick={() => setOpen(!open)}          className={`ui selection dropdown ${open ? "visible active" : ""}`}        >
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div
            // on click set value of open to opposite of current value
            onClick={() => setOpen(!open)}            className={`menu ${open ? "visible transition" : ""}`}          >
            {renderedOptions}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

The above syntax can be a bit confusing as we are mixing template literals with ternary operators. Just remember that the ternary operator is saying “if open is true apply this string, else apply this empty string” in the shortest method possible.

toggle visibility working

Close Dropdown When Clicking Outside of Element

So this all appears to be working correctly, and it is, but we have one major feature this dropdown is missing that most users would expect us to have. Clicking outside of the options does not close the toggle!

This is actually quite complicated to implement inside of React and this is the where the useRef hook finally comes in.

Why This Is Difficult

This is a difficult problem because we essentially need to set up a click event listener OUTSIDE of this component. Setting up event listeners on items inside of the component is easy. But right now the component has no visibility on what is happening outside of its own scope, which is what we are looking at when we want to listen for a click anywhere that isn’t inside the component.

Event Bubbling

In order to understand how to solve this problem we first need to become familiar with the concept of event bubbling.

Every time you trigger an event handler, the browser creates a click item which it then sends (bubbles) up through each layer of the DOM, checking each layer along the way to see if it requires any action at that layer. This is an expansive topic, so instead of covering this in depth here please check out Javascript.info: Bubbling and Capturing

Vanilla Event Listeners & useEffect Hook

The solution to this is to combine two previous tools that we have learned. The first was something we learned a long time ago, which is vanilla JavaScript click event listeners. For example here is one that console logs “CLICK” every time the mouse is clicked anywhere in the document body.

document.body.addEventListener("click", someFunction())

and the second is something that we learned more recently, the useEffect() hook.

Ncoughlin: React Hooks useEffect

Just as a brief reminder, the useEffect hook is a way to do something LIKE a component lifecycle, inside of a functional component. We can use this hook to run a function on initial page load, and we can make a vanilla event listener be that function.

components/Dropdown.js
import React, { useState, useEffect } from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  // state to manage toggle visibility
  const [open, setOpen] = useState(false)

  // on initial render, add click event listener
  useEffect(() => {    document.body.addEventListener("click", () => {      console.log("CLICK")    })  }, [])
  const renderedOptions = dropdownOptions.map(option => {
    // if current selection is equal to option do not generate div
    if (option.value === selected.value) {
      return null
    }

    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}
      >
        {option.label}
      </div>
    )
  })

  return (
    <div className="ui form">
      <div className="field">
        <label className="label">Select a Color</label>
        <div
          // on click set value of open to opposite of current value
          onClick={() => setOpen(!open)}
          className={`ui selection dropdown ${open ? "visible active" : ""}`}
        >
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div
            // on click set value of open to opposite of current value
            onClick={() => setOpen(!open)}
            className={`menu ${open ? "visible transition" : ""}`}
          >
            {renderedOptions}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

And now every time the Dropdown component is on the page, there will also be a click listener that will fire anytime ANYTHING is clicked. We could simply change the function inside the event listener to be setOpen(false), but that wouldn’t completely solve our problem.

The Order of Event Listeners

Event listeners will be executed in a specific order. First will be all the vanilla Javascript event listeners, and secondly the React event listeners, starting with the most child element and then moving up to the biggest parent. Meaning that of the three click event listeners that we have in the component right now, the vanilla body listener will be called first, then the option selection, then the value.

In this case if we clicked on the value div we would be firing two click event listeners, the top level vanilla listener, which would always turn the value of open to false, and then the listener on the component, which sets it to the opposite of what it currently is.

That makes it so that our dropdown will never close. Every time we click the dropdown expecting the value of open to toggle, we actually toggle it twice and it will stay open.

Event Listener Target

What we really want here is a way to distinguish what item was clicked on. Specifically, if we click on something in the dropdown component, we want the vanilla body event listener to do nothing.

When we use the vanilla addEventListener method we receive back an event that contains information including the target.

useEffect(() => {
  document.body.addEventListener("click", event => {
    console.log(event.target)
  })
}, [])

targets in console

So how can we know if our target was an item created by the Dropdown component? Here…finally…is where we come to the useRef() hook.

useRef Hook

The useRef hook can be used quite simply to create a reference to a parent element, and if anything inside of that element is referenced with an event listener we can create an action for that.

components/Dropdown.js
import React, { useState, useEffect, useRef } from "react"

const Dropdown = ({ dropdownOptions, selected, onSelectedChange }) => {
  // state to manage toggle visibility
  const [open, setOpen] = useState(false)
  // set ref variable
  const ref = useRef()
  // on initial render, add click event listener
  useEffect(() => {
    document.body.addEventListener("click", event => {
      // check if element that was clicked is inside of ref'd component
      // if so no action is required from this event listener so exit
      if (ref.current.contains(event.target)) {        return      }      // else close the dropdown
      setOpen(false)
    })
  }, [])

  const renderedOptions = dropdownOptions.map(option => {
    // if current selection is equal to option do not generate div
    if (option.value === selected.value) {
      return null
    }

    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}
      >
        {option.label}
      </div>
    )
  })

  return (
    // set the reference on the parent element
    <div ref={ref} className="ui form">
      {" "}
      <div className="field">
        <label className="label">Select a Color</label>
        <div
          // on click set value of open to opposite of current value
          onClick={() => setOpen(!open)}
          className={`ui selection dropdown ${open ? "visible active" : ""}`}
        >
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div
            // on click set value of open to opposite of current value
            onClick={() => setOpen(!open)}
            className={`menu ${open ? "visible transition" : ""}`}
          >
            {renderedOptions}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

So in plain english, what we have done here is, inside of our vanilla event listener, before we change the state of open to false (close the dropdown) we check to see if the currently referenced item (the dropdown) is the item that triggered the event listener. If it was, we exit the listener having done nothing.

Avoiding ref.current is null error

A common error to get when using references is something like “ref.current is null” or “cannot read property of null” referring to the reference. It’s common to get this error if the object that is referenced has been removed from the DOM, either with some sort of visibility toggle or React navigation.

ref.current is null error

The way to fix this is, whenever you are about to remove the referenced item from the DOM, turn off the event listener using a cleanup function.

Review the useEffect hook below. We have changed a couple of things from the previous version. First of all we have broken the click function out into a helper function called onBodyClick to make everything more legible. Then we put that helper function into the callback on the event listener.

So far we have changed nothing functionally.

Lastly we add a new cleanup function to remove the event listener (see highlight).

components/Dropdown.js
import React, { useState, useEffect, useRef } from "react"

const Dropdown = ({ label, options, selected, onSelectedChange }) => {
  // state to manage toggle visibility
  const [open, setOpen] = useState(false)
  // set ref variable
  const ref = useRef()

  // close dropdown if clicked anywhere outside of dropdown
  // on initial render, add click event listener
  useEffect(() => {
    const onBodyClick = event => {
      // check if element that was clicked is inside of ref'd component
      // if so no action is required from this event listener so exit
      if (ref.current.contains(event.target)) {
        return
      }
      // else close the dropdown
      setOpen(false)
    }

    // add event listener
    document.body.addEventListener("click", onBodyClick)

    // CLEANUP
    // remove event listener
    return () => {      document.body.removeEventListener("click", onBodyClick)    }  }, [])

  // map options from props
  const renderedOptions = options.map(option => {
    // if current selection is equal to option do not generate div
    if (option.value === selected.value) {
      return null
    }

    return (
      <div
        key={option.value}
        className="item"
        // on click change selection to current option
        onClick={() => onSelectedChange(option)}
      >
        {option.label}
      </div>
    )
  })

  return (
    <div ref={ref} className="ui form">
      <div className="field">
        <label className="label">{label}</label>
        <div
          // on click set value of open to opposite of current value
          onClick={() => setOpen(!open)}
          className={`ui selection dropdown ${open ? "visible active" : ""}`}
        >
          <i className="dropdown icon"></i>
          <div className="text">{selected.label}</div>
          <div
            // on click set value of open to opposite of current value
            onClick={() => setOpen(!open)}
            className={`menu ${open ? "visible transition" : ""}`}
          >
            {renderedOptions}
          </div>
        </div>
      </div>
    </div>
  )
}

export default Dropdown

By removing the event listener with a cleanup function, when the Dropdown component is not shown, the event listener will not fire and try to reference a DOM object that is not there, avoiding the null ref error.

Github Repo

Ncoughlin: React Widgets