Search code examples
javascriptreactjstypescriptreact-hooksintersection-observer

Variable from inside React Hook useEffect will be lost after each render


I have here a text animation that is working perfect. What I want to add now is an Intersection Observer so that the animation only starts once I scroll down to the Box.

So what I did to achieve this is: I used the react hook useRef to use as reference to the element I want to observe and applied it to my Box with ref={containerRef}. Then declared a callback function that receives an array of IntersectionObserverEntries as a parameter, inside this function I take the first and only entry and check if it is intersecting with the viewport and if it is then it calls setIsVisible with the value of entry.isIntersecting (true/false). After that I added the react hook useEffect and created an observer contructor using the callback function and the options I just created before. I implemented the logic in a new hook that I called useElementOnscreen.

It is working BUT I am getting a warning and cant solve it:

1. Initial Code tsx file

const useElementOnScreen = <T,>(options: T): [MutableRefObject<HTMLDivElement | null>, boolean] => {
    const containerRef = useRef<HTMLDivElement | null>(null);
    const [isVisible, setIsVisible] = useState(false);

    const callbackFunction = (entries: IntersectionObserverEntry[]) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
    };

    useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);

        if (containerRef.current) observer.observe(containerRef?.current);

        return () => {
            if (containerRef.current) observer.unobserve(containerRef?.current);
        };
    }, [containerRef, options]);

    return [containerRef, isVisible];
};

Here I get the warning: if (containerRef.current) observer.unobserve(containerRef?.current);

The warning is:

(property) MutableRefObject<HTMLDivElement | null>.current: HTMLDivElement
The ref value 'containerRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'containerRef.current' to a variable inside the effect, and use that variable in the cleanup function.

eslint react-hooks/exhaustive-deps

2. What I tried to do to remove the warning is to save the current ref value to a locally scoped variable to be closed over in the function.

const useElementOnScreen = <T,>(options: T): [MutableRefObject<HTMLDivElement | null>, boolean] => {
    let observerRefValue: Element | null = null; // <-- variable to hold ref value
    const containerRef = useRef<HTMLDivElement | null>(null);
    const [isVisible, setIsVisible] = useState(false);

    const callbackFunction = (entries: IntersectionObserverEntry[]) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
    };

    useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);

        if (containerRef.current) observer.observe(containerRef?.current);
        observerRefValue = containerRef.current; // <-- save ref value

        return () => {
            if (observerRefValue) observer.unobserve(observerRefValue); // <-- use saved value
        };
    }, [containerRef, options]);

    return [containerRef, isVisible];
};

But then again here I am getting the warning: observerRefValue = containerRef.current; // <-- save ref value

(property) MutableRefObject<HTMLDivElement | null>.current: HTMLDivElement | null
Assignments to the 'observerRefValue' variable from inside React Hook useEffect will be lost after each render. To preserve the value over time, store it in a useRef Hook and keep the mutable value in the '.current' property. Otherwise, you can move this variable directly inside useEffect.eslintreact-hooks/exhaustive-deps

3. Now I changed my let observerRefValue: Element | null = null; to const [observerRefValue, setObserverRefValue] = useState<Element | null>(null); And the warning is gone and its working! However in the current state I am not using setObserverRefValue anywhere and I am getting the warning 'setObserverRefValue' is assigned a value but never used.

const useElementOnScreen = <T,>(options: T): [MutableRefObject<HTMLDivElement | null>, boolean] => {
    const [observerRefValue, setObserverRefValue] = useState<Element | null>(null);
    const containerRef = useRef<HTMLDivElement | null>(null);
    const [isVisible, setIsVisible] = useState(false);

    const callbackFunction = (entries: IntersectionObserverEntry[]) => {
        const [entry] = entries;
        setIsVisible(entry.isIntersecting);
    };

    useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);

        if (containerRef.current) observer.observe(containerRef?.current);

        return () => {
            if (observerRefValue) observer.unobserve(observerRefValue); // <-- use saved value
        };
    }, [observerRefValue, containerRef, options]);

    return [containerRef, isVisible];
};

Its just a warning but my question is:

Is my solution correct to handle the previous warning? Or are there maybe better solutions? And regarding to the'setObserverRefValue' is assigned a value but never used is there a way to use it in my example so I can get rid of the warning?


Solution

  • What Adam has posted, is a nice way to solve this. I use the exact same code pattern in my code base. The ref is not a part of the hook, and is passed as a prop. That way the hook only contains logic for checking if the Node pointed to by the ref is visible on the screen or not. This has worked for us uptil now.

    Now in your code, the hook takes care of the ref too. And you then attach the ref to any value returned by the hook. (Would be interested in knowing in the comments why you are following this pattern.)

    I will discuss all three code snippets you have provided:

    1. The useEffect
    useEffect(() => {
            const observer = new IntersectionObserver(callbackFunction, options);
    
            if (containerRef.current) observer.observe(containerRef?.current);
            observerRefValue = containerRef.current; // <-- save ref value
    
            return () => {
                if (observerRefValue) observer.unobserve(observerRefValue); // <-- use saved value
            };
        }, [containerRef, options]);
    
    

    Firstly, you can use containerRef as a dependency, but let me remind you that changing containerRef.current will not trigger a render. It is not a state variable and does not contribute to a re-render. So if the ref changes, you cannot be sure that the useEffect callback will run again and hence you might have wrong values in your ref container.

    The linting warning has the same concern:

    The ref value 'containerRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'containerRef.current' to a variable inside the effect, and use that variable in the cleanup function.

    Here is a link with a similar discussion. This code sandbox from the discussion is a great demo.

    function Box() {
      const ref = useRef();
    
      useEffect(() => {
        console.log("mount 1 ", ref.current);
        return () => setTimeout(() => console.log("unmount 1 ", ref.current), 0);
      }, []);
    
      useEffect(() => {
        const element = ref.current;
    
        console.log("mount 2 ", element);
        return () => setTimeout(() => console.log("unmount 2 ", element), 0);
      }, []);
    
      return (
        <div ref={ref} className="box">
          Box
        </div>
      );
    }
    
    export default function App() {
      let [state, setState] = useState(true);
    
      useEffect(() => {
        setTimeout(() => setState(false), 1000);
      }, []);
    
      return (
        <div className="App">
          <p>useEffect useRef warning</p>
          {state && <Box />}
        </div>
      );
    }
    

    The output for the first useEffect return function will be unmount 1 null, because value has changed, but the effect callback is unaware.

    Similar DOM manipulations can happen in your code too. Now that we acknowledge the warning makes sense, let us first get to the solution:

    The simple solution:

    const useOnScreen = <T extends IntersectionObserverInit>(options: T): [MutableRefObject<HTMLDivElement | null>, boolean] => {
      const containerRef = useRef<HTMLDivElement | null>(null);
      const [isVisible, setIsVisible] = useState(false);
    
      const callbackFunction = (entries: IntersectionObserverEntry[]) => {
        const [entry] = entries;
        if(entry)
        setIsVisible(entry.isIntersecting);
      };
    
      useEffect(() => {
        const observer = new IntersectionObserver(callbackFunction, options);
        const refCopy = containerRef.current;
        if (refCopy) observer.observe(refCopy);
    
        return () => {
          if (refCopy) observer.unobserve(refCopy);
        };
      }, [options]);
    
      return [containerRef, isVisible];
    };
    

    The above code solves all warnings. This is exactly what the linter suggested. The ref points to a DOM Element, which on unmount is null, but by keeping a copy of the variable, we can still access it and do whatever we want with it.

    Coming to your other attempts:

    1. observerRefValue is a non-special variable (not a ref variable nor a state variable) here, and the changes made to it will be cleared on each render. So you cannot rely on its value. After every re-render it gets assigned the value null as defined initially. Hence the warning. This also might not help your use case.

    2. You are creating an extra state variable here. It is initialised with null but never updated to any other value. The below condition will always be false and your intended code will not run only:

    if (observerRefValue)
    

    This will also not help your use case. Also, you should not be using a state variable if not required.