go back

Progressive Images in React

August 20, 2018

Almost every article on a digital publication's website will have an image somewhere. Most of these have multiple images. Sometimes 10, 20... 30... 💤

So be lazy!

Lazily loading images to decrease load time is nothing new. People have come up with different implementations utilizing various concepts, but the goal is the same: load the page as fast as possible while minimally affecting UX.

This walkthrough will (hopefully) give you an understanding of a way to load an image in a performant way, similar to the way Medium loads images. It assumes basic knowledge of React, and that server-rendered components are being provided with a javascript bundle on the client.

Render static component on server ❄️

Let's make a basic component that can render two images, which we will switch between later:

const BlurredImg = styled.img`
    filter: blur(6px);
    transition: opacity 0.2s ease-in;
`;

const smallImageUrl = `www.images.com/low-quality/${this.props.id}.${this.props.format}`;
const fullImageUrl = `www.images.com/high-quality/${this.props.id}.${this.props.format}/`;

return (
    <React.Fragment>
        <BlurredImg src={smallImageUrl} data-id={this.props.id} data-format={this.props.format} />
        {this.props.pageLoaded && <img src={fullImageUrl} />}
    </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

If you aren't familiar with styled-components, that styled syntax is essentially a block of CSS around an img tag.

The pageLoaded prop will prevent the higher-quality image from being rendered on the server. We'll set this to true when hydrating later.

The BlurredImg element is doing two things:

*Because the server is returning raw HTML, the props we used to render the component will be lost. How you choose to keep props consistent between server and client is up to you; this implementation will rely on data attributes to keep things simple. Alternatively, you could pass a blob of data keyed by image id, etc.

Hydrate component on client 💧

Hydrating the component allows it to load the higher-quality image to replace the blurred version. As mentioned before, the props are determined by data attributes:

const imageElement = document.querySelector('.lazy-image-wrapper');

ReactDOM.hydrate(
    <ProgressiveImageExample
        id={imageElement.dataset.id}
        format={imageElement.dataset.format}
        pageLoaded={true}
    />,
    imageElement
);
Enter fullscreen mode Exit fullscreen mode

Note that pageLoaded is now true, which enables the real <img> to load. But the blurred image still shows even though the other is loaded...

Swap the images 🌃🤝🏞

This introduces complexity that requires state, so we'll need to add it in the constructor:

constructor(props) {
    super(props);

    this.state = {
        imageLoaded: false
    };
}
Enter fullscreen mode Exit fullscreen mode

And update our components to be more stateful:

const BlurredImg = styled.img`
    filter: blur(6px);
    opacity: ${props => props.shouldBeVisible ? 1 : 0};
    transition: opacity 0.2s ease-in;
`;

return (
    <React.Fragment>
        <BlurredImg 
            src={smallImageUrl} 
            data-id={this.props.id} 
            data-format={this.props.format} 
            shouldBeVisible={!this.state.imageLoaded} 
        />
        {this.props.pageLoaded && <img src={fullImageUrl} onLoad={this.handleImageLoaded} />}
    </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

Note the onLoad handler - this signals that the real image is ready & we can fade the blurred image out:

handleImageLoaded = () => {
    this.setState({
        imageLoaded: true
    });
}
Enter fullscreen mode Exit fullscreen mode

Great! So we have images loading after initially loading the page. But this only gets us most of the way, since they will all load immediately upon hydration (for all images on the page). So what do we do?

Track visibility 🔎

The react-on-screen library makes for a simple higher-order component that does this, passing an isVisible prop to the component. But any in-viewport visibility tracker will do. Since this introduces another state, we'll need to add { imageSeen: false } to our initial state.

Then, we can watch for updated props and set imageSeen to true when the image has been in the viewport, preventing it from being loaded again when scrolling the component off-screen:

componentDidUpdate() {
    if (!this.state.imageSeen && this.props.isVisible) {
        this.setState(prevState => ({
            ...prevState,
            imageSeen: this.props.isVisible
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

And there you have it! An image rendering component that's quick, efficient, and not lame at all!

go back