In this post, you can follow along and build an e-commerce book search UI using React and Elasticsearch — in under an hour!
Building a search UI requires two key components:
A powerful search backend — Elasticsearch here fits the bill, being the #1 search engine. You can also use OpenSearch instead.
A well designed UI — React is a great choice for undertaking this endeavor in.
However, wielding these two powerful tools effectively requires a lot of work. For instance, the mapping, analyzers and tokenizers need to be set correctly or you may not receive accurate search results back. Besides, the more filters that get applied along with the search query, the more complex the resulting search query becomes. And designing data-driven UI views where state is synced with the backend is no trivial feat.
This is where ReactiveSearch comes to aid, an open-source React UI components library for Elasticsearch that I am a contributor to. It offers 25+ rich UI components that provide a powerful scaffolding for building data-driven search UIs.
In this post, I will show how to use ReactiveSearch for building a booksearch UI in less than an hour.
Before we Get Started
We will need a good dataset. We will borrow one about Goodbooks from https://github.com/zygmuntz/goodbooks-10k. You can check out the cleaned-up version that is already indexed into Elasticsearch over [here]
(dejavu.appbase.io/?appname=good-books-ds&am..).
app="good-books-ds"
url="https://6a0ae3a3a8d4:6a3f508d-169b-4ed7-9680-20658120930f@appbase-demo-ansible-abxiydt-arc.searchbase.io"
Next, we will need an Elasticsearch index to host our backend data. In this post, we will use appbase.io for doing this but you can also run your own Elasticsearch cluster and create an index.
If you opt for cloning the above dataset, you would have already created an appbase.io app.
Getting Started with Create React App
Now that we have the dataset and indexing figured out, we are ready to get started with building the app.
We will initialize a boilerplate with the CRA setup.
npm install -g create-react-app # install CRA if you don't have it.
create-react-app booksearch # initialize the boilerplate.
cd booksearch
Let’s test the default CRA app by running npm start.
One of the great benefits of using CRA is that it works without requiring to set up a build configuration.
Add ReactiveSearch
Next, install @appbaseio/reactivesearch
via npm.
npm install --save @appbaseio/reactivesearch@latest
All the ReactiveSearch components are wrapped inside a container component — ReactiveBase which glues the Elasticsearch index and the ReactiveSearch components together. Edit src/App.js file.
import React, { Component } from 'react';
import { ReactiveBase } from '@appbaseio/reactivesearch';
class App extends Component {
render() {
return (
<ReactiveBase
app="good-books-ds"
url="https://6a0ae3a3a8d4:6a3f508d-169b-4ed7-9680-20658120930f@appbase-demo-ansible-abxiydt-arc.searchbase.io"
>
Hello from Reactive Search!
</ReactiveBase>
);
}
}
export default App;
The app and credentials refer to the index name and any credentials you may have set up to authorize access. ReactiveBase also supports a url prop which takes in the Elasticsearch cluster URL.
Note: You should either use read-only credentials or have an authorization middleware that ReactiveBase connects to for ensuring secure access in a production build.
Let’s start the server with npm start.
Adding UI Components
Components we will need to begin with:
A books search box,
A books ratings filter,
A display of books' results.
SearchBox
<SearchBox
componentId="mainSearch"
dataField={["original_title", "authors"]
queryFormat="and"
placeholder="Search for books..."
/>
componentId
is a mandatory prop which requires specifying a unique string that is used internally by ReactiveSearch lib, as well as by some of the other user facing props.
dataField
prop tells SearchBox which fields to query on. It can take either a string (single field) or an Array of strings (multiple fields).
queryFormat
prop’s and value tells SearchBox to only return those results where the search query is present across all the dataField
values.
We will eventually perform more customizations in the following steps. You can find the full reference of SearchBox component over here.
SingleRange
<SingleRange
componentId="ratingsFilter"
dataField="average_rating_rounded"
title="Book Ratings"
data={[
{ start: 4, end: 5, label: "★★★★ & up" },
{ start: 3, end: 5, label: "★★★ & up" },
{ start: 2, end: 5, label: "★★ & up" },
{ start: 1, end: 5, label: "★ & up" }
]}
/>
data prop here allows us to define the range [start, end] options with a label value. When a user selects one of the range options, a range query is applied on the dataField
with the selected range endpoints.
You can find the full reference of the SingleRange component over here.
ResultCard
ResultCard component UI from the snippet below
<ReactiveList
componentId="results"
dataField="original_title"
react={{
and: ["mainSearch", "ratingsFilter"]
}}
pagination={true}
size={8}
render={({ data }) => (
<ReactiveList.ResultCardsWrapper>
{data.map((item) => (
<ResultCard key={item.id}>
<ResultCard.Image src={item.image} />
<ResultCard.Title>
<div
className="book-title"
dangerouslySetInnerHTML={{
__html: item.original_title
}}
/>
</ResultCard.Title>
<ResultCard.Description>
<div className="flex column justify-space-between">
<div>
<div>
by{" "}
<span className="authors-list">
{item.authors}
</span>
</div>
<div className="ratings-list flex align-center">
<span className="stars">
{Array(item.average_rating_rounded)
.fill("x")
.map(() => "⭐ ")}
</span>
<span className="avg-rating">
({item.average_rating} avg)
</span>
</div>
</div>
<span className="pub-year">
Pub {item.original_publication_year}
</span>
</div>
</ResultCard.Description>
</ResultCard>
))}
</ReactiveList.ResultCardsWrapper>
)}
/>
As the name suggests, the ResultCard component displays the results in a card layout.
react
prop tells the ResultCard component to construct the query based on the individual queries used within the SearchBox and SingleRange components we defined above (referenced by their componentId
value).
render
prop is a function that receives hits and returns the cards to render.
Full reference of what ResultCard component allows can be found over here.
Combining the Elements
Let's put these three components together in the src/App.js file.
import React, { Component } from "react";
import {
ReactiveBase,
SearchBox,
SingleRange,
ResultCard,
ReactiveList
} from "@appbaseio/reactivesearch";
class App extends Component {
render() {
return (
<ReactiveBase
app="good-books-ds"
url="https://6a0ae3a3a8d4:6a3f508d-169b-4ed7-9680-20658120930f@appbase-demo-ansible-abxiydt-arc.searchbase.io"
>
<SearchBox
componentId="mainSearch"
dataField={[
"original_title",
"original_title.search",
"authors",
"authors.search"
]}
categoryField="genres.keyword"
className="search-bar"
queryFormat="and"
placeholder="Search for books..."
iconPosition="left"
autosuggest={true}
filterLabel="search"
enableRecentSuggestions={true}
enablePopularSuggestions={true}
enablePredictiveSuggestions={true}
popularSuggestionsConfig={{
size: 3,
minHits: 2,
minChars: 4
}}
recentSuggestionsConfig={{
size: 3,
minChars: 4
}}
index="good-books-ds"
size={10}
/>
<SingleRange
componentId="ratingsFilter"
dataField="average_rating_rounded"
title="Book Ratings"
data={[
{ start: 4, end: 5, label: "★★★★ & up" },
{ start: 3, end: 5, label: "★★★ & up" },
{ start: 2, end: 5, label: "★★ & up" },
{ start: 1, end: 5, label: "★ & up" }
]}
react={{
and: "mainSearch"
}}
/>
<ReactiveList
componentId="results"
dataField="original_title"
react={{
and: ["mainSearch", "ratingsFilter"]
}}
pagination={true}
size={8}
render={({ data }) => (
<ReactiveList.ResultCardsWrapper>
{data.map((item) => (
<ResultCard key={item.id}>
<ResultCard.Image src={item.image} />
<ResultCard.Title>
<div
className="book-title"
dangerouslySetInnerHTML={{
__html: item.original_title
}}
/>
</ResultCard.Title>
<ResultCard.Description>
<div className="flex column justify-space-between">
<div>
<div>
by{" "}
<span className="authors-list">{item.authors}</span>
</div>
<div className="ratings-list flex align-center">
<span className="stars">
{Array(item.average_rating_rounded)
.fill("x")
.map(() => "⭐ ")}
</span>
<span className="avg-rating">
({item.average_rating} avg)
</span>
</div>
</div>
<span className="pub-year">
Pub {item.original_publication_year}
</span>
</div>
</ResultCard.Description>
</ResultCard>
))}
</ReactiveList.ResultCardsWrapper>
)}
/>
</ReactiveBase>
);
}
}
export default App;
Let’s start the server to see how the UI looks with npm start
.
You should see a fully functional UI that works. Type a search query to see the results reflect in the cards UI. The only thing missing at this point is the layout arrangement and the styles. We will add these in the next step.
Note: We didn’t break a sweat so far and have been able to work around Elasticsearch’s Query DSL complexity, didn’t need to write complex rendering logic nor manage state bindings to our UI view.
Adding Styles and Layout
ReactiveSearch provides us with scoped-styled components while leaving the choice of layout to the user. In this post, we will use Flex to arrange the components but we could’ve also used Materialize, Bootstrap, or a CSS Grid. If you are new to Flex, I recommend reading this article.
We will add a navbar and give our app a nice logo, along with adding some layout and styles to each of the components. To do this, we will take benefit of the className
and innerClass
props.
Here’s our final src/App.js with the changes.
import React, { Component } from "react";
import {
ReactiveBase,
SearchBox,
SingleRange,
ResultCard,
ReactiveList
} from "@appbaseio/reactivesearch";
import "./App.css";
class App extends Component {
render() {
return (
<ReactiveBase
app="good-books-ds"
url="https://6a0ae3a3a8d4:6a3f508d-169b-4ed7-9680-20658120930f@appbase-demo-ansible-abxiydt-arc.searchbase.io"
enableAppbase
>
<div className="navbar">
<div className="logo">The Booksearch App</div>
<SearchBox
componentId="mainSearch"
dataField={[
"original_title",
"original_title.search",
"authors",
"authors.search"
]}
categoryField="genres.keyword"
queryFormat="and"
placeholder="Search for books..."
iconPosition="left"
autosuggest={true}
filterLabel="search"
enableRecentSuggestions={true}
enablePopularSuggestions={true}
enablePredictiveSuggestions={true}
popularSuggestionsConfig={{
size: 3,
minHits: 2,
minChars: 4
}}
recentSuggestionsConfig={{
size: 3,
minChars: 4
}}
index="good-books-ds"
size={10}
className="searchbar"
innerClass={{
input: "searchbox",
list: "suggestionlist"
}}
showClear
/>
</div>
<div className={"display"}>
<div className={"leftSidebar"}>
<SingleRange
componentId="ratingsFilter"
dataField="average_rating_rounded"
title="Book Ratings"
data={[
{ start: 4, end: 5, label: "⭐⭐⭐⭐ & up" },
{ start: 3, end: 5, label: "⭐⭐⭐ & up" },
{ start: 2, end: 5, label: "⭐⭐ & up" },
{ start: 1, end: 5, label: "⭐ & up" }
]}
/>
</div>
<div className={"mainBar"}>
<ReactiveList
componentId="results"
dataField="original_title"
react={{
and: ["mainSearch", "ratingsFilter"]
}}
pagination={true}
size={8}
render={({ data }) => (
<ReactiveList.ResultCardsWrapper>
{data.map((item) => (
<ResultCard key={item.id}>
<ResultCard.Image src={item.image} />
<ResultCard.Title>
<div
className="book-title"
dangerouslySetInnerHTML={{
__html: item.original_title
}}
/>
</ResultCard.Title>
<ResultCard.Description>
<div className="flex column justify-space-between">
<div>
<div>
by{" "}
<span className="authors-list">
{item.authors}
</span>
</div>
<div className="ratings-list flex align-center">
<span className="stars">
{Array(item.average_rating_rounded)
.fill("x")
.map(() => "⭐ ")}
</span>
<span className="avg-rating">
({item.average_rating} avg)
</span>
</div>
</div>
<span className="pub-year">
Pub {item.original_publication_year}
</span>
</div>
</ResultCard.Description>
</ResultCard>
))}
</ReactiveList.ResultCardsWrapper>
)}
/>
</div>
</div>
</ReactiveBase>
);
}
}
export default App;
We also import src/App.css this time around to include our user-defined styles.
.navbar {
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
background-color: #0b6aff;
color: #fff;
height: 52px;
font-size: 15px;
letter-spacing: 0.05rem;
}
.logo {
margin-left: 16px;
float: left;
}
.searchbar {
margin-left: auto;
margin-right: auto;
}
.searchbar .searchbox {
min-width: 400px;
border-radius: 30px;
padding: 5px 31px 5px 35px;
}
.suggestionlist {
color: #424242;
width: 90%;
margin-left: 5%;
}
.display {
display: flex;
margin-top: 60px;
}
.leftSidebar {
width: 320px;
height: 100%;
padding: 15px 20px;
position: fixed;
left: 0;
right: 0;
border-right: 1px solid #f0f0f0;
}
.mainBar {
width: calc(100% - 320px);
position: relative;
left: 320px;
padding: 0px 30px;
background-color: #fefefe;
}
.book-title {
white-space: normal;
margin-top: 4px;
}
.book-title-card {
white-space: normal;
margin-top: 4px;
max-height: 45px;
}
.book-image {
height: 150px;
width: 110px;
background-size: cover;
}
.book-header {
font-weight: bold;
margin-bottom: 5px;
}
.book-content {
background: white;
margin: 10px 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.authors-list {
color: #9d9d9d;
font-weight: bold;
}
.ratings-list {
padding: 10px 0;
}
.avg-rating {
color: #6b6b6b;
margin-left: 5px;
}
The majority of the stylesheet here is about setting the layout.
After changing these two files and running npm start
, you should see a UI like this.
Our App UI with layout and styles applied
Looks much better, right!
In case you are missing a step, you can get the code so far by following these:
git clone git@github.com:appbaseio-apps/booksearch.git
cd booksearch && git checkout basic-app
npm install && npm start
# visit http://localhost:3000
What can we do next?
We have just scratched the surface here of what’s possible to build. Some ideas that you can build upon here are:
Add more filters (ReactiveSearch offers 20+ components),
Add sort options to allow different ordering of the results,
Add an OAuth login flow and only allow signed-in users to see this UI view.
I have created a flavor with some additional filters:
A flavor of the booksearch app with more filters and sort options
I have added two new components here: RangeSlider and MultiList, and tweaked some props in the SearchBox and ResultCard components.
You can also see the code for the same at https://github.com/appbaseio-apps/booksearch/tree/master.
Conclusion
We went from a boilerplate with CRA to creating a data-driven booksearch UI, hopefully well within 60 minutes.
Here are some relevant links to help with further exploring:
Code Repository — https://github.com/appbaseio-apps/booksearch,
ReactiveSearch Repo — https://github.com/appbaseio/reactivesearch,
Components playground — https://opensource.appbase.io/playground/,
Documentation — https://docs.appbase.io/docs/reactivesearch/v3/overview/quickstart/.