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.
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
It occurred to me that the problem could be solved by adjusting the dependency list of the
useEffect
that creates theIntersectionObserver
.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 theref
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 theuseEffect
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).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.
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:
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 aIntersectionObserver
constructor):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:
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:
And the actual fetching from the API:
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.