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.