Search code examples
reactjsaddeventlisteneruse-effect

addEventListener inside useLayoutEffect: the event listener is added multiple times


I have this useLayoutEffect (it happens the same inside useEffect which basically listens an element for touchStart and touchEnd to know which direction the user is trying to swipe.

useLayoutEffect(() => {
  if (window) {
    setWindowWidth(window.innerWidth);
    window.onresize = () => {
      setWindowWidth(window.innerWidth);
    };
  }

  let touchStartX = 0;
  let touchEndX = 0;
  let movingTestimonials = false;

  const handleGesture = () => {
    if (movingTestimonials) {
      return false;
    }
    movingTestimonials = true;
    const diff = touchStartX - touchEndX;
    const moves = diff > 0 ? 1 : -1;
    const next = (isActive + moves) % Math.ceil(data.testimonials.length / testimonialsToShow());
    const finalNext = next >= 0 ? next : testimonialsToShow() + 1 - next;

    setIsActive(finalNext);
    movingTestimonials = false;
  };

  const touchStartHandler = (e) => {
    touchStartX = e.touches[0]?.pageX;
  };

  const touchEndtHandler = (e) => {
    touchEndX = e.changedTouches[0]?.pageX;
    if (touchEndX) {
      handleGesture();
    }
  };

  if (testimonialRef && testimonialRef.current) {
    testimonialRef.current.addEventListener('touchstart', touchStartHandler, true);
    testimonialRef.current.addEventListener('touchend', touchEndtHandler, true);
    console.log('added event listeners');
  }
}, [isActive]);

All works as expected the very few first times that users swipes, then it becomes very slow because the event listener is added exponentially.

https://watch.screencastify.com/v/ltYIkI9X8uSlJPm7hLXG

how can I do it so the eventListener is added only once?


Solution

  • You can't with the way it is currently implemented, because handleGesture is dependent on isActive, so the event listeners must be recreated every time it changes to get the current value. You can do event cleanup to make sure that only the one correct set of handlers is attached at any one time:

    useLayoutEffect(() => {
      ...
    
      return () => {
        testimonialRef.current.removeEventListener('touchstart', touchStartHandler);
        testimonialRef.current.removeEventListener('touchend', touchEndtHandler);
      }
    }, [isActive]);
    

    Alternatively, you could get rid of the dependency and just track the change with a locally scoped variable. This will only work if testimonalRef is assigned on mount and does not change:

    useLayoutEffect(() => {
      ...
    
      let touchStartX = 0;
      let touchEndX = 0;
      let _isActive = false;
      let movingTestimonials = false;
    
      const handleGesture = () => {
        if (movingTestimonials) {
          return false;
        }
        movingTestimonials = true;
        const diff = touchStartX - touchEndX;
        const moves = diff > 0 ? 1 : -1;
        const next = (_isActive + moves) % Math.ceil(data.testimonials.length / testimonialsToShow());
        const finalNext = next >= 0 ? next : testimonialsToShow() + 1 - next;
    
        _isActive = finalNext;
        setIsActive(finalNext);
        movingTestimonials = false;
      };
    
      ...
    }, []);
    

    To be honest though, you should almost never need to manually attach event listeners to DOM elements in a React environment. You are fighting with React here, when you should really be trying to attach your event handlers via JSX - this is not a sensible use of the useLayoutEffect hook.