In this post we will be exploring a technique for lazy loading images in React that makes use of the IntersectionObserver browser API.
The end goal is to build a drop-in replacement for the <img />
tag (in React) that defers loading the image until it's needed.
If you prefer to jump straight in to the code, you can visit the final implementation on GitHub.
I've also published a library to npm called afterimage if you want to just use it.
Deferring the load of off-screen images can significantly improve page performance, and since there's very little detriment to the user experience, it's a no-brainer.
We'll be creating a drop-in replacement for <img />
that does the following:
IntersectionObserver
(or reuses a cached one)IntersectionObserver
to apply the src
attribute to the <img />
tag when the image is at least 10% in the viewportprops
that extends the default <img />
tag props but does not apply the src
attribute on the <img />
tag during first renderIntersectionObserver
Let's tackle each of these in turn.
The foundation of our lazy load image component is an IntersectionObserver
which allows us to know when the image has entered the viewport at runtie. We'll create a simple JavaScript function that creates the observer and caches it on the window
so that if there are many images, they all use the same observer. And, to ensure backwards compatibility with older browsers that do not support IntersectionObserver
, we'll exit early (which will just apply the src
attribute automatically).
const CACHE_KEY = "__AFTER_IMAGE_INTERSECTION_OBSERVER__";
function getImageLoaderObserver(): null | IntersectionObserver {
// exit if browser does not support IntersectionObserver
if (typeof IntersectionObserver === "undefined") {
return null;
}
// return the cached observer for performance
if (typeof window[CACHE_KEY] !== "undefined") {
return window[CACHE_KEY];
}
// create a new observer and cache it on the window
window[CACHE_KEY] = new IntersectionObserver((images) => {
entries.map((entry) => {
// TODO: implement the lazy load behavior in step 2
});
});
return window[CACHE_KEY];
}
To make sure that we aren't performing unnecessary work, we will ensure that we only apply the src
attribute if it has not already been set, and we will put measures in place to ensure that we only apply the src
attribute if it is provided and there is an <img />
tag available.
const threshold = 0.1; // 10% in view
window[CACHE_KEY] = new IntersectionObserver(
(entries) => {
entries.map((entry) => {
const img = entry.target.querySelector("img");
if (img && !img.src && entry.intersectionRatio >= threshold) {
const src = img.getAttribute("data-src");
if (src) {
img.src = src;
}
}
});
},
{
threshold,
}
);
We get the src
from another attribute, data-src
on the img
tag. This lets the IntersectionObserver
read from the DOM so it can be cached on the window, agnostic from the React component we'll create in a moment.
The line where we query for the <img />
tag is necessary to achieve placeholder styling, we'll come back to why in more detail in step 4.
This step is fairly simple and sets up the React component that we'll be extending with lazy-load behavior in step 4. The only interesting thing here is how we destructure this.props
in the render method to pull out the src
attribute and place it as data-src
and then spread the remaining rest props on the <img />
tag. This means that the <img />
tag will not have a src
attribute on the first-render.
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
}
interface State {}
export class AfterImage extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
render() {
const { src, ...imgProps } = this.props;
return <img {...imgProps} data-src={src} />;
}
}
This step is more of a nice-to-have, but to ensure a nice user-experience it's a good idea to render a placeholder element that assumes the eventual size of the image when it has been loaded so that the content below the image does not jump when the image finishes loading.
To achieve this, we'll use the aspect ratio box technique and expose some props
to configure it.
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
aspectHeight?: number;
aspectWidth?: number;
withPlaceholder?: boolean;
}
interface State {}
export class AfterImage extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
render() {
const {
src,
aspectHeight = 9,
aspectWidth = 16,
withPlaceholder = true,
...imgProps
} = this.props;
const aspectRatio = aspectHeight / aspectWidth;
return (
<div style={{ position: "relative" }}>
<img
{...imgProps}
data-src={src}
style={{
width: "100%",
height: "auto",
position: withPlaceholder ? "absolute" : "static",
left: 0,
}}
/>
{withPlaceholder && (
<div
style={{
width: "100%",
paddingTop: `${aspectRatio * 100}%`,
}}
/>
)}
</div>
);
}
}
I'm a big fan of sensible defaults, and for that reason, the placeholder element defaults to a 16:9
size, but can be changed externally.
The last piece in the puzzle is to connect the React component to the IntersectionObserver
. Doing so involves using React's lifecycle methods to achieve the following:
src
attribute if this happensexport class AfterImage extends React.Component<Props, State> {
imgElm: null | HTMLImageElement = null;
wrapper: null | HTMLDivElement = null;
observer: null | IntersectionObserver = null;
constructor(props: Props) {
super(props);
this.state = {
hasLoaded: false,
};
}
/**
* Grab a reference to the IntersectionObserver and
* store it on the instance.
*/
componentDidMount() {
this.observer = getImageLoaderObserver();
}
/**
* Remove the instance from the IntersectionObserver
* to prevent memory leaks.
*/
componentWillUnmount() {
if (this.observer && this.wrapper) {
// for memory leaks
this.observer.unobserve(this.wrapper);
}
}
/**
* Adds the current instance to the IntersectionObserver
* if created and all React refs exist.
*
* Backwards compatible for browsers without support.
*/
addImageToObserver = () => {
if (this.observer && this.wrapper) {
this.observer.observe(this.wrapper);
} else if (this.imgElm && this.props.src) {
// for browser compatibility
this.imgElm.src = this.props.src;
}
};
/**
* Called when a required React ref is stored on the
* class instance.
*/
onRefStored = () => {
if (this.wrapper && this.imgElm) {
window.requestAnimationFrame(this.addImageToObserver);
}
};
/**
* Set afterimage wrapper elm React ref on the instance.
*
* Wrapper elm is necessary so that the IntersectionObserver
* works (needs an element with at least 1px by 1px).
*/
setWrapperRef = (elm: any) => {
this.wrapper = ReactDOM.findDOMNode(elm) as any;
this.onRefStored();
};
/**
* Set img tag elm React ref on the instance.
*/
setImgRef = (elm: any) => {
this.imgElm = ReactDOM.findDOMNode(elm) as any;
this.onRefStored();
};
render() {
const {
src,
className = "",
aspectHeight = 9,
aspectWidth = 16,
withPlaceholder = true,
...imgProps
} = this.props;
const { hasLoaded } = this.state;
const aspectRatio = aspectHeight / aspectWidth;
// data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag
return (
<div
className={`afterimage ${
hasLoaded ? "afterimage--loaded" : ""
} ${className}`}
ref={this.setWrapperRef}
>
<img
{...imgProps}
className="afterimage__image"
onLoad={this.onImageLoad}
ref={this.setImgRef}
data-src={src}
style={{
width: "100%",
height: "auto",
opacity: hasLoaded ? 1 : 0,
transition: "opacity 300ms ease",
position: withPlaceholder ? "absolute" : "static",
left: 0,
}}
/>
{withPlaceholder && (
<div
className="afterimage__placeholder"
style={{
width: "100%",
paddingTop: `${aspectRatio * 100}%`,
opacity: hasLoaded ? 0 : 1,
transition: "opacity 300ms ease",
pointerEvents: "none",
}}
/>
)}
</div>
);
}
}
The last thing we'll do is add some default styling so that when the image loads, it transitions in nicely. To do so, we will add inline styles and class names.
...
render() {
const {
src,
className = "",
aspectHeight = 9,
aspectWidth = 16,
withPlaceholder = true,
...imgProps
} = this.props;
const { hasLoaded } = this.state;
const aspectRatio = aspectHeight / aspectWidth;
// data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag
return (
<div
className={`afterimage ${
hasLoaded ? "afterimage--loaded" : ""
} ${className}`}
ref={this.setWrapperRef}
>
<img
{...imgProps}
className="afterimage__image"
onLoad={this.onImageLoad}
ref={this.setImgRef}
data-src={src}
style={{
width: "100%",
height: "auto",
opacity: hasLoaded ? 1 : 0,
transition: "opacity 300ms ease",
position: withPlaceholder ? "absolute" : "static",
left: 0
}}
/>
{withPlaceholder && (
<div
className="afterimage__placeholder"
style={{
width: "100%",
paddingTop: `${aspectRatio * 100}%`,
opacity: hasLoaded ? 0 : 1,
transition: "opacity 300ms ease",
pointerEvents: 'none'
}}
/>
)}
</div>
);
}
So in a few small steps, we've achieved a (nearly) drop-in replacement for the standard <img />
tag that lazily loads images for performance. And here's the final code!
import * as React from "react";
import * as ReactDOM from "react-dom";
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
className?: string;
onLoad?: () => any;
aspectHeight?: number;
aspectWidth?: number;
withPlaceholder?: boolean;
}
interface State {
hasLoaded: boolean;
}
export class AfterImage extends React.Component<Props, State> {
imgElm: null | HTMLImageElement = null;
wrapper: null | HTMLDivElement = null;
observer: null | IntersectionObserver = null;
constructor(props: Props) {
super(props);
this.state = {
hasLoaded: false,
};
}
/**
* Grab a reference to the IntersectionObserver and
* store it on the instance.
*/
componentDidMount() {
this.observer = getImageLoaderObserver();
}
/**
* Remove the instance from the IntersectionObserver
* to prevent memory leaks.
*/
componentWillUnmount() {
if (this.observer && this.wrapper) {
// for memory leaks
this.observer.unobserve(this.wrapper);
}
}
/**
* Adds the current instance to the IntersectionObserver
* if created and all React refs exist.
*
* Backwards compatible for browsers without support.
*/
addImageToObserver = () => {
if (this.observer && this.wrapper) {
this.observer.observe(this.wrapper);
} else if (this.imgElm && this.props.src) {
// for browser compatibility
this.imgElm.src = this.props.src;
}
};
/**
* Called when a required React ref is stored on the
* class instance.
*/
onRefStored = () => {
if (this.wrapper && this.imgElm) {
window.requestAnimationFrame(this.addImageToObserver);
}
};
/**
* Set afterimage wrapper elm React ref on the instance.
*
* Wrapper elm is necessary so that the IntersectionObserver
* works (needs an element with at least 1px by 1px).
*/
setWrapperRef = (elm: any) => {
this.wrapper = ReactDOM.findDOMNode(elm) as any;
this.onRefStored();
};
/**
* Set img tag elm React ref on the instance.
*/
setImgRef = (elm: any) => {
this.imgElm = ReactDOM.findDOMNode(elm) as any;
this.onRefStored();
};
/**
* Called when the image src has completely finished
* downloading.
*
* Used to prevent displaying the image until it's fully
* downloaded.
*/
onImageLoad = () => {
this.setState({ hasLoaded: true });
if (this.props.onLoad) {
this.props.onLoad();
}
};
render() {
const {
src,
className = "",
aspectHeight = 9,
aspectWidth = 16,
withPlaceholder = true,
...imgProps
} = this.props;
const { hasLoaded } = this.state;
const aspectRatio = aspectHeight / aspectWidth;
// data-src is needed so that the cached intersection observer can read the src from the wrapper and set it on the img tag
return (
<div
className={`afterimage ${
hasLoaded ? "afterimage--loaded" : ""
} ${className}`}
ref={this.setWrapperRef}
>
<img
{...imgProps}
className="afterimage__image"
onLoad={this.onImageLoad}
ref={this.setImgRef}
data-src={src}
style={{
width: "100%",
height: "auto",
opacity: hasLoaded ? 1 : 0,
transition: "opacity 300ms ease",
position: withPlaceholder ? "absolute" : "static",
left: 0,
}}
/>
{withPlaceholder && (
<div
className="afterimage__placeholder"
style={{
width: "100%",
paddingTop: `${aspectRatio * 100}%`,
opacity: hasLoaded ? 0 : 1,
transition: "opacity 300ms ease",
pointerEvents: "none",
}}
/>
)}
</div>
);
}
}
const CACHE_KEY = "__AFTER_IMAGE_INTERSECTION_OBSERVER__";
/**
* Returns an IntersectionObserver that loads the image
* when it is at least 10% visible in the viewport.
*
* NB: Cached on the window for performance
*/
function getImageLoaderObserver(): null | IntersectionObserver {
if (typeof IntersectionObserver === "undefined") {
return null;
}
// return the cached observer for performance
if (typeof window[CACHE_KEY] !== "undefined") {
return window[CACHE_KEY];
}
// create a new observer and cache it on the window
const threshold = 0.1; // 10% in view
window[CACHE_KEY] = new IntersectionObserver(
(entries) => {
entries.map((entry) => {
const img = entry.target.querySelector("img");
if (img && !img.src && entry.intersectionRatio >= threshold) {
// data-src is read from the wrapperElm so that the intersection observer does not need to read from props and can be cached
const src = img.getAttribute("data-src");
if (src) {
img.src = src;
}
}
});
},
{
threshold,
}
);
return window[CACHE_KEY];
}
export default AfterImage;