DEV Community

Cover image for Building a Lazy Loader from Scratch in React (Part 1)
Codeguage
Codeguage

Posted on • Edited on • Originally published at codeguage.com

Building a Lazy Loader from Scratch in React (Part 1)

Table of Contents


Introduction

In today's complex world of the world wide web, performance remains one of the most important factors for an online business's success and ranking.

While there are many ways to improve the performance of a website, many of them are really easy to implement. Lazy loading is one such way.

As the name suggests, lazy loading is when resources are loaded lazily, i.e. only when there is a need to load them. Customarily, these resources are images and videos, and the 'need' to load them arises when they are scrolled into the viewport.

With the advent of the loading attribute for images and videos into HTML, it's only a matter of setting it to "true" in order to enable lazy loading for a given resource. However, loading is not very well-supported across some not-so-old browsers. So, we turn ourselves to creating a lazy loader from scratch.

Creating a lazy loader is merely a matter of some JavaScript. And thanks to the IntersectionObserver API, it has never been this easier to implement lazy loading on the web. Believe it!

In this series, we shall learn how to create a lazy loader from scratch using the IntersectionObserver API, in React.

Yes, you heard it right β€” React.

We've already crafted a comprehensive tutorial on lazy loading in vanilla JavaScript on our website, so there's very little point in exploring all that again here.

What we want to do differently now is to borrow from most of those ideas and build a similar lazy loader, albeit in React. React is undoubtedly one of the most popular frontend tools in use all across the globe, and it would be pretty amazing to build a useful functionality using such a useful tool.

Pretty fascinating, isn't it?


Prerequisites

Before we begin developing our lazy loader, let's spare a few minutes for quickly going through the prerequisites that you'll be needing for it.

1. Knowledge of React

Starting with the very first thing, since we'll be using React, you need to be well aware of this JavaScript UI library and how it works.

If you're comfortable with such things as components, props, mounting/unmounting, hooks like useState() and useEffect(), then you're all set to continue with the discussion below.

Otherwise, if you lack somewhat in these concepts, don't worry, for we have a completely free course on React to help you improve upon your weak areas.

2. Knowledge of the IntersectionObserver API

Moving on, for the sake of brevity, using a modern API, and giving efficiency a first preference, we'll be leveraging the IntersectionObserver API for our lazy loader to track when given resources enter the viewport.

The browser compatibility of IntersectionObserver is decent across all modern browsers today without any notable discrepancies between them, which renders the API as a really good choice to go with.

πŸ’‘ Notice: Unlike the loading attribute in HTML, the IntersectionObserver API can be polyfilled (and that using scroll events) and there do exist polyfills.

Henceforth, you'll be required to know how exactly IntersectionObserver works because we won't be explaining that in this article.

Once again, if you don't know about the IntersectionObserver API, don't worry; we have a unit dedicated to discovering this API in our Advanced JavaScript course.

Here are the chapters to turn to if you wish to learn about intersection observers:

And these are pretty much the main prerequisites required, at least for now.

In the subsequent parts of this series, when we'll be adding more features to our lazy loader, we might consider some other prerequisites there which would make more sense to be explored along with those concepts.

Without further ado, let's get into the business.


The basic idea

Getting straightaway into the coding of something without giving any thought into the design of that thing is exciting but not typically rewarding.

So prior to coding our lazy loader, we ideally want to understand how it'll work, what things it'll need, how those things will be integrated together, and so on.

First things first, we'll need a component to denote a lazy resource. For now, let's just stick to lazy images, but the ideas can be applied to videos, iframes, and other embeds as well. We'll call it LazyImage.

Trivially, LazyImage will render a descendant <img> element, because at the end of the day, we need to show an image to the user and that's the very purpose of <img>.

Getting into <img>, to be able to defer its loading in HTML, we obviously need to omit its src attribute (or point it to an image that's very small in size, probably showcasing a low-quality blur version of the original image).

So we can create a src prop for LazyImage but not relay it forward to the <img> until we are sure that we want to initiate the loading of the underlying image.

Let's now talk about the integration of IntersectionObserver with our lazy loader.

When a LazyImage gets rendered, it'll be observed by an IntersectionObserver instance. This IntersectionObserver instance will be created once in the global scope and reused across all LazyImages.

The IntersectionObserver for our lazy loader will have its

  • root option set to null (which is just the default), as we need to observe elements entering the viewport;
  • threshold set to [0], in order to fire the observer callback when the image is just touching the viewport (or is beyond that point).

When the callback function of the observer fires for a lazy image and the conditions for loading the image are met (which we'll see below), we'll remove the image from being observed by the IntersectionObserver instance.

This isn't required per se, but it's considered a good practice to keep from unnecessarily wasting resources (the observer will be holding on to non-existent targets).

Moreover, if a LazyImage gets torn down, for e.g. by virtue of a LazyImage component instance being replaced or deleted in the component tree, even then we'll be unobserving the image to, again, preserve computing resources.

And that's the basic idea of implementing a lazy image loader in React.

With this rock-solid plan in hand, now is the time to get coding. Finally!


The implementation

We'll start by defining the LazyImage component.

Recall that it ought to have a src prop, containing a value to be ultimately assigned to the src attribute of the contained <img> element.

With this simple idea in mind, here's the initial definition of LazyImage:

function LazyImage({ src }) {
   return (
      <div className="lazy">
         <img/>
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

Notice the <div> container that we've used here around <img>. Precisely speaking, we don't need it for our lazy loader. But we do need it for a good lazy loader.

Let's find out why.

Why have a <div> encapsulating the <img>?

Well, firstly, it's a good practice to encapsulate inline elements, such as <img>, with block elements, such as <div>, if those inline elements have to be shown as separate blocks, as is the case with our lazy images.

Secondly, in the latter part of this series, when we'll be adding fade effects and fixing CLS issues in loading images naively, we'll be needing a container anyways.

And so now is the right time to have it.

The implementation shown above does give us a good start for LazyImage but it obviously ain't complete right now.

That's what we discuss and do below.

We'll apply the src prop to <img> only when the underlying image is meant to be initiated for a load. This hints us that we'll need a state value for LazyImage to indicate whether its <img> has a src or not. Let's call it srcExists.

πŸ’‘ Notice: Setting src on the <img> element, to begin with, doesn't make sense since it'll start the image's loading whereas we want to defer it until the image shows up.

As per our choice for the name, srcExists being false would mean that the LazyImage's contained <img> doesn't have src set.

Here's the extension of the previous code with an inclusion of srcExists:

import { useState } from 'react';

function LazyImage({ src }) {
   const [srcExists, setSrcExists] = useState(false);

   return (
      <div className="lazy">
         <img src={srcExists ? src : null}/>
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

The conditional expression assigned to src above is used so that when srcExists is false, the value of the src prop becomes null, which effectively prevents the src attribute from being set on the underlying <img> element.

You might ask at this stage: who will call setSrcExists and set it to true? Well, this is the job of the IntersectionObserver API, as we shall see up next.

Before anything, let's instantiate an IntersectionObserver instance and store it on the LazyImage function, under the property io:

function LazyImage({ src }) { /* ... */ }

function ioCallback() {}

LazyImage.io = new IntersectionObserver(ioCallback, {
   root: null,
   threshold: [0]
});
Enter fullscreen mode Exit fullscreen mode

We could've just stored this instance in a global variable io as well, but we feel that it's better to store it in LazyImage since the instance is only meant for LazyImage.

The callback function ioCallback() will be defined later on below; as for the second options argument of the constructor, we've already discussed the root and threshold properties above and so we won't be going into them again here.

With the observer instance created, the next step is to observe a LazyImage as soon as it's rendered. If you're experienced in React hooks, you'll immediately say: "This is the job of useEffect()."

But wait...it's not that simple.

The IntersectionObserver interface is meant to observe DOM elements, likewise, if we wish to observe a lazy image, we have to extract out a reference to a DOM element representing the image.

In our case, the element could be either of <div> or <img>. We'll just go with <div>.

Needless to say, since we're dealing with DOM element references in React here, what we ought to use is the useRef() hook along with the ref prop on the <div> element.

Further reading:
To learn more about refs in React, consider going through React Refs.

Here's the new code we get:

import { useState, useEffect } from 'react';

function LazyImage({ src }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div ref={divRef} className="lazy">
         <img src={srcExists ? src : null}/>
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

divRef.current holds a reference to the <div> DOM element, which is passed to the observe() method of our IntersectionObserver instance inside useEffect()'s callback.

Pretty elementary.

Take note of the dependency list (the second argument) provided to useEffect() above β€” it's an empty array which means that useEffect()'s callback would only be invoked once for a given LazyImage component instance (which is when the instance gets rendered for the first time).

This is simply because we want to call the observe() method only once on a lazy image.

So far, so good.

Now, let's move over to the intersection observer's callback function,ioCallback(), where the real action happens.

The idea is as follows: when the callback fires for a given image target, we check if it fired because the image is showing in (or touching with) the viewport. If this check yields success, we set the src attribute on the underlying <img>, and that by changing srcExists to true.

But since the <img> element is controlled by the React component LazyImage, in order to be able to do so, we need access to the state mutating function setSrcExists() outside of LazyImage.

So how to do this?

Fortunately, it's really simple β€” just assign the setSrcExists() function to the <div> DOM element that we observe for a LazyImage.

Consider the following addition to our LazyImage component:

import { useState, useEffect } from 'react';

function LazyImage({ src }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div ref={divRef} className="lazy">
         <img src={srcExists ? src : null}/>
      </div>
   );
}
Enter fullscreen mode Exit fullscreen mode

And here's our ioCallback() function:

function ioCallback(entries, io) {
   entries.forEach(entry => {
      if (entry.intersectionRatio >= 0 && entry.isIntersecting) {
         io.unobserve(entry.target);
         entry.target.setSrcExists(true);
      }
   });
}
Enter fullscreen mode Exit fullscreen mode

And this should pretty much do it for our minimal lazy loader.

Without any doubt whatsoever, this ain't the best lazy loader, but that's okay for now since we've just begun the development; the refinements are to come very soon.

For now, the only additional thing we'll do, to be able to better visualize our lazy loader in action, is to give some minimum height to the .lazy element along with a background color so that we can see it prior to the image's display.

Here's the rudimentary CSS:

.lazy {
   min-height: 100px;
   background-color: #ddd
}
Enter fullscreen mode Exit fullscreen mode

In addition to this, we'll also set up a 1s timeout inside ioCallback() to defer the beginning of the loading of the image (for 1 second). This, again, is to be able to better visualize the lazy loading functionality.

Here's the rewritten ioCallback() function:

function ioCallback(entries, io) {
   entries.forEach(entry => {
      if (entry.intersectionRatio >= 0 && entry.isIntersecting) {
         setTimeout(() => {
            io.unobserve(entry.target);
            entry.target.setSrcExists(true);
         }, 1000);
      }
   });
}
Enter fullscreen mode Exit fullscreen mode

And with this, we're done with our lazy loader's implementation.

Now, it's time to see it in real action.


A working lazy loader

Before we begin this section, note that we're assuming that we have an application set up as shown in our article How to Set Up Rollup to Run React?, using the bundler Rollup.

The complete code of this example, including all the setup files, can be found on our GitHub page, at lazy-loader-react-1.

Our React setup

To summarize our application setup, which is quite a familiar one, we have two directories in our demo project:

  • A public directory that acts as the root directory for http://localhost:3000.
  • A src directory that's just meant to contain all the JavaScript/JSX source files. These files are eventually bundled together and exported as a single JavaScript file in public.

Inside src, we have an index.js that loads another file called App.jsx and renders its default-exported App component inside a #root DOM element in the index.html file (in public).

If you've worked with React using a custom build process before, all this setup wouldn't be any new to you.

For this demonstration, let's just go with one <LazyImage> instance, and that placed at a very large distance from the top of the document.

The image that we'll be using is the following one, by Lukas from Pexels:

Photo by Lukas from Pexels

You can obviously choose any image that you want to.

We've saved the image under the name image.jpg in the directory where our index.html file resides.

Here's our App.jsx file:

import LazyImage from './LazyImage';

function App() {
   return (
      <>
         <h1>Demonstrating lazy loading</h1>
         <p style={{ marginBottom: 1000 }}>Slowly scroll down until you bring the lazy image into view.</p>
         <LazyImage src="/image.jpg" />
      </>
   );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The marginBottom style applied to the <p> element makes sure that the lazy image is initially out of view in the viewport.

Here's a live example:

Live Example

Scroll down the page and just wait for a second before the image gets shown to you. This is lazy loading in action.

As we said before, and as you can even realize by looking at the live example above, this lazy loader isn't done yet. There's a lot to add to it and that's what we'll be doing throughout the subsequent parts of this series.

For now, let's take a break 🍡, for part 2 is to be continued...


More from CodeGuage

In the meanwhile, you can consider going through some of the following articles published by us on Dev πŸ‘‡:

Top comments (0)