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

Time:07-23

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?

CodePudding user response:

I think it could be something like

export function useElementOnScreen(ref: RefObject<HTMLElement>, rootMargin?: string) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;

    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsVisible(entry.isIntersecting);
      },
      { threshold: [0, 1], rootMargin }
    );

    observer.observe(element);

    return () => {
      observer.disconnect();
    };
  }, [ref, rootMargin]);

  return isVisible;
}

This hook returns boolean indicating if element is visible or not.

Note that the hook is not creating ref by itself - instead it expect it as an argument.

Usually it is a better pattern, as you might want to run multiple hooks on the same element. If each hook created independent ref - then there is no easy way to assign all of those refs to a single HTML element.

Example of usage:

export function Foo() {
  const elementRef = useRef<HTMLElement>(null);

  const isVisible = useIsVisible(elementRef);
  const isVisibleWithMargin = useIsVisible(elementRef, "100px");

  return (
    <div ref={elementRef}>
      {isVisible ? "visible" : "not visible"}
      {isVisibleWithMargin ? "visible with margin" : "not visible with margin"}
    </div>
  );
}

Note that I am saving ref.current value into const element. This is because ref.current is mutable and can be cleared by react when element unmounts. Thus we need to save it to variable so we can properly clean it up later.

CodePudding user response:

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 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 rerender. So if the ref changes, you cannot be sure that your 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 dicussion 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,>(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);
    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 rednder 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 (not required). It is initialized to 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.

  • Related