Search code examples
javascriptreactjsreact-hooksintersection-observer

Should I use react State for adding and removing classes?


I am using intersection observer to add and remove classes so I can start an animation when certain div is being exhibited in the viewport. I am using this basically to add animation just like this website here:https://crypto.com/us, notice that when you scroll and certain section is exhibited an animation starts, showing the titles.

My code is below and is working fine:

useEffect(() => {
    const titleElements = document.querySelectorAll('.title, .description, .text');
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: .4
    }

    const callbacks = (entries: any) => {
      entries.forEach((e: any) => {
        if (e.isIntersecting && e.target.className === 'text') {
          e.target.classList.add('text-animation');
        } else if (e.isIntersecting && e.target.className !== 'text') {
          e.target.classList.add('title-animation');
        } else {
          e.target.classList.remove('title-animation');
          e.target.classList.remove('text-animation');
        }
      });
    }

    let observer = new IntersectionObserver(callbacks, options);

    titleElements.forEach((e) => {
      observer.observe(e);
    })
  }, [])

I also noticed that I am not using react useState in another class. Everytime a value changes this function below runs returning one or another class. Obs: the function runs everytime when the value changes, when it happens the component is rendered again because I set the key value to always be different so if this function returns 'current-price-up' two or more times this animation will runs always as react is rendering a 'new' component everytime.

const handleUpdateEffect = (price: any, i: any) => {
    if (prevList.length === 1) {
      return ''
    } else if (price > prevList[i].current_price) {
      return 'current-price-up'
    } else if (price < prevList[i].current_price) {
      return 'current-price-down'
    } else {
      return 'current-price-keep'
    }
  }

Everything is working fine, my question is: once I am using react is it bad practice to add and remove classes directly in the DOM even if I am using useEffect (classes are added or removed after everything is rendered) in the first example or using a function in the second example?

Should I apply useState for all those classes? Does it cause performance problems? I've always used useState but I find this way using '.classList.add' and it is working in this case, and I also realized I am using a function instead of state in the second example, what way is right in what contexts? Should I always use useState to toggle between classes to start animations and add effects?


Solution

  • TL;DR - yes, you shouldn't be adding and removing classNames using element.classList when using React

    The "proper" way to handle this situation really depends on the way in which you want to render your elements.

    If the elements you want to add the animation to are very disparate and unrelated, you could consider separating this logic into some sort of component or hook, which gives each one of them their own IntersectionObserver and isAnimating state; something like:

    const options = {
      root: null,
      rootMargin: '0px',
      threshold: .4
    }
    
    function useAnimateOnScroll(element: HTMLElement | null) {
      const [isAnimating, setIsAnimating] = useState(false);
    
      useEffect(() => {
        if (!element) return;
    
        const observer = new IntersectionObserver((entries) => {
          if (entries[0].isIntersecting) setIsAnimating(true);
          else setIsAnimating(false);
        }, options);
    
        observer.observer(element);
      }, [element]);
    
      return isAnimating;
    }
    
    function MyAnimatingTitle() {
      const [element, setElement] = useState<HTMLElement | null>(null);
      const isAnimating = useAnimateOnScroll(element);
    
      return (
        <div ref={(ref) => {
          // this is a way to get the reference to the actual DOM element
          setElement(ref);
        } className={`text ${isAnimating ? "text-animation" : ""}`}>My Title</div>
      );
    }
    

    This hook will give each component/element responsibility over its own animation. I see from your question that you're worried about performance, but I think this is a case of premature optimization. I am almost entirely certain that this approach won't have any noticeable impact on the performance of your application; having a few more useState calls won't make your web-page slower.

    If your elements are related, however, you might want to map over some array to render them. You could then use this array for the IntersectionObserver and for the relevant classNames.

    If you have more than one element you can push their ref into an variable array and setElement after rendering using useEffect to get all the ref, doing that you avoig multiple re-renders as the useEffect in the useAnimateOnScroll function is called everytime you setElement.

     useEffect(() => {
        setElement([...elementArray]);
    }, [])
    

    Then in the useAnimateOnScroll function you map it to get the observer for each element, for reference you can use a key or id as I did:

    function useAnimateOnScroll(element: Array<HTMLElement | null>) {
      const [isAnimating, setIsAnimating] = useState<{[key: string]: boolean}>({});
    
      useEffect(() => {
        const observer = new IntersectionObserver((entries) => {
          console.log(entries);
          entries.map((e: any) => {
            const name = e.target.id
            if (e.isIntersecting) {
              setIsAnimating((prev) => {
                return {
                  ...prev, [name]: true,
                }
              });
            } else {
              setIsAnimating((prev) => {
                return {
                  ...prev, [name]: false,
                }
              })
            }
            
          })
        }, options);
    
        element.map((e: HTMLElement | null) => {
          observer.observe(e!);
        });
      }, [element]);
      console.log(isAnimating);
      console.log(isAnimating['box1']);
      return isAnimating;
     }
    

    And then for acessing this observing you use the reference you setted previously as the name in the useAnimateOnScroll function, you can see that I am using the id to reference them:

    function MyAnimatingTitle() {
      const [element, setElement] = useState<HTMLElement | null>(null);
      const isAnimating = useAnimateOnScroll(element);
    
      return (
        <div id='title2' className={`title ${isAnimating['title2'] ? 
         'title-animation' : ''}`} ref={(ref) => {
                  elementArray.push(ref);
                }}> This is the title 1</div>
    
        <div id='descrip1' className={`title ${isAnimating['descrip1'] ? 
         'description-animation' : ''}`} ref={(ref) => {
                  elementArray.push(ref);
                }}>This is the description</div>
    
        <div id='title2' className={`title ${isAnimating['title2'] ? 
         'title-animation' : ''}`} ref={(ref) => {
                  elementArray.push(ref);
                }}>This is 2nd title</div>
      );
    }
    

    I figure it out how to do it for multiple elements by this resource I find and adapted it to my problem: https://betterprogramming.pub/react-useinview-hook-intersection-observer-animations-and-multiple-refs-73c68a33b5b1