Shamil Maashev
Shamil Maashev

Reputation: 53

React: I can't get updated state values in the onScroll function

Task: I am trying to implement dynamic loading of content when scrolling for a reaction. I have an onScroll function to listen for a scroll event, which switches the isFetching variable to true when the page is scrolled all the way down. When isFetching === true, the code from useEffect is executed and the content is loaded. Everything works as it should, but I need to add another condition to onScroll, which is triggered while the amount of loaded data is < the total amount of data.

Problem: I have variables schoolResults and schoolResultsCount, which contain the number of loaded data and the total amount of data, but they are not updated when scrolling, they are always 0 when scrolling. I understand that I can transfer the function to useEffect or use a condition in useEffect and everything will work as I need. But I don't understand why the updated values of external variables are not available in the function?

Thank you for your help!

export default function Results(props: Props) {
   const location = useLocation();

   const [isLoading, setLoading] = useState<boolean>(false);
   const [isFetching, setFetching] = useState<boolean>(false);
   const [schoolResults, setSchoolResults] = useState<SchoolEvent[]>([]);
   const [schoolResultsCount, setSchoolResultsCount] = useState<number>(0);
   const [currentPage, setCurrentPage] = useState<number>(1);

   const { school } = props;
   const { id: schoolId } = school;


   useEffect(() => {
      setLoading(true);

      const promises = [
         getSchoolEvents(schoolId, getFilterForAllSchoolResults()),
         getSchoolEventsCount(schoolId, getFilterForAllSchoolResults())
      ];

      Promise.all(promises).then(([events, eventsCountObj]) => {
         setSchoolResults(events);
         setSchoolResultsCount(eventsCountObj.count);
         setLoading(false);
      });
   }, [location.search]);

   useEffect(() => {
      if (isFetching) {

         const queryFilter = {
            ...getFilterForAllSchoolResults(),
            skip: currentPage * LIMIT
         };

         getSchoolEvents(schoolId, queryFilter)
            .then((events) => {
               setSchoolResults([...schoolResults, ...events]);
               setCurrentPage(prevState => prevState + 1);
            })
            .finally(() => setFetching(false));
      }
   }, [isFetching]);

   useEffect(() => {
      document.addEventListener('scroll', onScroll);

      return function () {
         document.removeEventListener('scroll', onScroll);
      }
   }, []);

   const onScroll = (event: Event) => {
      const { target } = event;

      const scrollHeight = propz.get(target, ['documentElement', 'scrollHeight']);
      const scrollTop = propz.get(target, ['documentElement', 'scrollTop']);
      const windowInnerHeight = window.innerHeight;

      const isBottomOfPage = scrollHeight - (scrollTop + windowInnerHeight) < 100;

      // console.log('schoolResults', schoolResults.length); //The value is 0. Not updated. Why?
      // console.log('schoolResultsCount', schoolResultsCount); // The value is 0. Not updated. Why?

      if (isBottomOfPage /*&& schoolResults.length > schoolResultsCount*/) {
         setFetching(true);
      };
   };

   if (isLoading) {
      return <Loader />;
   }

   return (//Some JSX)
}

Upvotes: 3

Views: 1612

Answers (1)

Robin Zigmond
Robin Zigmond

Reputation: 18249

This is a classic stale closure problem.

You have a useEffect with no dependencies, that attaches the onScroll handler to the scroll event when the component mounts (and removes it when the component unmounts). Because of the empty dependency array, this function is the same for the lifetime of the component.

And that's the problem, because the onScroll that's in scope on that first render is one that references an empty array as its schoolResults, because that's what the const schoolResults holds on that first render. Yes it will have a different value on subsequent renders, but those are forever inaccessible to the onScroll event handler that's being used.

The best solution likely depends on details of your component that you haven't given. The simplest is to replace the state with a ref, since refs, unlike state variables, maintain the same identity between renders, with only their current property updating (mutating). So you can replace

const [schoolResults, setSchoolResults] = useState<SchoolEvent[]>([]);

with

const schoolResults = useRef<SchoolEvent[]>([]);

and replace all calls to setSchoolResults(someValue) to schoolResults.current = someValue. (And likewise where you reference schoolResults to get its value, replace that by schoolResults.current.) This will solve the stale closure problem. (And you'll have to do this with each of the relevant state variables.)

However the disadvantage of refs as opposed to state is that updating a ref does NOT cause a rerender, unlike state updates. So if anything in your Component's rendered JSX output uses isLoading, this won't work as expected.

So in that case you'll have to use another solution, which involves being honest about the fact that your state is updating so you need to update the event handler function to one which uses the correct value. There are only a few steps though:

First, wrap the event handler in useCallback, and put all the relevant state values you're referencing in its dependency array:

const onScroll = useCallback(() => { /* your function goes here */, [schoolResults, schoolResultsCount, /* you possibly need other dependencies here, that are used inside your function */]};

Then add onScroll as a dependency to the useEffect which adds the scroll handler:

useEffect(() => {
  document.addEventListener('scroll', onScroll);

  return function () {
     document.removeEventListener('scroll', onScroll);
  }
}, [onScroll]);

This should then work as you expect, by ensuring the scroll handler always holds up-to-date values of your state variables.

Upvotes: 6

Related Questions