skip to Main Content

I have been working on an SPA with React 18 and The Movie Database (TMDB) API. I am curenly working on the search functionality.

There is a list of search results, as seen below.

enter image description here

For a nice scrollbar on this list, I use React-Perfect-Scrollbar.

I need this list to disapear when I click outside it.

For this purpose, in Searchbox.jsx, I have:

import PerfectScrollbar from 'react-perfect-scrollbar'
import { ReactComponent as Magnifier } from '../../icons/magnifier.svg';
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import Searchitem from './Searchitem';

function Searchbox() {

  const API_URL = 'https://api.themoviedb.org/3';
  const [searchInput, setSearchInput] = useState('');
  const [results, setResults] = useState([]);
  let [isSearch, setIsSearch] = useState(Boolean);
  const timeOutInterval = 500;
  const searchResultsContainer = useRef(null);

  const doMovieSearch = async (e) => {
    setSearchInput(e.target.value);

    if (e.target.value.length >= 3) {
      setIsSearch(true);

      const { data: { results } } = await axios.get(`${API_URL}/search/movie`, {
        params: {
          api_key: process.env.REACT_APP_API_KEY,
          query: e.target.value
        }
      });

      setResults(results);
    } else {
      setIsSearch(false);
    }
  }

  const debounceMovieSearch = (e) => {
    setTimeout(() => doMovieSearch(e), timeOutInterval);
  }

  const hideSearchResults = () => {
    setIsSearch(false);
  }

  const handleOutsideClick = (e) => {
    if (!searchResultsContainer.current.contains(e.target)) {
      setIsSearch(false);
    } else {
      setIsSearch(true);
    }
  }

  useEffect(() => {
        document.addEventListener('click', handleOutsideClick, true);
  }, [])

  return (
    <form className="search_form w-100 mx-auto mt-2 mt-md-0">
      <div className="input-group">
        <input className="form-control search-box" type="search" defaultValue={searchInput} onKeyUp={debounceMovieSearch} placeholder="Search movies..." />
        <div className="input-group-append">
          <button className="btn" type="button">
            <Magnifier />
          </button>
        </div>
      </div>

      {isSearch ?
        <PerfectScrollbar ref={searchResultsContainer} className={"search-results shadow-sm" + (results.length ? ' with-results' : null)}>
          {results.length ?
            <>
              {results.map(movie => (
                <Searchitem key={movie.id} movie={movie} hideSearchResults={hideSearchResults} />
              ))}
            </> : <></>
          }

          {!results.length ?
            <>
              <p className="no-results">No movies found for this search</p>
            </> : <></>
          }
        </PerfectScrollbar> : <></>
      }
    </form>
  );
}

export default Searchbox;

The handleOutsideClick method should do that.

The problem

For a reason I have been unable to figure out, clicking outside the search list results in this error:

Cannot read properties of null (reading ‘contains’)
TypeError: Cannot read properties of null (reading ‘contains’)

Questions

  1. What am I doing wrong?
  2. What is the most reliable way to fix this issue?

2

Answers


  1. Chosen as BEST ANSWER

    I have solved it by checking if the click event is outside the (parent) form, not the search results list. This code gives a good result:

    import PerfectScrollbar from 'react-perfect-scrollbar'
    import { ReactComponent as Magnifier } from '../../icons/magnifier.svg';
    import { useState, useEffect, useRef } from 'react';
    import axios from 'axios';
    import Searchitem from './Searchitem';
    
    function Searchbox() {
    
      const API_URL = 'https://api.themoviedb.org/3';
      const [searchInput, setSearchInput] = useState('');
      const [results, setResults] = useState([]);
      let [isSearch, setIsSearch] = useState(Boolean);
      const timeOutInterval = 500;
      const searchResultsContainer = useRef(null);
    
      const doMovieSearch = async (e) => {
        setSearchInput(e.target.value);
    
        if (e.target.value.length >= 3) {
          setIsSearch(true);
    
          const { data: { results } } = await axios.get(`${API_URL}/search/movie`, {
            params: {
              api_key: process.env.REACT_APP_API_KEY,
              query: e.target.value
            }
          });
    
          setResults(results);
        } else {
          setIsSearch(false);
        }
      }
    
      const debounceMovieSearch = (e) => {
        setTimeout(() => doMovieSearch(e), timeOutInterval);
      }
    
      const hideSearchResults = () => {
        setIsSearch(false);
      }
    
      const handleOutsideClick = (e) => {
        if (!searchResultsContainer.current.contains(e.target)) {
          setIsSearch(false);
        }
      }
    
      useEffect(() => {
            document.addEventListener('click', handleOutsideClick, true);
        }, [])
    
      return (
        <form ref={searchResultsContainer} className="search_form w-100 mx-auto mt-2 mt-md-0">
          <div className="input-group">
            <input className="form-control search-box" type="search" defaultValue={searchInput} onChange={debounceMovieSearch} placeholder="Search movies..." />
            <div className="input-group-append">
              <button className="btn" type="button">
                <Magnifier />
              </button>
            </div>
          </div>
    
          {isSearch ?
            <PerfectScrollbar className={"search-results shadow-sm" + (results.length ? ' with-results' : null)}>
              {results.length ?
                <>
                  {results.map(movie => (
                    <Searchitem key={movie.id} movie={movie} hideSearchResults={hideSearchResults} />
                  ))}
                </> : <></>
              }
    
              {!results.length ?
                <>
                  <p className="no-results">No movies found for this search</p>
                </> : <></>
              }
            </PerfectScrollbar> : <></>
          }
        </form>
      );
    }
    
    export default Searchbox;
    

  2. Your PerfectScrollbar component is only displayed when isSearch is true, so when that state is false, the searchResultContainer ref has no current value (so null).

    You need to, either:

    • deactivate the handleClickOutside listener when isSearch is false by using this variable in your useEffect dependency array (also, beware that you didn’t set any cleanup function as your useEffect return value, which you should).
    • create a container component around your PerfectScrollbar component, which is always displayed (even if it’s an empty <div>) and is passed to the searchResultContainer ref, so it’s value never null, after the first render that is.

    I found two open source implementations pf this feature, if you want to look at their source code:

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search