How to Build an E-commerce Search UI with React and Elasticsearch

How to Build an E-commerce Search UI with React and Elasticsearch

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:

  1. A powerful search backend — Elasticsearch here fits the bill, being the #1 search engine. You can also use OpenSearch instead.

  2. 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"

**Image:** Tap on the image to view and/or “clone” this dataset.

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.

**Image:** Output at [http://localhost:3000](http://localhost:3000) after setting up CRA.

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.

**Image:** Output at [http://localhost:3000](http://localhost:3000) after adding reactivesearch

Adding UI Components

Components we will need to begin with:

  1. A books search box,

  2. A books ratings filter,

  3. A display of books' results.

**Image:** SearchBox component UI using the adjacent snippet.

<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

**Image:** SingleRange component UI from the snippet below.

<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

image.png 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.

**Image:** Our app UI after adding three components :-)

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.

image.png 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:

  1. Add more filters (ReactiveSearch offers 20+ components),

  2. Add sort options to allow different ordering of the results,

  3. Add an OAuth login flow and only allow signed-in users to see this UI view.

**Image:** All the UI components that [ReactiveSearch](https://docs.appbase.io/docs/reactivesearch/v3/overview/quickstart/) offers

I have created a flavor with some additional filters:

image.png 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:

  1. Code Repository — https://github.com/appbaseio-apps/booksearch,

  2. ReactiveSearch Repo — https://github.com/appbaseio/reactivesearch,

  3. Components playground — https://opensource.appbase.io/playground/,

  4. Documentation — https://docs.appbase.io/docs/reactivesearch/v3/overview/quickstart/.