Tackling and implementing an image loading strategy
Have you ever seen or noticed how sometimes you would first be given a lower resolution picture and then a higher resolution picture when it was ready, or when you had a higher speed connection? This is what I wanted to try out!
This strategy gives the user some context and feedback saying:
“I’m still loading, but this is basically what you’re getting in a second just in a better quality .”
I think it’s great UX and should, if it isn’t already, be the industry standard — of course not my implementation, but the idea itself. Nowadays video providers and players provide this as well! It’s absolutely amazing in my opinion and I love it!
What are we working with?
Here’s a compact component tree relevant to this post:
src
-components
-Tile
-Tile.js
-TileContainer.js
-TileDetails.js
-TilePic.js
-TileTitle.js
-contexts
-App.js
-index.js
I like to split my components into a containers and display components where the containers hold most if not all the business logic and pass only the necessary information down to the display components which will reflect them on the page.
# TileContainer.js...renderTiles = () => { return this.state.data.map(tile => { let favs = localStorage.getItem('favs') if (favs) {
favs = JSON.parse(favs) const fav = favs.filter(el => el.id === tile.id) if (fav.length) {
return <Tile key={tile.id} tile={tile} fav={true} />
}
} return <Tile key={tile.id} tile={tile} fav={false} /> })
}
return (
<main className='tile-area'>
<Suspense delayMs={1000} fallback={<div>Loading...</div>}> { this.state.data ? this.renderTiles() : null } </Suspense>
</main>
)
Here we split up the display components further into smaller pieces so as to avoid unnecessary re-renders if the Tile state changes.
# Tile.js...return ( <React.Fragment>
<div className='tile-wrapper'}>
<div className="tile-container" style={style} > <TilePic
picture={tile.picture}
thumbnail={tile.thumbnail}
title={tile.title}
author={tile.author}
/> <TileTitle title={tile.title} url={tile.url} /> <TileDetails tile={tile} /> </div> <i className="fab fa-gratipay fav-icon" /> </div>
</React.Fragment>
)
What are we trying to accomplish again?
I want to:
- Show a thumbnail, or a low resolution image
- Then once it is fully loaded, load a higher resolution image
Seems simple enough right? But, I had to know when exactly the thumbnail would be completely loaded, and not just when the HTML tag was rendered on the page. How was I to know when the thumbnail was already loaded? Because when you have an <img />, you are essentially doing a fetch request to get that picture from an external source and then loading it. I’ve never dealt with a situation like this previously, and so I consulted Google!
There wasn’t anything helpful pertaining to my specific question, but I found that the <img /> has a couple of attributes I hadn’t recalled or remember seeing before — the onload and onerror attributes!
The only examples I could find were using it as an event handler to trigger logs to the console, but it takes a callback as an argument so we’re in business!
How do we do this exactly?
Now we have a way to know when an image has been actually loaded, and not just when the element is first rendered on the DOM. Let’s see how we can use this to our advantage.
It’s very common to pass down a function to a child component which would be invoked on the child component or because of an event with the child component. One implementation, also very common, is to have that function change the state of the parent component.
Originally I had a piece of state holding whether or not the thumbnail was loaded or not as a boolean and passing down both a thumbnail and higher resolution image link down to the TilePic display component.
# Tile.jsstate = {
loaded: false
}handleOnload = () => {
this.setState({ loaded: true })
}<TilePic
thumbnail={tile.thumbnail}
picture={tile.picture}
handleOnload={this.handleOnload}
loaded={this.state.loaded}
/># TilePic.jsimport React, { memo } from 'react';const TilePic = memo(({ thumbnail, picture, author, title, handleOnload, loaded }) => { return (
<figure className='pic-container'> <img
className='tile-pic'
src={loaded ? picture : thumbnail}
onLoad={handleOnload}
alt={`${title} by ${author}`}
/> </figure> )});export default TilePic;
The callback handleOnload would be triggered when the thumbnail was loaded, Tile’s state would change and then TilePic would render the picture instead of the thumbnail since the state changed.
It worked, but it was less than ideal for a couple of reasons:
- I wanted to pass down as little information as possible — could there be a way to only pass down a single picture URL?
- The callback was being triggered twice — once when the thumbnail is loaded and a second time when the picture was loaded
Working Solution → Improving Solution
Sometimes I enjoy trying to think of an improved solution from the beginning, but I run into many what-if questions and it prevents me from coding and I end up starting on a working solution first, and then trying to improve that solution later on — it’s quite common I’d say!
Let’s start with the first and refactor the TilePic component to take less props and add this logic to the parent component, Tile.
# Tile.js
passPictureDown = () => { if(this.state.loaded) {
return this.props.tile.picture
} return this.props.tile.thumbnail
}return (
<TilePic
picture={this.passPictureDown()}
alt={`${tile.title} by ${tile.author}`}
handleOnload={this.handleOnload}
/>
)# TilePic.jsconst TilePic = memo(({ picture, alt, handleOnload }) => { return (
<figure className='pic-container'> <img
className='tile-pic'
src={picture}
onLoad={handleOnload}
alt={alt}
/> </figure>
)
});
Now we’ve reduced six props to only three now — less is always better!
So let’s work on the second and prevent an unnecessary state change:
# Tile.js
handleOnload = () => { this.setState(prevState => { if(prevState.loaded) {
return
} return {
loaded: true
}
})
}
We can pass a callback function into setState and access the previous state, instead of the current state because of how React batches some state changes together allowing you to reliably changing state. We check if it was already true and if so, exit the function altogether and that will effectively remove another change to state.
Other Related Issues
I was fetching the image links from an external API and noticed that sometimes they would only have a thumbnail and no higher resolution picture which would lead to a broken <img /> and that’s just ugly and most undesirable if I must say so myself!
In order to combat this, I adjusted the handleOnload callback function:
# Tile.jshandleOnload = () => { if(!this.props.tile.picture) {
return
} this.setState(prevState => { if(prevState.loaded) {
return
} return {
loaded: true
}
})
}
When the thumbnail is loaded, this callback function is invoked and there isn’t anything we can do to prevent that, but we can prevent a state change and prevent a broken link being passed down as props and in the end not show that ugly broken image tag!
Of course that wasn’t the only issue — sometimes the higher resolution picture link was provided but due to CORS or CORB errors it wouldn’t display and again that ugly broken image tag would be displayed instead. If we have the thumbnail, then I’d rather use that than a 404 image. But if neither is provided or both are broken, then a 404 image would be much nicer than a broken image tag!
# Tile.js
handleOnError = (e) => { // set img to thumbnail instead of broken img
if(this.props.tile.thumbnail) {
e.target.src = this.props.tile.thumbnail
return
} // if no thumbnail, show 404 image instead of broken img
const four04 = document.createElement('img') four04.src = "http://i.imgur.com/lqHeX.jpg" const parent = e.target.parentElement e.target.remove() parent.appendChild(four04)}return (
<TilePic
picture={this.passPictureDown()}
title={tile.title}
author={tile.author}
handleOnload={this.handleOnload}
handleOnError={this.handleOnError}
/>
)
# TilePic.jsconst TilePic = memo(({ picture, alt, handleOnload, handleOnError }) => { return (
<figure className='pic-container'> <img
className='tile-pic'
src={picture}
onLoad={handleOnload}
onError={handleOnError}
alt={alt}
/> </figure>
)
});
Another Possibility
With the new update to React we have access to the Suspense API which could be used in this scenario too! Unsure which implementation may be best, but this is what it would look like:
# Tile.jsimport React, { PureComponent, Suspense } from 'react'return ( <React.Fragment>
<div className='tile-wrapper'}>
<div className="tile-container" style={style} >
<Suspense fallback={
<TilePic
picture={tile.thumbnail}
alt={`${title} by ${author}`}
description={tile.description}
/>
}>
<TilePic
picture={tile.picture}
alt={`${title} by ${author}`}
description={tile.description}
/>
</Suspense> <TileTitle title={tile.title} url={tile.url} /> <TileDetails tile={tile} /> </div> <i className="fab fa-gratipay fav-icon" /> </div>
</React.Fragment>
)
This way if the TilePic with the higher resolution picture takes too long to load, it will load the TilePic with the thumbnail in the meantime!
Update
Check out a better version of this on a more recent blog post with a link to a live demo demonstrating loading a blurred low resolution picture and showing a high resolution picture as it loads on top of it for the most satisfying combination ever!