Performant Lazy Loading In React

One day Intersection Observer will become widely supported enough that you can use it to implement lazy loading. For the moment however its browser support is too patchy, the polyfill explodes if it comes within 30 feet of a server-side render, and it all feels too new to bet the farm on. One day…. One day…

Until then you may find yourself the proud owner of a DIY React lazy loading solution.

Lets say you have a higher order component which will wrap things like images that you wish to lazy load.

componentDidMount() {
    addListener('scroll', this.onscroll)
}

onscroll = () => {
    const { top, bottom, height } = this.wrappedComponent.getBoundingClientRect()

    // Calculation to determine if on screen and distance from the viewport...

    this.setState({ onscreen, distanceFromViewport })
}

After every scroll each instance has its onscroll handler called. Each instance figures out if its onscreen. If not onscreen it works out how far it is from the viewport in case we want to load stuff when its getting close to the viewport.

The wrapped component can look at the state values set by the higher order component and make the final determination if it should load its image (or whatever).

Within the wrapped component (in this case an image component).

componentWillReceiveProps() {
    if (this.onScreenOrCloseEnough()) {
        this.setState({ loadImage: true })
    }
}

Wrapping your images in a higher order component that sets a bunch of position related state then letting the wrapped component decide whether it needs to load does work. There is just one problem. On a resource constrained device its really slow.

Things To Try (That Won’t Help)

Reduce The Number Of Event Subscribers

With each instance of the higher order component subscribing to scroll events you can easily wind up with dozens of event subscribers. You can instead have one object subscribe to events and notify all instances of your component. This doesn’t help much if at all.

Refactor The Distance To Viewport Code

The code here isn’t computationally intensive. The math just isn’t that complicated. Wins can be made but they are slight.

Use requestAnimationFrame()

Instead of doing everything in the scroll event handler have the scroll handler set a flag and pass our function into requestAnimationFrame(). When the browser is ready to redraw we can figure out what is onscreen . That way it doesn’t matter how many scroll events occur between frames.

This just transfers our performance problem from the onscroll handler into the animation frame handler.

The Real Problem

The below contains the main problem hidden in plain sight.

componentDidMount() {
    addListener('scroll', this.onscroll)
}

onscroll = () => {
    const { top, bottom, height } = this.wrappedComponent.getBoundingClientRect()
    
    // Calculation to determine if on screen and distance from the viewport...

    this.setState({ inViewport: viewportPosition, distanceFromViewport })
}

If you google getBoundingClientRect() you may discover that it can cause a “forced synchronous layout”. To tell you where the component is located the browser has to ensure that all state changes, CSS and whatever else has been applied. If anything has changed since the last layout was done the browser can’t guarantee that a component hasn’t moved.

So this code forces the browser to do a layout if state has changed, then at the end of the function it changes state. If we have a bunch of instances listening for scroll events, every one causes a forced synchronous layout then changes state.

How To Fix It

Simply making the state changes happen later makes the UI more responsive. All of the onscreen calculations occur in the onscroll event handlers then all of the state changes happen later.

setTimeout(() => this.setState({ inViewport: viewportPosition, distanceFromViewport }))

The CPU still gets a pretty solid hammering working through all of the timers that get spun up.

The problem is that distanceFromViewport prop. That is almost guaranteed to change on every scroll. Can we not have every component change its state every scroll?

Instead of setting state then letting the wrapped component access those values to make the decision whether it should load, how about passing in a function that makes that determination?

onscroll = () => {
    const { top, bottom, height } = this.wrappedComponent.getBoundingClientRect()
    
    // Calculation to determine if on screen and distance from the viewport...

    // shouldLoad() supplied by the wrapped component
    if (shouldLoad(shouldLoadProps)) {
        setTimeout(() => {
            this.setState({ loaded: true })
        })
    }
}

The calling code will look something like this.

const LazyImage = lazyHigherOrderComponent(Image, shouldload);

Now the state change happens after the scroll handlers and state changes also happen much less often. This gives dramatically better performance.

Lessons

1) Be very careful about interleaving requests for component locations and changes to state. The performance hit is large.

2) Try to update state as rarely as possible. Do you really need the precise pixel location or that millisecond timestamp stored in state?