It turns out it's really easy to build an image gallery with a loading indicator (spinner) in React. This one is a single component with only 70 lines including spacing and comments.
I'll walk you through creating the gallery component piece by piece. Here's what you'll get when you're done:
The refresh button is not really part of the component - I added that so you can see the spinner in action.
If you don't have a starter/template project for React ready to go, feel free to use mine! ahfarmer/minimal-react-starter.
Just run the following to get up and running:
git clone https://github.com/ahfarmer/minimal-react-starter.git
cd minimal-react-starter
npm install
npm start
This template project has a single 'HelloWorld' component - you can actually
just edit HelloWorld.js
and follow along with the rest of the tutorial. Your
browser will automatically reload any time you make changes.
The first thing I do when writing a new component is pick the name and the
propTypes
. This is the starting point for our gallery:
import React from "react";
class Gallery extends React.Component {
// implementation will go here
}
Gallery.propTypes = {
imageUrls: React.PropTypes.arrayOf(React.PropTypes.string).isRequired
};
export default Gallery;
It only takes one property: an array of image URLs. When we are done it will display one image per URL.
Correct use of React PropTypes
can save a lot of debugging time. If our Gallery
component is passed
anything other than an array of strings in the imageUrls
prop, we'll see a
clearly-worded warning in the console. Don't use React.PropTypes.object
or
React.PropTypes.any
. Be specific.
Let's pass some image URLs to our Gallery
component so that when we fill out
its implementation we see some images start to show up. Wherever you are
rendering your component, provide the imageUrls
property. If you are following
along with minimal-react-starter
, you can change index.js
to look like this:
import React from "react";
import ReactDOM from "react-dom";
import Gallery from "./Gallery";
let urls = [
"/react-image-gallery/img/cat1.jpg",
"/react-image-gallery/img/cat2.jpg",
"/react-image-gallery/img/cat3.jpg"
];
ReactDOM.render(<Gallery imageUrls={urls} />, document.getElementById("mount"));
If you're not as big of a cat-lover as me, feel free to change the image URLs to any images of your choosing. Or if you want more than 3 images to show up - feel free to find and add even more cat pics. 😺
What you have so far should compile, but it won't actually render anything.
Let's fill out the render()
method so we have something to show for ourselves
;).
import React from "react";
class Gallery extends React.Component {
renderImage(imageUrl) {
return (
<div>
<img src={imageUrl} />
</div>
);
}
render() {
return (
<div className="gallery">
<div className="images">
{this.props.imageUrls.map(imageUrl => this.renderImage(imageUrl))}
</div>
</div>
);
}
}
Gallery.propTypes = {
imageUrls: React.PropTypes.arrayOf(React.PropTypes.string).isRequired
};
export default Gallery;
All my code samples strive to use the latest, most concise and readable ES6/ES2015 code. They follow the AirBNB Javascript style guide to the letter.
Okay so I threw 2 methods in there. render()
adds a couple <div>
wrappers
and then calls renderImage()
once per image URL. We're using the ES6
Array.prototype.map()
method to convert the array of image URLs into an array of virtual DOM nodes.
For visual learners (like me) here's a demo showing what we have so far:
Pretty sweet right? Not impressed? It gets more interesting when you add the loading indicator.
If all your content hasn't loaded and there's no spinner, your page just looks broken. Let's add a spinner to let your users know that more is coming. There are just 4 short steps.
Our Gallery
component needs to track whether or not it is loading so that it
knows whether or not to show the spinner. Set the default state in the
constructor. Default to 'true' since the images will be loading when the
component is first rendered.
constructor(props) {
super(props);
this.state = {
loading: true,
};
}
We render the spinner only when the state indicates that the images are still
loading. Create a separate renderSpinner()
method, and then call it from the
top of your render()
method.
renderSpinner() {
if (!this.state.loading) {
// Render nothing if not loading
return null;
}
return (
<span className="spinner" />
);
}
render()
now looks like this:
render() {
return (
<div className="gallery">
{this.renderSpinner()}
<div className="images">
{this.props.imageUrls.map(imageUrl => this.renderImage(imageUrl))}
</div>
</div>
);
}
At this point you should see the spinner - the only problem is it never goes away!
Next we'll add an onLoad
and onError
handler so we can respond when any
image loads (or fails to load).
Add these attributes to your <img>
tags:
onLoad={this.handleStateChange}
onError={this.handleStateChange}
And this function to the component:
handleStateChange = () => {
this.setState({
loading: !imagesLoaded(this.galleryElement),
});
}
imagesLoaded()
is a function that we'll define in the next step. It takes in
an Element as an argument and returns true if all of its <img>
children
have finished loading.
To get the gallery element we are using a ref. You'll need to add that
in the render()
method:
<div className="gallery" ref={element => { this.galleryElement = element; }}>
The ref attribute should be passed a function. The function will be called and passed the element as the first argument.
Time to define that function we left undefined in the last step:
imagesLoaded()
.
We just search the gallery Element for any <img>
children and check if they
are loaded or not with the
HTMLImageElement
'complete' property. This function isn't dependent on anything else in the
component (no references to this
) so we define it outside of the component
class.
function imagesLoaded(parentNode) {
const imgElements = parentNode.querySelectorAll("img");
for (const img of imgElements) {
if (!img.complete) {
return false;
}
}
return true;
}
You could also use a static method instead of defining the function above your class. It doesn't make any difference - it's just a style choice.
Now to tie it all together. If you've been coding along, you should have something like this:
import React from "react";
import PropTypes from "prop-types";
/**
* Given a DOM element, searches it for <img> tags and checks if all of them
* have finished loading or not.
* @param {Element} parentNode
* @return {Boolean}
*/
function imagesLoaded(parentNode) {
const imgElements = [...parentNode.querySelectorAll("img")];
for (let i = 0; i < imgElements.length; i += 1) {
const img = imgElements[i];
if (!img.complete) {
return false;
}
}
return true;
}
class Gallery extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: true
};
}
handleImageChange = () => {
this.setState({
loading: !imagesLoaded(this.galleryElement)
});
};
renderSpinner() {
if (!this.state.loading) {
return null;
}
return <span className="spinner" />;
}
renderImage(imageUrl) {
return (
<div>
<img
src={imageUrl}
onLoad={this.handleImageChange}
onError={this.handleImageChange}
/>
</div>
);
}
render() {
return (
<div
className="gallery"
ref={element => {
this.galleryElement = element;
}}
>
{this.renderSpinner()}
<div className="images">
{this.props.imageUrls.map(imageUrl => this.renderImage(imageUrl))}
</div>
</div>
);
}
}
Gallery.propTypes = {
imageUrls: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default Gallery;
And now the demo, one more time. Press the little reload(↻) button to see the spinner again.
Was that fun/useful/helpful? Sign up for my mailing list to get more like it! ⇟⇣★