Autocomplete Widget with React

This project will guide you through building an autocomplete function similar to the one that you might see in Slack (a popular messaging app), as shown in figure 1, when you type something in the search box. For simplicity, our widget will work with room names (the rooms in a chat application).

Figure 1
Figure 1

The autocomplete widget will have (figure 2):

  1. An input field
  2. A list of options filtered according to the entered characters
  3. An Add button (figure 3)
Figure 2
Figure 2

The filtering of the matches will be done using the entered characters as the first characters of the option. In other words, there is a simple comparison that allows us to autocomplete the name of the room (figure X). For example, if you type “mac” and you have “Mac OS X” and “Apple Mac,” then only “Mac OS X” will be shown as a match, not both options.

NOTE : The options will be stored in MongoDB using the native MongoDB Node.js driver. For the web server, I’m using Express.js.

The Add button will be shown only when there are no matches (figure 3).

Figure 3
Figure 3

The new option will be saved to the database via an XHR call to our REST API. We can use this new room name in the future (figure 4), just like our initial room names.

Figure 4
Figure 4

To get started with the project, create a new folder. The project structure looks like this:

/autocomplete
  /__tests__
    autocomplete.test.js
  /node_modules
  /public
    /css
      bootstrap.css
    /js
      app.js
  /src
    /build
      app.js
      autocomplete.js
    app.jsx
    autocomplete.jsx
  /views
    index.handlebars
  gulpfile.js
  index.js
  package.json
  README.md
  rooms.json

Let’s cover them one by one. __tests__ is the folder with the Jest tests. By now familiar to you, the node_modules folder is the Node.js dependencies folder (from npm’s package.json). Then there are public, public/css, and public/js folders, which contain the static files for our application. The app.js file will be bundled by Gulp and Browserify from the dependencies and the JSX source code. The source code itself is in the src folder. I created src/build for the files compiled from JSX into native JavaScript.

views is just a folder for Handlebars templates. If you feel confident about your React skills by now, you don’t have to use a template engine; you can use React as the Node.js template engine!

In the root of the project, you will find gulpfile.js, which enables build tasks, package.json, which contains project metadata, rooms.json, which contains the MongoDB seed data, and index.js, with the Express.js server and its routes for the API server (GET and POST /rooms).

This project’s structure is somewhat similar to ch8/board-react2, and we’ll be rendering React components on the server, testing them with Jest, and making AJAX/XHR requests with request within the Reflux data store.

Start by copying (it’s better to copy by typing instead of copying and pasting) this package.json:

{
  "name": "autocomplete",
  "version": "1.0.0",
  "description": "React.js autocomplete component with Express.js and MongoDB example.",
  "main": "index.js",
  "scripts": {
    "test": "jest",
    "start": "gulp",
    "seed": "mongoimport rooms.json --jsonArray --collection=rooms --db=autocomplete"
  },
  "keywords": [
    "react.js",
    "express.js",
    "mongodb"
  ],
  "author": "Azat Mardan",
  "license": "MIT",
  "dependencies": {
    "body-parser": "^1.13.2",
    "compression": "^1.5.1",
    "errorhandler": "^1.4.1",
    "express": "^4.13.1",
    "express-handlebars": "^2.0.1",
    "express-validator": "^2.13.0",
    "mongodb": "^2.0.36",
    "morgan": "^1.6.1",
    "react": "^0.14.0",
    "react-dom": "^0.14.0",
    "reflux": "^0.3.0",
    "request": "^2.65.0"
  },
  "devDependencies": {
    "jest-cli": "0.5.10",
    "react-addons-test-utils": "0.14.0",
    "gulp": "^3.9.0",
    "gulp-browserify": "^0.5.1",
    "gulp-develop-server": "^0.4.3",
    "gulp-nodemon": "^2.0.3",
    "gulp-react": "^3.0.1",
    "gulp-watch": "^4.2.4"
  }
}

The interesting thing here is scripts:

  "scripts": {
    "test": "jest",
    "start": "gulp",
    "seed": "mongoimport rooms.json --jsonArray --collection=rooms --db=autocomplete"
  },

The test is for running Jest tests, and start is for building and launching our server.
I also added seed data for the room names, which you can run with $ npm run seed.
The database name is autocomplete, and the collection name is rooms. This is the content of the rooms.json file:

[ {"name": "react"},
  {"name": "node"},
  {"name": "angular"},
  {"name": "backbone"}]

Once you run the seed command, it will print this (MongoDB must be running as a separate process):

2015-10-19T15:26:39.632-0700    connected to: localhost
2015-10-19T15:26:39.793-0700    imported 4 documents

In this project, we’ll be using npm modules for the dependencies like React, request, and React DOM. This is possible due to Browserify. Here’s the gulpfile.js file, which has a scripts task in which Browserify parses src/build/app.js and includes all the dependencies in public/js/app.js:

var gulp = require('gulp'),
  react = require('gulp-react'),
  watch = require('gulp-watch'),
  nodemon = require('gulp-nodemon'),
  browserify = require('gulp-browserify')

gulp.task('build', function (done) {
  return gulp.src('./src/*.jsx')
    .pipe(react())
    .pipe(gulp.dest('src/build'))
})

gulp.task('scripts', ['build'], function() {
  gulp.src('./src/build/app.js')
    .pipe(browserify({
      insertGlobals : true,
      debug: true
    }))
    .pipe(gulp.dest('./public/js'))
})

gulp.task('watch', ['build', 'scripts'], function(done){
    gulp.watch('src/*.jsx', ['build','scripts'] )
})

gulp.task('nodemon', ['build', 'scripts'], function(done){
  nodemon({ script: 'index.js',
    ext: 'html js' })
})

gulp.task('default', ['build', 'scripts', 'watch', 'nodemon'])

For app.js to exist in the src/build folder, there is a build task that precedes the scripts task. The scripts task compiles all the JSX files into native JS files.

Lastly in gulpfile.js, the nodemon and watch tasks are for convenience. They’ll restart the Node.js server on a file change and rebuild JS files on a JSX file change, respectively.

An important part of the index.js file is the way we include the libraries and components:

  ReactDOM = require('react-dom'),
  ReactDOMServer = require('react-dom/server'),
  React = require('react'),
  Autocomplete  = React.createFactory(require('./src/build/autocomplete.js'))

The index.js file has GET and POST routes for /rooms. If you’re not familiar with Express.js, there’s a quick-start guide in the cheatsheet. This post is on React, not Express. :) We’ll only cover the / route in Express. In it, we render React on the server by hydrating components with the room objects:

  app.get('/', function(req, res, next) {
    var url = 'http://localhost:3000/rooms'
    req.rooms.find({}, {sort: {_id: -1}}).toArray(function(err, rooms){
      if (err) return next(err)
      res.render('index', {
        autocomplete: ReactDOMServer.renderToString(Autocomplete({
          options: rooms,
          url: url
        })),
        props: '<script type="text/javascript">var rooms = ' + JSON.stringify(rooms) + ', url = "' + url + '"</script>'
      })
    })
  })

This is the same approach that you saw in the previous example, ch8/board-react2: the props are in the scripts tag.

There are two props to the Autocomplete component: options and url. The options are the names of the rooms for the chat and the url is the URL of the API server (http://localhost:3000/rooms in our case).

According to the principles of TDD/BDD, let’s start with tests. In the __tests__/autocomplete.test.js file we have:

jest.autoMockOff()

The rooms variable is just hardcoded data for the room names:

var rooms = [
    { "_id" : "5622eb1f105807ceb6ad868b", "name" : "node" },
    { "_id" : "5622eb1f105807ceb6ad868c", "name" : "react" },
    { "_id" : "5622eb1f105807ceb6ad868d", "name" : "backbone" },
    { "_id" : "5622eb1f105807ceb6ad868e", "name" : "angular" }
  ]

Then we include the libraries. They are npm modules, except for src/build/autocomplete.js, which is a file:

var TestUtils = require('react-addons-test-utils'),
  React = require('react'),
  ReactDOM = require('react-dom'),
  Autocomplete = require('../src/build/autocomplete.js'),

The fD object is just a convenience (less typing means fewer errors):

  fD = ReactDOM.findDOMNode

The next line is using TestUtils from react-addons-test-utils to render the Autocomplete component:

var autocomplete = TestUtils.renderIntoDocument(
  React.createElement(Autocomplete, {
    options: rooms,
    url: 'test'
  })
)

Now we get the input field, which will have a class option-name. These will be our options:

var optionName = TestUtils.findRenderedDOMComponentWithClass(autocomplete, 'option-name')

Then, we can write the actual tests:

describe('Autocomplete', function() {

We can get all the option-name elements from the widgets and compare them against the number 4, which is the number of rooms in the rooms array:

  it('have four initial options', function(){
    var options = TestUtils.scryRenderedDOMComponentsWithClass(autocomplete, 'option-list-item')
    expect(options.length).toBe(4)
  })

The next test changes the input field value and then checks for that value and the number of the offered autocomplete option. There should be only a single match, which is react:

  it('change options based on the input', function(){
    expect(fD(optionName).value).toBe('')
    fD(optionName).value = 'r'
    TestUtils.Simulate.change(fD(optionName))
    expect(fD(optionName).value).toBe('r')
    options = TestUtils.scryRenderedDOMComponentsWithClass(autocomplete, 'option-list-item')
    expect(options.length).toBe(1)
    expect(fD(options[0]).textContent).toBe('#react')
  })

The last test will change the room name field to ember, and there should be no matches, only the Add button:

  it('offer to save option when there are no matches', function(){
    fD(optionName).value = 'ember'
    TestUtils.Simulate.change(fD(optionName))
    options = TestUtils.scryRenderedDOMComponentsWithClass(autocomplete, 'option-list-item')
    expect(options.length).toBe(0)
    var optionAdd = TestUtils.findRenderedDOMComponentWithClass(autocomplete, 'option-add')
    expect(fD(optionAdd).textContent).toBe('Add #ember')

  })
})

The tests should fail for now, but that’s okay. Onward to implementing our browser script, which is src/app.jsx:

React = require('react')
ReactDOM = require('react-dom')
request = require('request')

Autocomplete = require('./autocomplete.js')

ReactDOM.render(<Autocomplete options={rooms} url={url}/>, document.getElementById('autocomplete'))

The global vars rooms and url will be provided via the props local from the Express.js tag (the script HTML tag). In the index.handlebars file, you can see the props and autocomplete locals being output:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Autocomplete with React.js</title>
    <meta name="description" content="" />
    <meta name="author" content="" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link type="text/css" rel="stylesheet" href="/css/bootstrap.css" />
  </head>

  <body>
  <div class="container-fluid">
    <div>{{{props}}}</div>
    <div class="row-fluid">
      <div class="span12">
        <div id="content">
          <div class="row-fluid"  id="autocomplete" />{{{autocomplete}}}</div>
      </div>
    </div>
  </div>
  <script type="text/javascript" src="/js/app.js"></script>
  </body>
</html>

Finally, the autocomplete.jsx file with our component. We start by importing the libraries in the CommonJS/Node.js style (thanks to Browserify, this will be bundled for the browser’s consumption):

var React = require('react'),
  ReactDOM = require('react-dom'),
  request = require('request'),
  Reflux = require('reflux')

Again, this alias is for convenience:

var fD = ReactDOM.findDOMNode

We’ll be using Reflux. These are the actions for our data store:

var Actions = Reflux.createActions([
  'loadOptions',
  'addOption',
  'setUrl',
  'setOptions'
])

Let’s create the store and set up the actions. We can use the listenables property:

var optionsStore = Reflux.createStore({
    listenables: [Actions],

onSetUrl will set the REST API server URL to perform AJAX/XHR requests:

    onSetUrl: function(url){
      this.url = url
    },

onSetOptions will create a property called options. This will be all the available options (i.e., unfiltered):

    onSetOptions: function(options){
      this.options = options
    },

In onLoadOptions, we perform the GET request using the request library. It’s similar to jQuery’s $.get:

    onLoadOptions: function(options) {
      this.options = options
      request({url: this.url},function(error, response, body) {
        if(error || !body){
          return console.error('Failed to load')
        }
        body = JSON.parse(body)

Once we get the options, we assign them to this.options and trigger the callback, which is in the component that listens to the loadOptions event:

        this.options = body
        this.trigger(body)
      }.bind(this))
    },

The onAddOptions method performs a POST request and puts the newly created record into the this.options array:

    onAddOptions: function(option, callback){
      request({url: this.url, method: 'POST', json: {name: option}}, function(error, response, body) {
        if(error || !body){
          return console.error('Failed to save')
        }
        this.options.unshift(body)
        callback(body)
        this.trigger(this.options)
      }.bind(this))
    }
})

We’re using CommonJS syntax, so we can declare the Autocomplete component and export it like this:

module.exports = React.createClass({

The next line enables the auto-syncing of the optionsStore’s options with our state options:

  mixins: [Reflux.connect(optionsStore,'options')],

In the initial state function, we set the URL and options from props. The filtered options will be the same as all of the options, and the current option (input field value) is empty:

  getInitialState: function(){
    Actions.setUrl(this.props.url)
    Actions.setOptions(this.props.options)
    return {options: this.props.options,
      filteredOptions: this.props.options,
      currentOption: ''
    }
  },

When the component is about to be mounted, we load the options from the server by invoking the optionsStore action:

  componentWillMount: function(){
    Actions.loadOptions(this.props.options)
  },

The filter method will be called on every change of the <input> field. The goal is to leave only the options that match user input:

  filter: function(e){
    this.setState({
      currentOption: e.target.value,
      filteredOptions: (this.state.options.filter(function(option, index, list){
        return (e.target.value === option.name.substr(0, e.target.value.length))
      }))
    }, function(){
    })
  },

As for addOption, this method handles the addition of a new option (in the event that there are no matches) by invoking the store’s action:

  addOption: function(e){
    var currentOption = this.state.currentOption
    Actions.addOption(this.state.currentOption, function(){

There is a callback in the action that will ensure that the list of options is updated once the new value is part of the list:

      this.filter({target: {value: currentOption}})
    }.bind(this))
  },

Finally, the render method has a controlled component, <input>, with an onChange event listener, this.filter:

  render: function(){
    return (
      <div className="form-group">
        <input type="text" className="form-control option-name" onChange={this.filter} value={this.currentOption} placeholder="React.js"></input>

The list of filtered options is powered by the state filteredOptions, which is updated in the filter method. We simply iterate over it and print _id as keys and links with option.name:

        {this.state.filteredOptions.map(function(option, index, list){
          return <div key={option._id}><a className="btn btn-default option-list-item" href={'/#/'+option.name} target="_blank">#{option.name}</a></div>
        })}

The last element is the Add button, which is shown only when there is no filteredOptions (no matches):

        {function(){if (this.state.filteredOptions.length == 0 && this.state.currentOption!='')
          return <a className="btn btn-info option-add" onClick={this.addOption}>Add #{this.state.currentOption}</a>
        }.bind(this)()}
      </div>
    )
  }
})

If you’ve followed all the steps, you should be able to install the dependencies with

$ npm install

and then launch the app with this command (you must have started MongoDB first with $ mongod):

$ npm start

The tests will pass after you run the command:

$ npm test

Optionally, you can seed the database with $ npm run seed.

If for some reason your project is not working, the full tested source code is in ch8/autocomplete and on GitHub.

Author: Azat

Techies, entrepreneur, 20+ years in tech/IT/software/web development expert: NodeJS, JavaScript, MongoDB, Ruby on Rails, PHP, SQL, HTML, CSS. 500 Startups (batch Fall 2011) alumnus. http://azat.co http://github.com/azat-co

One thought on “Autocomplete Widget with React”

  1. Love the topic, but I got lost in the text. I think this might be better as a video tutorial.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.