DEV Community

John Au-Yeung
John Au-Yeung

Posted on

Use React to Display Images in a Grid Like Google and Flickr

Subscribe to my email list now at http://jauyeung.net/subscribe/

Follow me on Twitter at https://twitter.com/AuMayeung

Many more articles at https://medium.com/@hohanga

If you use image search websites like Google Image Search or Flickr, you will notice that their images display in a grid that looks like a wall of bricks. The images are uneven in height, but equal in width. This is called the masonry effect because it looks like a wall of bricks.

To implement the masonry effect, we have to set the width of the image proportional to the screen width and set the image height to be proportional to the aspect ratio of the image.

This is a pain to do if it’s done without any libraries, so people have made libraries to create this effect.

In this article, we will build a photo app that allows users to search for images and display images in a masonry grid. The image grid will have infinite scroll to get more images. We will build it with React and the React Masonry Component library. For infinite scrolling, we will use the React Infinite Scroller library. We will wrap the React Infinite Scroller outside the React Masonry Component to get infinite scrolling with the masonry effect when displaying images.

Our app will display images from the Pixabay API. You can view the API documentation and register for a key at https://pixabay.com/api/docs/

To start, we run Create React App to create the app. Run npx create-react-app photo-app to create the initial code for the app.

Then we install our own libraries. We need React Infinite Scroller, React Masonry Component, Bootstrap for styling, Axios for making HTTP requests, Formik and Yup for form value data binding and form validation, and React Router for routing URLs to our pages.

To install all the packages, run:

npm i axios bootstrap formik react-bootstrap react-infinite-scroller react-masonry-component react-router-dom yup

to install all the packages.

With all the packages installed, we can start building the app. First start with replacing the code in App.js with:

import React from "react";  
import { Router, Route } from "react-router-dom";  
import HomePage from "./HomePage";  
import { createBrowserHistory as createHistory } from "history";  
import TopBar from "./TopBar";  
import ImageSearchPage from "./ImageSearchPage";  
import "./App.css";  
const history = createHistory();

function App() {  
  return (  
    <div className="App">  
      <Router history={history}>  
        <TopBar />  
        <Route path="/" exact component={HomePage} />  
        <Route path="/imagesearch" exact component={ImageSearchPage} />  
      </Router>  
    </div>  
  );  
}

export default App;

to add the top bar and the routes for our app into the entry point of the app.

Next remove all the code in App.css and add:

.page {  
  padding: 20px;  
}

to add padding to our pages.

Then we set our React Masonry Component options by creating a exports.js in the src folder and add:

export const masonryOptions = {  
  fitWidth: true,  
  columnWidth: 300,  
  gutter: 5  
};

These options are very important. We need to set fitWidth to true to center our grid. columnWidth must be a number to get constant width. It will scale according to screen size only with constant width. The gutter value is the margin between items.

The full list of options are at https://masonry.desandro.com/options.html

Next we create our app’s home page by creating HomePage.js in the src folder and add:

import React from "react";  
import { getImages } from "./request";  
import InfiniteScroll from "react-infinite-scroller";  
import Masonry from "react-masonry-component";  
import "./HomePage.css";  
import { masonryOptions } from "./exports";

function HomePage() {  
  const [images, setImages] = React.useState([]);  
  const [page, setPage] = React.useState(1);  
  const [total, setTotal] = React.useState(0);  
  const [initialized, setInitialized] = React.useState(false); 
  const getAllImages = async (pg = 1) => {  
    const response = await getImages(page);  
    let imgs = images.concat(response.data.hits);  
    setImages(imgs);  
    setTotal(response.data.total);  
    pg++;  
    setPage(pg);  
  }; 

  React.useEffect(() => {  
    if (!initialized) {  
      getAllImages();  
      setInitialized(true);  
    }  
  }); 

  return (  
    <div className="page">  
      <h1 className="text-center">Home</h1>  
      <InfiniteScroll  
        pageStart={1}  
        loadMore={getAllImages}  
        hasMore={total > images.length}  
      >  
        <Masonry  
          className={"grid"}  
          elementType={"div"}  
          options={masonryOptions}  
          disableImagesLoaded={false}  
          updateOnEachImageLoad={false}  
        >  
          {images.map((img, i) => {  
            return (  
              <div key={i}>  
                <img src={img.previewURL} style={{ width: 300 }} />  
              </div>  
            );  
          })}  
        </Masonry>  
      </InfiniteScroll>  
    </div>  
  );  
}  
export default HomePage;

In the home page, we just get the images when the page loads. When the user scroll down, we load more images by adding 1 to the currentpage value and get the image with the page number.

With the InfiniteScroll component, which is provided by React Infinite Scroll, wrapped outside the Masonry component, which is provided by React Masonry Component, we display our images in a grid, and also display more when the user scrolls down until the length of the images array is greater than or equal to the total, which is from the total field given by the Pixabay API’s results.

We load images when the page loads by checking if initialized flag is true, we only load images on page load if initialized is false and the when the request is first made to the API and succeeds, then we set initialized flag to true to stop requests from being made on every render.

Next we create a image search page by creating the ImageSearchPage.js file and adding the following:

import React from "react";  
import { Formik } from "formik";  
import Form from "react-bootstrap/Form";  
import Col from "react-bootstrap/Col";  
import Button from "react-bootstrap/Button";  
import * as yup from "yup";  
import InfiniteScroll from "react-infinite-scroller";  
import Masonry from "react-masonry-component";  
import { masonryOptions } from "./exports";  
import { searchImages } from "./request";

const schema = yup.object({  
  keyword: yup.string().required("Keyword is required")  
});

function ImageSearchPage() {  
  const [images, setImages] = React.useState([]);  
  const [keyword, setKeyword] = React.useState([]);  
  const [page, setPage] = React.useState(1);  
  const [total, setTotal] = React.useState(0);  
  const [searching, setSearching] = React.useState(false); 

  const handleSubmit = async evt => {  
    const isValid = await schema.validate(evt);  
    if (!isValid) {  
      return;  
    }  
    setKeyword(evt.keyword);  
    searchAllImages(evt.keyword, 1);  
  }; 

  const searchAllImages = async (keyword, pg = 1) => {  
    setSearching(true); 
    const response = await searchImages(keyword, page);  
    let imgs = response.data.hits;  
    setImages(imgs);  
    setTotal(response.data.total);  
    setPage(pg);  
  }; 

  const getMoreImages = async () => {  
    let pg = page;  
    pg++;  
    const response = await searchImages(keyword, pg);  
    const imgs = images.concat(response.data.hits);  
    setImages(imgs);  
    setTotal(response.data.total);  
    setPage(pg);  
  }; 

  React.useEffect(() => {}); 

  return (  
    <div className="page">  
      <h1 className="text-center">Search</h1>  
      <Formik validationSchema={schema} onSubmit={handleSubmit}>  
        {({  
          handleSubmit,  
          handleChange,  
          handleBlur,  
          values,  
          touched,  
          isInvalid,  
          errors  
        }) => (  
          <Form noValidate onSubmit={handleSubmit}>  
            <Form.Row>  
              <Form.Group as={Col} md="12" controlId="keyword">  
                <Form.Label>Keyword</Form.Label>  
                <Form.Control  
                  type="text"  
                  name="keyword"  
                  placeholder="Keyword"  
                  value={values.keyword || ""}  
                  onChange={handleChange}  
                  isInvalid={touched.keyword && errors.keyword}  
                />  
                <Form.Control.Feedback type="invalid">  
                  {errors.keyword}  
                </Form.Control.Feedback>  
              </Form.Group>  
            </Form.Row>  
            <Button type="submit" style={{ marginRight: "10px" }}>  
              Search  
            </Button>  
          </Form>  
        )}  
      </Formik>  
      <br />  
      <InfiniteScroll  
        pageStart={1}  
        loadMore={getMoreImages}  
        hasMore={searching && total > images.length}  
      >  
        <Masonry  
          className={"grid"}  
          elementType={"div"}  
          options={masonryOptions}  
          disableImagesLoaded={false}  
          updateOnEachImageLoad={false}  
        >  
          {images.map((img, i) => {  
            return (  
              <div key={i}>  
                <img src={img.previewURL} style={{ width: 300 }} />  
              </div>  
            );  
          })}  
        </Masonry>  
      </InfiniteScroll>  
    </div>  
  );  
}  
export default ImageSearchPage;

We do not load images on the first load on this page. Instead, the user enters a search term in the form and when the user clicks the Search button, then handleSubmit is called. The evt object has the form values, which is updated by the Formik component. Yup provides the form validation object with the schema object, we just check if keyword is required.

In the handlesubmit function, we get the evt object, which we validate against the schema by callingschema.validate, which returns a promise. If the promise returns to something truthy, then we proceed with making the request to the Pixabay API with the search keyword and page number.

We have the same setup as the home page for the infinite scroll and masonry effect image grid. The only difference is that we call the searchAllImages function which has similar logic as the getAllImages function, except that we pass in the keyword parameter in addition to the page parameter. We set the imgs variable to the array returned from the Pixabay API and set the images by calling setImages. We also set the page by calling setPage.

When the user scrolls far enough down that content runs out, the getMoreImages function is called when images.length is less than the total. The total is set by getting the total field from the API.

We use masonryOptions from exports.js just like in the home page and display images the same way.

Next create request.js in the src folder to add the code for making HTTP requests to the back end, like so:

const axios = require("axios");  
const APIURL = "https://pixabay.com/api";

export const getImages = (page = 1) =>  
  axios.get(`${APIURL}/?page=${page}&key=${process.env.REACT_APP_APIKEY}`);

export const searchImages = (keyword, page = 1) =>  
  axios.get(  
    `${APIURL}/?page=${page}&key=${process.env.REACT_APP_APIKEY}&q=${keyword}`  
  );

We have the getImages for just getting images and searchImages that also sends the search term to the API. process.env.REACT_APP_APIKEY is from setting the REACT_APP_APIKEY variable in the .env file in the project’s root folder.

Next create TopBar.js in the src folder and add:

import React from "react";  
import Navbar from "react-bootstrap/Navbar";  
import Nav from "react-bootstrap/Nav";  
import { withRouter } from "react-router-dom";

function TopBar({ location }) {  
  React.useEffect(() => {}); 

  return (  
    <Navbar bg="primary" expand="lg" variant="dark">  
      <Navbar.Brand href="#home">Photo App</Navbar.Brand>  
      <Navbar.Toggle aria-controls="basic-navbar-nav" />  
      <Navbar.Collapse id="basic-navbar-nav">  
        <Nav className="mr-auto">  
          <Nav.Link href="/" active={location.pathname == "/"}>  
            Home  
          </Nav.Link>  
          <Nav.Link  
            href="/imagesearch"  
            active={location.pathname == "/imagesearch"}  
          >  
            Search  
          </Nav.Link>  
        </Nav>  
      </Navbar.Collapse>  
    </Navbar>  
  );  
}  

export default withRouter(TopBar);

This contains the React Bootstrap Navbar to show a top bar with a link to the home page and the name of the app. We check the location.pathname to highlight the right links by setting the active prop, where the location prop is provided by React Router by wrapping the withRouter function outside the TopBar component.

Finally, in index.js , we replace the existing code with:

<!DOCTYPE html>  
<html lang="en">  
  <head>  
    <meta charset="utf-8" />  
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />  
    <meta name="viewport" content="width=device-width, initial-scale=1" />  
    <meta name="theme-color" content="#000000" />  
    <meta  
      name="description"  
      content="Web site created using create-react-app"  
    />  
    <link rel="apple-touch-icon" href="logo192.png" />  
    <!--  
      manifest.json provides metadata used when your web app is installed on a  
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ 
    -->  
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />  
    <!--  
      Notice the use of %PUBLIC_URL% in the tags above.  
      It will be replaced with the URL of the `public` folder during the build.  
      Only files inside the `public` folder can be referenced from the HTML.Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC\_URL%/favicon.ico" will  
      work correctly both with client-side routing and a non-root public URL.  
      Learn how to configure a non-root public URL by running `npm run build`.  
    -->  
    <title>Photo App</title>  
    <link  
      rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"  
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"  
      crossorigin="anonymous"  
    />  
  </head>  
  <body>  
    <noscript>You need to enable JavaScript to run this app.</noscript>  
    <div id="root"></div>  
    <!--  
      This HTML file is a template.  
      If you open it directly in the browser, you will see an empty page. You can add webfonts, meta tags, or analytics to this file.  
      The build step will place the bundled scripts into the <body> tag. To begin the development, run `npm start` or `yarn start`.  
      To create a production bundle, use `npm run build` or `yarn build`.  
    -->  
  </body>  
</html>

to add the Bootstrap CSS and change the title.

Top comments (2)

Collapse
 
ghowardmsd profile image
ghowardMSD

Hi John, nice article. Couple of points. You refer to index.js at one point when I think you mean index.html.

Also, where is HomePage.css?

Collapse
 
aumayeung profile image
John Au-Yeung

I didn't change HomePage.css from the default so I left it out.

You're right about index.js. It should be index.html.

Thanks for reading.