skip to Main Content

A popular infinite scroll react component has not been maintained for a few years now, so I put together something simple using IntersectionObserver.

const InfiniteScroll = (props: Props) => {
    const bottomRef = useRef(null);

    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {
            if (entries[0].isIntersecting)
                props.next();
        });

        if (bottomRef.current)
            observer.observe(bottomRef.current);

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

    return (
        <>
            {props.children}
            {props.hasMore && <div ref={bottomRef}></div>}
        </>
    )
}

export default InfiniteScroll;

When the div with ref bottomRef intersects the viewport the callback ensures props.next() is run and more data is fetched. And it works well enough if the initial content is sufficiently large to scroll off the page.

If the initial content already fits within the viewport then I see an initial "observer intersection" event and then nothing. Presumably because the IntersectionObserver already notified that the intersection happened, and the addition of new data did not change the state of that original observation (i.e. the bottomRef was intersecting and is still intersecting after data load).

On the left is the initial render (5 elements), and the right is after the initial intersection that triggers the fetch of an additional 10 elements. Note that in either case the final element is well within the viewport.

enter image description here


The question here is:

  • Under what conditions would the IntersectionObserver resubmit its assertion that the bottomRef reference is intersecting?
  • Can I somehow invalidate previous events, or otherwise force the observer to continuously report its state?
  • How can I continuously trigger the IntersectionObserver while the observed element is contained within the client viewport? The design would be that once the parent element reports that no more data is available that the observer be deliberately disarmed.

I tried various incantations of using clientHeight and scrollHeight on the container element of the props.children elements but Chrome was always reporting to me that clientHeight == scrollHeight, which seemed wrong to me based on the docs I was reading.

Some other approach here I am not thinking of?

2

Answers


  1. Chosen as BEST ANSWER

    It occurred to me that the problem could be solved by adjusting the dependency list of the useEffect that creates the IntersectionObserver.

    The issue's root cause is that the the final element in the list (the one with the ref being observed) is visible in the viewport on initial render. This triggers the initial intersection callback from the Observer. Upon adding more data to the list the ref being observer remains visible in the viewport (and so its state has not changed). This means no further callbacks are executed because the visibility of the element is not changing.

    But by adding the list of children ReactNode elements to the dependency array of the useEffect we can create a new observer instance each time a new list of children nodes is passed to the component (and so on each render of the new children list following a fetch of data).

    const InfiniteScroll = (props: Props) => {
        const bottomRef = useRef(null);
    
        useEffect(() => {
            const observer = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting)
                    props.next();
            });
    
            if (bottomRef.current)
                observer.observe(bottomRef.current);
    
            return () => {
                if (bottomRef.current)
                    observer.unobserve(bottomRef.current);
            };
        }, [props.children]);
    
        return (
            <>
                {props.children}
                {props.hasMore && <div ref={bottomRef}></div>}
            </>
        )
    }
    
    export default InfiniteScroll;
    

    Perhaps not the most efficient solution, but for the general human-user use-case it seems acceptable, though I am open to alternatives.

    Alternatively we could retain the existing IntersectionObserver and create a new element to observe on each loop (rather than retaining the same reference created the first time the component is rendered), but I was not quite sure how to do that.


  2. I would suggest another approach. Forgive me that I will be using Vue – I hope it will be easier enough to get the idea.
    So, imagine that we have a Vue component which must show a list of search results:

    <template>
      <div ref="container" class="grow scroll">
        <ProductResult 
          v-for="(item,idx) in results" 
          :key="item._id" 
          v-observe-visibility="visibilityParams" 
          :product="item" 
          :data-last="idx + 1 == results.length ? 'true' : null" 
          @click="showProduct(item)" 
        />
      </div>
    </template>
    

    How we render each result with the ProductResult component is irrelevant for the current discussion.
    The important thing is that each result has its own IntersectionObserver instance (through the v-observe-visibility Vue directive) with the following parameters (directly used by a IntersectionObserver constructor):

    computed:
    {
          visibilityParams()
          {
            return {
              callback: this.visibilityChanged,
              once: true,
              intersection:
                {
                  threshold: 0.1,
                },
            };
          },
    }
    

    Notice that we instruct the IntersectionObserver’s to trigger only once. We also mark the last result with data-last="true" attribute – you will see how I use this below.
    The callback function goes like this:

          visibilityChanged(isVisible, entry)
          {
            if (isVisible && entry.target.dataset.last && this.results.length >= 10) // we assume that each page of results that we fetch from the API contains up to 10 products
            {
              this.pageIndex++;
              this.doSearch();
            }
          },
    

    So, if the item enters the viewport (or if it is already visible even on the initial rendering) AND it is the last item AND we have fetched at least one full page of results (i.e. the total number of results is not less than one page) – then we fetch the next page of results from the API.
    We also use a lifecycle hook in our Vue component called mounted (so it is called by Vue when the component is instantiated) to fetch the first page of results:

      mounted()
      {
        this.doSearch();
      },
    

    And the actual fetching from the API:

      doSearch()
      {
        // cancel the running search - user is typing faster than the server can perform searching
        // the actual way of cancelling depends on what you use - Fetch API or XHR
    
        if (this.noMoreResults) return;
    
        // fetch the data
        // .....
    
        // after the data was fetched, then
        // if we already have the first page - append these results to the existing ones (a.k.a infinite scroll)
        if (this.pageIndex > 1) this.results = this.results.concat(response.products);
        // otherwise this IS the first page of results
        else this.results = response.products;
        // if the API did not return any new results
        if (response.products.length == 0)
        {
          if (this.pageIndex > 1) this.noMoreResults = true; // then we know that we've shown all possible results
        }
      },
    

    As a summary – we watch (with an IntersectionObserver) until the last item becomes visible and then we (try to) fetch the next page/group of items. If there are no more items – we use a flag to mark the condition. Once we fetch all items – we receive no more signals from the IntersectionOberver’s.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search