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:
- Intersection Observers — Basics: covers what are intersection observers and how they work.
-
Intersection Observers — Entries: explore the
IntersectionObserverEntry
interface and how it works.
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 LazyImage
s.
The IntersectionObserver
for our lazy loader will have its
-
root
option set tonull
(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>
);
}
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>
);
}
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]
});
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>
);
}
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>
);
}
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);
}
});
}
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
}
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);
}
});
}
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 forhttp://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 inpublic
.Inside
src
, we have anindex.js
that loads another file calledApp.jsx
and renders its default-exportedApp
component inside a#root
DOM element in theindex.html
file (inpublic
).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:
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;
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:
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)