Infinite component scrolling with React.lazy and IntersectionObserver

Shaun Wallace
ITNEXT
Published in
6 min readMar 8, 2019

--

In a previous post, I wrote briefly about how the IntersectionObserver API could be used for things such as analytics and dynamic module loading but I didn’t fully provide any concrete examples. In this post, I would like to practically show how one can achieve the above with this rather new API.

Disclaimer: Here I am using React with Webpack but the same can be achieved in Vue as well as Angular and with other bundlers such as parcel.js.

Recently, React released version 16.6.3 which gave us support for code-splitting out of the box, although support for server-side rendering is not available and there are other implementations, but for our purposes we are going to use React.lazy and React.Suspense. More details on the specifics of the API can be referenced here.

For those unfamiliar with code-splitting or dynamically loading modules at runtime, its useful to understand that one of the main purposes is to allow developers to split their codebase into smaller chunks, generally separated by concern, route, or function. Dividing an application up by route is a great start to shipping smaller bundles to users, and in turn, only shipping code to a user based on that individual user’s needs. If they never visit a part of your application, either by choice or by some constraint, i.e. permission restrictions, then they are not penalized by slower time-to-interactive or data transfer limits.

The React docs explain the benefits as the following…

Code-splitting your app can help you “lazy-load” just the things that are currently needed by the user, which can dramatically improve the performance of your app. While you haven’t reduced the overall amount of code in your app, you’ve avoided loading code that the user may never need, and reduced the amount of code needed during the initial load.

So what about practical use cases for non-route or page based code-splitting and how can we also dynamically load code as a user navigates a single view or route in an application?

At my current company FanAI we came across a use case for using aggressive module loading that helped speed up our application’s performance and allowed us to defer a significant amount of upfront computation. We found that as our application grew, and continued to become more complex, that our long-scrolling views, which contained many different data visualizations, started to feel quite sluggish. Most of the visualizations lived well below the fold and were not always interacted with by a user. In addition, many of the visualizations made calls to various api endpoints for data and were all competing for animations and layout changes when those api responses resolved and the visualizations were rendered.

With the addition of React.Suspense and React.lazy we were now able to load these various visualizations on demand but most of the examples out there were simply using route based loading or user based actions to trigger dynamic imports. We needed a way to pull in code as the user navigated a single view, more specifically, a way to load code as the user scrolled. This is where we were able to implement and take advantage of the IntersectionObserver api.

Implementing dynamic code splitting using React.lazy and React.Suspense was quite straight forward and the docs expound on the topic in more depth but a very simple implementation could be achieved as follows:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}

The code above uses React.lazy which takes a function as the only input and this function calls a dynamic import which then returns a Promise that resolves to a module containing a React component.

Ok, now let’s get into the details of what we built. In the code snippet below we are just setting things up. We set the default intersected state to false, create a ref, which will later be used as the target element in which to observe intersections in relationship to a root element. The root element in-turn defaults to the browser viewport if not specified or if null .

state = {
hasIntersected: false
};
targetContainerRef = React.createRef();options = {
root: this.props.root || null,
rootMargin: this.props.margin || "0px",
threshold: this.props.threshold || 0
};
observer;

Next we setup up our subscriptions which will call our callback this.load when the intersection happens:

componentDidMount() {
this.observer = new IntersectionObserver(this.load, this.options);
this.observer.observe(this.targetContainerRef.current);
}
componentWillUnmount() {
this.observer.unobserve(this.targetContainerRef.current);
}

Finally, we tell the observer what to actually do when an intersection event is observed. Our first condition tells the observer to stop listening once an intersection has happened. This is what we want for dynamic module loading.

The second condition is useful when you want to trigger a callback each and every time an intersection event is fired. We’ve used this for infinite loading of images and search results as this allows us to make additional calls to backend services, for instance each time a user has scrolled to the bottom of a list. The only thing to keep in mind for the second scenario is that you might need to track the direction of scroll as you may only want to trigger a callback if the intersection happens in a specific direction.

load = (entries) => {  const { onIntersection, continueObserving } = this.props;  if (!continueObserving && !this.state.hasIntersected) {
const entry =
entries.find(
entry => entry.target === this.targetContainerRef.current
);
if (entry && entry.isIntersecting) {
this.setState({ hasIntersected: true });
onIntersection && onIntersection(entries);
this.observer.unobserve(this.targetContainerRef.current);
}
} else if (continueObserving && onIntersection) {
onIntersection(entries);
}
};

Next we need to actually use the IntersectionObserver component in conjunction with React.Suspense. We can achieve that with the following:

<IntersectionObserver>
<DynamicModule
placeholder={<Placeholder />}
component={() => import("./path/to/asyncComponent")}
/>
</IntersectionObserver>

The above code wraps a component that calls the component function prop when it mounts. Our <IntersectionObserver />component internally only renders its children when the intersection happens. When this occurs the
<DynamicModule /> component internally does the following when it mounts:

componentDidMount() {
this.setState({
Component: lazy(this.props.component),
initializing: true
});
}

The above taps into the React.lazy api which then pulls in our component at runtime and this happens only when the user scrolls this component into view! To play around with these components and see a bit more in detail how it’s all working please check out the codesandbox below.

In addition to the above example, if you wanted to see how infinite scrolling could work with the <IntersectionObserver /> component you can simply append it to the bottom of your scrollable container and pass it an onIntersection() callback and a continueObserving prop set to true if you wanted to fire a callback each time the <IntersectionObserver /> intersects your root element.

By implementing the above components we were able to defer work, which in-turn sped up our initial page load times since we were shipping significantly smaller bundles. In addition, it improved the perceived performance of the application since we were only loading individual component modules when they were actually needed, and since those modules depended on external services for data, the loading of the module’s chunks and the async responses from the apis worked really well together. To the user, it seem one-in the same and they were unable to perceive the the difference between the load of the modules and the loading state of the api resolution. It was a win-win for us and more importantly for our users!

I hope the above makes sense and please feel free to provide any feedback for my benefit and the benefit of the community at large. Happy coding! 🙌 🙌 🙌

--

--

Senior Engineer @FanAI mostly playing around with javascript and the like.