Untangling state changes in onMouseEnter, onMouseLeave, and onClick

Viewed 251

I have the following component

  const ListItem = ({ children, title = '' }) => {
    const [isActive, setIsActive] = useState(false)

    useEffect(() => {
      console.log('isActive', isActive)
    }, [isActive])

    return (
      <li className={`depth1${(isActive ? ' is-active' : '')}`} onMouseEnter={() => {
        console.log('onMouseEnter')
        setIsActive(true)
      }} onMouseLeave={() => {
        console.log('onMouseLeave')
        setIsActive(false)
      }}>
        <a href="#" onClick={(e) => {
          console.log('onClick a')
          e.preventDefault()
          setIsActive(!isActive)
        }}>{title}</a>
        {isActive && (
          <ul onClick={() => {
            console.log('onClick ul')
            setIsActive(!isActive)
          }}>
            {children}
          </ul>
        )}
      </li>
    )
  }

The component is used in the following context

<ul>
  <ListItem title="BRAND">
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
  </ListItem>
  <ListItem title="SERVICE">
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
  </ListItem>
  <ListItem title="CLIENT">
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
  </ListItem>
  <ListItem title="CONTENTS">
    <li>
      <a href="#" onClick={(e) => {
        e.preventDefault()
      }}>Link</a>
    </li>
  </ListItem>
</ul>

On my PC when the mouse hovers over a ListItem, onMouseEnter activates and console logs

onMouseEnter
isActive true

On my PC when the mouse leaves a ListItem, onMouseLeave activates and console logs

onMouseLeave
isActive false

Everything here works fine. The problem is on mobile when I open my navigation menu, I console log

isActive false

as expected.

But when I click a ListItem for the first time, I console log

onMouseEnter
isActive true
onClick a
isActive false

and my ListItem children do not open. But when I clicked the ListItem the second time I console log

onClick a
isActive true

which opens my ListItem children. If I click the ListItem the third time, I console log

onClick a
isActive false

which closes my ListItem children

How do I untangle my state changes so that on my first and not second click, my ListItem children open on mobile?

2 Answers

That's a surprisingly tricky problem. You can try several approaches. I haven't had time to try them out myself.

  • Approach 1: You can use the native non-bubbling events (mouseenter, mouseleave) on the <li> element. A hook to make the use of native events more convenient is useEvent (just copy the code, because I haven't made up my mind about the future of that lib)
import React, { useState, useCallback } from 'react';
import classnames from 'classnames';
import { useEvent } from 'the/path/to/the/hook';

const useMouseEnter = (listener, options) => useEvent('mouseenter', listener, options);
const useMouseLeave = (listener, options) => useEvent('mouseleave', listener, options);

const ListItem = ({ children, title = '' }) => {

  const [isActive, setIsActive] = useState(false);
  const toggle = () => setIsActive(_ => !_);
  const open   = () => setIsActive(true);
  const close  = () => setIsActive(false);

  const [setMouseEnterTarget] = useMouseEnter(open); // the setters are stable...
  const [setMouseLeaveTarget] = useMouseLeave(close);
  const setRef = useCallback((element) => {
    setMouseEnterTarget(element);
    setMouseLeaveTarget(element);
  }, []); // ...so we don't need them in the dependencies array

  return (
      <li ref={setRef} className={classnames('depth1', { 'is-active': isActive })}>
        <a href="#" onClick={(e) => {
          console.log('onClick a');
          e.preventDefault();
          toggle();
        }}>{title}</a>
  • Approach 2: You can detect whether hover is a feature of your current environment, and control your code behavior accordingly:
  const [isActive, setIsActive] = useState(false);
  const toggle = () => setIsActive(_ => !_);
  const open   = () => setIsActive(true);
  const close  = () => setIsActive(false);
  const supportsHover = useMedia('(hover: hover)');
  const mouseEvents = {
    onMouseEnter: open,
    onMouseLeave: close,
  };

  return (
      <li className={classnames('depth1', { 'is-active': isActive })}
        { ...supportsHover ? mouseEvents : undefined }
      >
        <a href="#" onClick={(e) => {
          console.log('onClick a');
          e.preventDefault();
          toggle();
        }}>{title}</a>

and the hook

import { useState, useEffect } from 'react';

export const useMedia = (query) => {
  const [matches, setMatches] = useState(window.matchMedia(query).matches);

  useEffect(() => {
    const media = window.matchMedia(query);
    if (media.matches !== matches) setMatches(media.matches);
    const listener = () => setMatches(media.matches);
    media.addListener(listener);
    return () => media.removeListener(listener);
  }, [matches, query]);

  return matches;
};
  • Approach 3: You can start a timer of e.g. 250ms after each toggle and ignore further toggling till the timer expired

  • Approach 4: You can prevent the state from being toggled more than once per event loop by synchronously setting a flag and by asynchronously removing the flag (therefore in the next event loop iteration) (But I don't know if the problematic events are triggered synchronously on the mobil device)

I found it very difficult to untangle the states so I resolved the issue with the following

const [isMobileNav, setIsMobileNav] = useState(false)

const windowWidthOnLoad = () => {
  const browser = window.innerWidth
  if (browser <= 1024) {
    setIsMobileNav(true)
  } else {
    setIsMobileNav(false)
  }
}

const windowWidthOnResize = () => {
  const browser = window.innerWidth
  if (browser <= 1024) {
    setIsMobileNav(true)
  } else {
    setIsMobileNav(false)
  }
}

useEffect(() => {
  windowWidthOnLoad()
  window.addEventListener('resize', windowWidthOnResize)
}, [])

const ListItem = ({ children, title = '' }) => {
  const [isActive, setIsActive] = useState(false)

  return (
    <>
      {/* click link once to show ListItem children on mobile */}
      {isMobileNav
        ? <li className={`depth1${(isActive ? ' is-active' : '')}`}>
          <a href="#" onClick={(e) => {
            e.preventDefault()
            setIsActive(!isActive)
          }}>{title}</a>
          {isActive && (
            <ul onClick={() => {
              setIsActive(!isActive)
            }}>
              {children}
            </ul>
          )}
        </li>
        : <li className={`depth1${(isActive ? ' is-active' : '')}`} onMouseEnter={() => {
          setIsActive(true)
        }} onMouseLeave={() => {
          setIsActive(false)
        }}>
          <a href="#" onClick={(e) => {
            e.preventDefault()
            setIsActive(!isActive)
          }}>{title}</a>
          {isActive && (
            <ul onClick={() => {
              setIsActive(!isActive)
            }}>
              {children}
            </ul>
          )}
        </li>
      }
    </>
  )
}
Related