Autocomplete in Ruby on Rails using Stimulus

Use Stimulus to do autocomplete in your applications. This article will show you how to use it for subreddit names from Reddit.


Mon Aug 10 2020 - 9 min read
ruby-on-railsstimulus

We are going to build a simple application in Ruby on Rails to autocomplete Reddit subreddits.  If you want to view the completed application first then it is available on here.  The code is available on Github.

The finished application will look like this:

We will be using Stimulus for this example. Stimulus is a simple Javascript framework that uses the HTML in the application. It is not a virtual DOM framework like React or Vue. I think it works very well when you need something more advanced than say Alpine.js but don't want to move to React or Vue. It works very well with Turbolinks as well so its a great fit for many applications.

Generate a new Rails application using webpacker. Make sure you have Rails 6 installed(rails -v). As this application will not use a database then we can skip it as well.

rails new autocomplete_stimulus --webpack --skip-active-record

Install Stimulus:

yarn add stimulus

We also need autocomplete.js and axios:

yarn add autocomplete.js axios

There are many libraries out there for doing simple autocomplete and typeahead stuff. I am a big fan of autocomplete.js by Algolia. You don't need to use Algolia to use autocomplete.js however and it comes with all the bells and whistles like debounce support.

Add the following to app/javascript/packs/application.js to get Stimulus going. Make sure that you don't delete any of the other content.

import { Application } from "stimulus";
import { definitionsFromContext } from "stimulus/webpack-helpers";

const application = Application.start();
const context = require.context("../stimulus/controllers", true, /\.js$/);
application.load(definitionsFromContext(context));

Go into config/routes.rb and make sure it looks something like this:

Rails.application.routes.draw do
  resource :home, only: [:show]

  root to: 'home#show'
end

We will be using home#show for our main page so create app/controllers/home_controller.rb and add the following code to it:

class HomeController < ApplicationController
  def show; end
end

Finally we'll need a template for the HTML. Create app/views/home/show.html.erb and add the following:

<div style="margin: auto; width: 25%; padding: 10px">
  <h1>Autocomplete</h1>
  <div data-controller="autocomplete">
    <input data-target="autocomplete.field" />
  </div>
</div>

Here you can see Stimulus in action. The data-controller is where Stimulus will start working. The value of the data attribute is important as that corresponds to a controller named autocomplete_controller. Let's create it now! Create the directory tree and file structure like so: app/javascript/stimulus/controllers/autocomplete_controller.js

Mind that the naming of the controller is very important in Stimulus so make sure you get it right.

Lets start with some boilerplate code in it:

import { Controller } from "stimulus";

export default class extends Controller {
  static targets = ["field"];
}

The targets array contains all the targets of this Stimulus controller. As you can see it is what we added to the input tag in the template. Now we need to setup the Autocomplete part in the controller. The controller should now look something like this:

import { Controller } from "stimulus";
import autocomplete from "autocomplete.js";

export default class extends Controller {
  static targets = ["field"];

  connect() {
    this.ac = autocomplete(this.fieldTarget, { hint: false }, [
      {
        source: this.search(),
        debounce: 200,
        templates: {
          suggestion: function (suggestion) {
            return suggestion.name;
          },
        },
      },
    ])
  }
}

The connect method is run whenever the controller is hooked up to the view. To access the field target we use this.fieldTarget. Mind that this.search() doesn't exist yet. Lets create it:

import { Controller } from "stimulus";
import autocomplete from "autocomplete.js";

export default class extends Controller {
  static targets = ["field"];

  search(query, callback) {
    callback([{ name: "Hello" }])
  }

  connect() {
    this.ac = autocomplete(this.fieldTarget, { hint: false }, [
      {
        source: this.search,
        debounce: 200,
        templates: {
          suggestion: function (suggestion) {
            return suggestion.name;
          },
        },
      },
    ])
  }
}

Mind that we also imported Axios to perform HTTP requests. source expects a function so the search-function will return a function.

We are now ready to give this a try. Fire up the Rails server with rails server

If you go to localhost:3000 you should now see something like this:

If you type something in the input field you should see "Hello" as a suggestion. Stimulus is now working. Excellent!

Now lets move on to actually wiring this up with the backend.

Go to config/routes.rb and make it look like this:

Rails.application.routes.draw do
  resource :home, only: [:show]
  resource :autocomplete, only: [:show]

  root to: 'home#show'
end

We now need to create a controller for the route. Create the file app/controllers/autocompletes_controller.rb and add the following to it:

class AutocompletesController < ApplicationController
  def show
    data = [
      { name: 'Foo' },
      { name: 'Bar' }
    ]

    render json: data
  end
end

We now have a placeholder controller. Now all we have to do is add this to our Stimulus controller to make it request the autocomplete from the server. Change your Stimulus controller to look like this:

import { Controller } from "stimulus";
import axios from "axios";
import autocomplete from "autocomplete.js";

export default class extends Controller {
  static targets = ["field"];

  search(query, callback) {
    axios.get("/autocomplete", { params: { query } }).then((response) => {
      callback(response.data);
    });
  }

  connect() {
    this.ac = autocomplete(this.fieldTarget, { hint: false }, [
      {
        source: this.search,
        debounce: 200,
        templates: {
          suggestion: function (suggestion) {
            return suggestion.name;
          },
        },
      },
    ]).on("autocomplete:selected", (event, suggestion, dataset, context) => {
      this.ac.autocomplete.setVal(suggestion.name);
    });
  }
}

We have imported axios and changed the search function to perform requests instead of having placeholder data.

If you try this in the browser it should now always return "Foo" and "Bar". Now lets do it for real and autocomplete subreddit names shall we. I like to use the HTTP gem for http requests in my Rails application so install that by adding the following to your Gemfile:

gem "http"

Don't forget to restart your rails server after adding the gem.

Change your AutocompleteController to perform requests to Reddit:

class AutocompletesController < ApplicationController
  def show
    response = JSON.parse(HTTP.get("https://www.reddit.com/subreddits/search.json?q=#{params[:query]}&limit=10"))
    subreddits = response["data"]["children"].map do |subreddit|
      {
        name: subreddit["data"].fetch("display_name").downcase,
        title: subreddit["data"].fetch("title"),
        description: subreddit["data"].fetch("public_description").truncate(100)
      }
    end
    render json: subreddits
  end
end

This should be it. If you try this in your browser it should look something like this:

It works! Looks real bad though. Lets add some styling to fix it.

Create app/assets/stylesheets/style.scss and add the following to it:

.algolia-autocomplete {
  width: 100%;
}
.algolia-autocomplete .aa-input,
.algolia-autocomplete .aa-hint {
  width: 100%;
}
.algolia-autocomplete .aa-hint {
  color: #999;
}
.algolia-autocomplete .aa-dropdown-menu {
  width: 100%;
  background-color: #fff;
  border: 1px solid #999;
  border-top: none;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion {
  cursor: pointer;
  padding: 5px 4px;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor {
  background-color: #b2d7ff;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion em {
  font-weight: bold;
  font-style: normal;
}

Now try again!

A bit better! Of course, this still looks bad and will need some more love to be used for real.

As an added bonus you can easily change the Stimulus controller so that it gets its API URL from a data attribute instead of having it hard coded in the controller itself. This way you can reuse the Stimulus controller for autocompletes all over the place!

All it takes is to change the HTML to this:

<div style="margin: auto; width: 25%; padding: 10px">
  <h1>Autocomplete</h1>
  <div data-controller="autocomplete" data-autocomplete-url="/autocomplete">
    <input data-target="autocomplete.field" />
  </div>
</div>

Note that data attribute that holds the URL. It is also a Stimulus thing so make note of it. Now change the Stimulus controller like so:

import { Controller } from "stimulus";
import axios from "axios";
import autocomplete from "autocomplete.js";

export default class extends Controller {
  static targets = ["field"];

  source() {
    const url = this.data.get("url");
    return (query, callback) => {
      axios.get(url, { params: { query } }).then((response) => {
        callback(response.data);
      });
    };
  }

  connect() {
    this.ac = autocomplete(this.fieldTarget, { hint: false }, [
      {
        source: this.source(),
        debounce: 200,
        templates: {
          suggestion: function (suggestion) {
            return suggestion.name;
          },
        },
      },
    ]).on("autocomplete:selected", (event, suggestion, dataset, context) => {
      this.ac.autocomplete.setVal(suggestion.name);
    });
  }
}

But what if you want to have some other rendering of the suggestions? Perhaps you would like to add some images or a nice description or something? There are a number of ways to do this. For example, you can use a simple Javascript template string with some HTML in the Stimulus controller and return that in the suggestion function. This works but its real dirty to deal with it once the string gets complicated. You could also add some kind of Javascript template system like Mustache in separate files and import those in your controller using a metaprogrammed data attribute. I prefer to keep the suggestion next to the rest of the autocomplete form, so I will show you a way to do it using Mustache and script tags. Mind that you don't really need Mustache for this but it makes things a bit cleaner.

Start by installing Mustache:

yarn add mustache

Now, change your HTML to this:

<div style="margin: auto; width: 25%; padding: 10px">
  <h1>Autocomplete</h1>
  <div data-controller="autocomplete" data-autocomplete-url="/autocomplete">
    <script data-target="autocomplete.suggestionTemplate" type="text/template">
      <h2>{{name}}</h2>
      <p>{{description}}
    </script>
    <input data-target="autocomplete.field" />
  </div>
</div>

Note the new script tag. The browser will not render that so it's safe to use. The curly braces are Mustache tags which will correspond to the JSON data from our autocomplete controller on the Ruby side. We also added a new target data attribute to be able to access it from Stimulus. Lets do that now:

import { Controller } from "stimulus";
import axios from "axios";
import autocomplete from "autocomplete.js";
import Mustache from "mustache";

export default class extends Controller {
  static targets = ["field", "suggestionTemplate"];

  source() {
    const url = this.data.get("url");
    return (query, callback) => {
      axios.get(url, { params: { query } }).then((response) => {
        callback(response.data);
      });
    };
  }

  connect() {
    const suggestionTemplate = this.suggestionTemplateTarget.innerHTML;

    this.ac = autocomplete(this.fieldTarget, { hint: false }, [
      {
        source: this.source(),
        debounce: 200,
        templates: {
          suggestion: (suggestion) => {
            return Mustache.render(suggestionTemplate, suggestion);
          },
        },
      },
    ]).on("autocomplete:selected", (event, suggestion, dataset, context) => {
      this.ac.autocomplete.setVal(suggestion.name);
    });
  }
}

We've added Mustache, the new target and a new way to render suggestions using our new template. It will look something like this:

Ok that looks terrible but you get the idea. Do note that you can still reuse the Stimulus controller for other autocomplete fields. Simply add a new template and URL!

There you have it! A simple way to add a reusable Stimulus component to your Rails applications. The complete application application is available on Heroku and the code is available on Github.