27 September 2018 Engineering

How to Build Application Search with React and Elastic App Search

By Kellen EvanJason Stoltzfus

When a user searches for something they want to receive relevant results. But relevant results are only part of what will win over a visitor; their search should also feel good. Search should be fast, react to input, and feel intelligent and effective.

This tutorial will teach you about application search by demonstrating how to build a fluid and robust search experience using React and the Elastic App Search JavaScript client. In the end, you will have a good-looking, relevant, React-ified application that will allow you to search over various npm packages in real-time with sorting by facets and the state maintained as part of the URI.

The completed code can be found on GitHub.

The live sample application can be found at http://packagehunt.swiftype.info/.

Requirements

To proceed, you will need to have the following...

  1. A recent version of Node.js.
  2. A recent version of npm.
  3. An Elastic App Search Service account or active 14-day free trial.
  4. About 30 minutes.

App Search, An Introduction

Applications are built around data. Facebook exploded into our social circles by presenting 'friend' data in an interesting way. eBay started as the most streamlined way of finding and buying used goods. Wikipedia made it easy for readers to learn about... well, everything!

Applications exist to solve data problems. In this effort, search is an essential companion. If it is a major application, a key part of its offering will be search: finding friends, products, conversations, or articles. The larger and more interesting the dataset, the more popular the application will be — especially if search is relevant and rewarding.

Elastic App Search is built on top of Elasticsearch, an open source, distributed, RESTful search engine. With Elastic App Search, developers receive access to a robust set of API endpoints optimized to handle premium application search use cases.

Start Your Engines

To begin, create an Engine within App Search.

An Engine ingests objects for indexing. An object is your data; it is the friend profile, the product, or the wiki-page. Once data is ingested into App Search, it is indexed against a flexible schema and optimized for search. From there, we can leverage different client libraries in order to craft an enjoyable search experience.

For this example, we will call our Engine: node-modules.

Once the Engine has been created, we will need three things from the Credentials page:

  1. The Host Identifier, prefixed with host-
  2. A Private API Key, prefixed with private-
  3. A Public Search Key, prefixed with search-

With that, we may clone the project, enter the directory, checkout the starter branch, then run an npm install:

$ git clone https://github.com/swiftype/app-search-demo-react.git
$ cd react-tutorial && git checkout starter && npm install

Great — we have an application primed and ready. But to search requires data...

Ingestion ~

In most cases, your objects would live within a database or a back-end API. Given that this is an example, we will use a static .json file. The repository contains two scripts: init-data.js and index-data.js. The former is a scraper which was used to acquire well-formatted node-module data from npm. The data exists within the node-modules.json file. The latter is an indexer which will ingest that data into your App Search Engine for indexing.

In order to run the indexer script, we will need to pass our Host Identifier and Private API Key along with it.

$ REACT_APP_HOST_IDENTIFIER={Your Host Identifier} \
REACT_APP_API_KEY={Your Private API Key} \
npm run index-data

Objects are rapidly sent to our App Search Engine in batches of 100 and the index is constructed.

We should now have a Dashboard for our newly created Engine with ~9,500 npm packages indexed as documents. It might be useful to poke around with the data so that we are familiar with its contents.

app_search_engine_overview.png

Reactivity

With our Engine filled up and ready, we can start building our core application.

$ npm start

Starting npm from within the project directory will open up the React boilerplate. It pulls its styling from App.css — let us better customize it to fit our needs.

In the near future, we are going to need a search box wherein we can type our search queries. Users will seek out these useful rectangles because search engines and browsers have them well trained: type here, find what you want! 

// App.css
...
.App-search-box {
  height: 40px;
  width: 500px;
  font-size: 1em;
  margin-top: 10px;
}

We will also need to place our access credentials for App Search in a safe place, like an .env file.

Create one within the project root directory and fill it in, like so:

// .env
REACT_APP_HOST_IDENTIFIER={your host identifier, prefixed with <code>host-</code>}
REACT_APP_SEARCH_KEY={your public search key, prefixed with <code>search-</code>}

With the variables safely tucked away, we can start writing our search logic.

Starting to Search

The App.js file is where the core logic will live. This file — along with most of the other starter files — was created by create-react-app, a tool to help bootstrap React applications without any configuration. Before we write some logic to test search, we need to install the Swiftype App Search JavaScript client library:

$ npm install --save swiftype-app-search-javascript

Place the following code into App.js. It will perform a basic search.

We will hard-code foo as our example search term:

import * as SwiftypeAppSearch from "swiftype-app-search-javascript";
const client = SwiftypeAppSearch.createClient({
  hostIdentifier: process.env.REACT_APP_HOST_IDENTIFIER,
  apiKey: process.env.REACT_APP_SEARCH_KEY,
  engineName: "node-modules"
});
// We can query for anything -- <code>foo</code> is our example.
const query = "foo";
const options = {};
client.search(query, options)
  .then(resultList => console.log(resultList))
  .catch(error => console.log(error))

The browser will refresh and create a resultList array via console.log. To explore the array, we can open up our browser's developer console. We can try a few more queries by replacing the foo query with another string. Once the query is changed and the page refreshes, we can see how the result set has adapted.

Great ~ with that, we are already searching through our node-modules.

Resulting Goodness

We have a simple pattern down in order to search, but the results are of little use hidden away in a console.log. We are going to remove the basic React style and our previous code, then extend things.

We are going to create...

  1. A state variable which will hold a response property.
  2. A performQuery method that will query App Search using client.search. It will store the query results within the response property.
  3. A componentDidMount lifecycle hook, which will run once upon application load. We will query foo again, but we can use any query we would like.
  4. Structured HTML to hold the resulting data output and the total number of results.
// App.js
// ... Truncated!
class App extends Component {
  state = {
    // A new state property, which holds the most recent query response
    response: null
  };
  componentDidMount() {
    /* Calling this in componentDidMount ensures that results are displayed on
    the screen when the app first loads */
    this.performQuery("foo");
  }
  // Method to perform a query and store the response
  performQuery = queryString => {
    client.search(queryString, {}).then(
      response => {
        // Add this for now so you can inspect the full response
        console.log(response);
        this.setState({ response });
      },
      error => {
        console.log(`error: ${error}`);
      }
    );
  };
  render() {
    const {response} = this.state;
    if (!response) return null;
    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">Node Module Search</h1>
        </header>
        {/* Show the total count of results for this query */}
        <h2>{response.info.meta.page.total_results} Results</h2>
        {/* Iterate over results, and show their Name and Description */}
        {response.results.map(result => (
          <div key={result.getRaw("id")}>
            <p>Name: {result.getRaw("name")}</p>
            <p>Description: {result.getRaw("description")}</p>
          </div>
        ))}
      </div>
    );
  }
}
// ... Truncated!

The moment we hit save, results will appear within http://localhost:3000 — 27 results and some nifty sounding modules. If something went wrong, we can check out the console as we have two console.logs nested within the code.

Fancy Boxing

We have been hard-coding foo into our queries. What makes search most valuable is that it begins with a free expression. Once you have developed a great search experience, you will be able to optimize for the more common expressions, curating the most relevant result sets. It all begins with a blank canvas: the search box.

To craft an able search box, we will add a property to state called queryString. To keep queryString updated with new strings, we will create an updateQuery method; we will leverage an onChange handler to update queryString and trigger a new search each time a user changes the text in the box.

Our full App class now looks like this:

// src/App.js
// ... Truncated!
class App extends Component {
  state = {
    // A new state property, which tracks value from the search box
    queryString: "",
    response: null
  };
  componentDidMount() {
    // Remove hard-coded search for "node"
    this.performQuery(this.state.queryString);
  }
  // Handles the <code>onChange</code> event every time the user types in the search box.
  updateQuery = e => {
    const queryString = e.target.value;
    this.setState(
      {
        queryString // Save the user entered query string
      },
      () => {
        this.performQuery(queryString); // Trigger a new search
      }
    );
  };
  performQuery = queryString => {
    client.search(queryString, {}).then(
      response => {
        this.setState({
          response
        });
      },
      error => {
        console.log(`error: ${error}`);
      }
    );
  };
  render() {
    const {response, queryString} = this.state;
    if (!response) return null;
    return (
      <div className="App">
        <header className="App-header">
          <h1 className="App-title">Node Module Search</h1>
        </header>
        {/* A search box, connected to our query string value and onChange
         handler */}
        <input
          className="App-search-box"
          type="text"
          placeholder="Enter a search term here"
          value={queryString}
          onChange={this.updateQuery}
        />
        <h2>{response.info.meta.page.total_results} Results</h2>
        {response.results.map(result => (
          <div key={result.getRaw("id")}>
            <p>Name: {result.getRaw("name")}</p>
            <p>Description: {result.getRaw("description")}</p>
          </div>
        ))}
      </div>
    );
  }
}
// ... Truncated!

Debounce!

Within this iteration, each time a change is detected within the box a search will occur — that might get intensive on our systems. To remedy this we will apply a _debounce_ function, courtesy of Lodash.

$ npm install --save lodash

Debounce is a method of rate-limiting the number of inbound requests based on a defined number of milliseconds. A user is thinking about how to phrase their query, making typos, or typing very fast... and so, we do not need to query on every detected change.

By wrapping our performQuery method within a debounce function from Lodash, we can specify a 200ms rate-limit — 200ms must elapse with no input before the next search query begins:

// App.js
// ... Truncated!
import { debounce } from "lodash"; // Import debounce
// ... Truncated!
performQuery = debounce(queryString => {
  client.search(queryString, {}).then(
    response => {
      this.setState({
        response
      });
    },
    error => {
      console.log(`error: ${error}`);
    }
  );
}, 200); // 200 milliseconds.
// ... Truncated!

Apart from giving our servers a break, rate-limiting can help smooth out user queries. It goes a long way! Feel is important.

Next Up…

This is the beginning of a quality, React-ified search experience. Going forward, there are many great things to add. We could add styling or implement dynamic App Search features like Facets, Curations, or Relevance Tuning. Or, we could explore the Analytics API Suite to unearth valuable insights into user search activity.

If we wanted to get really deep, the README within the master branch extends the tutorial to craft URI-based state management, add slick styling, pagination, filtering, and faceted search. With a few stylistic customizations, it could become the foundation for a high-quality search experience.

The fully developed sample is live here: http://packagehunt.swiftype.info/.

package_search.png

Summary

In the past, constructing relevant and rewarding appication search has been complicated. Elastic App Search is an expedient, managed way to write valuable, tunable search into web applications. The best part? Both engineers and less-technical stakeholders can manage key features from within a slick and intuitive dashboard. You can jump right into App Search with a 14-day free trial — no credit card required.