4 December 2021

Making a Single Page Search with Turbo

ruby rails hotwire

Turbo (part of Hotwire) is a supercharged version of Turbolinks. If you’re used to immediately turning off Turbolinks in any new project, you might be surprised to learn that you don’t need React or Vue to build a rich and interactive web app.

What We’re Building

We’re going to be adding a single page search to a simple Single Page App (SPA) that allows users to view information about some Movies.

demo

Starting Point

The starting point is heavily based on ‘Using Hotwire with Rails for a SPA like experience’ by Mike Wilson. If you want to follow along with this post you can use the starting point here on Github or follow along through that post. You should have a simple app where you can select a movie without reloading the page, with only a very simple rails controller.

demo

class MoviesController < ApplicationController
  def index
    @movies = Movie.all
  end

  def show
    @movie = Movie.find(params[:id])
  end
end

The first step is to add search support to the controller.

class MoviesController < ApplicationController
  def index
    @movies = movies
    @query = query
  end

  def show
    @movie = Movie.find(params[:id])
  end

  private

  def movies
    if query
      Movie.where("title ILIKE ?", "%#{query}%")
    else
      Movie.all
    end
  end

  def query
    params[:query]
  end
end

We can verify this is working by simply adding the query parameter to the URL and visiting the page again. Only the movies matching the query you made are present in the list.

Not very user friendly though. Let’s add a search form to the top of the list of movies, within the turbo-frame-tag named :index - the same turbo-frame with the list of movies.

<%= form_with(url: movies_path, method: :get) do |f| %>
  <%= f.text_field(:query, value: @query, placeholder: "Search") %>
<% end %>

Now when you make a search and hit enter, the list of movies is filtered. You’ll also notice that, if you have a movie selected, it stays selected. Searching doesn’t cause a full page refresh, only the list of movies updates.

This happens because each turbo-frame creates its own navigational context. By default all links and form submissions from within a given turbo-frame only effect that turbo-frame. Under the hood, turbo is hijacking the submission event, making an AJAX call for the form submission and interpreting the result. When the submission is successful turbo reads the response, looking for a turbo-frame-tag that has the same name as the turbo frame that caused the submission. If it finds a match, it swaps out the contents.

In our case, the form submission returns the same page so it is pretty clear that there will be a matching turbo-frame - the :index frame.

I said this was the default behaviour. By adding a turbo-target attribute to a form or link turbo will instead look for, and swap the contents in, the turbo frame with the name you passed - this is how clicking the movies changes the :details turbo frame and not the :index frame.

Come Alive with Stimulus

This is pretty good, but what if the user didn’t have to press enter when they were done searching? We can search as the user types and we can do this with very little Javascript thanks to Stimulus - another part of the Hotwire suite.

We can watch for input changes on the text field of the search form and call an action on a Stimulus controller to submit the form for the user. This can be achieved with a small amount of markup on the form and the text field.

<%= form_with(
  url: movies_path,
  method: :get,
  data: {
    controller: "submit-form",
    submit_form_target: "form",
  }
) do |f| %>
  <%= f.text_field(
    :query,
    value: @query,
    placeholder: "Search",
    data: {
      action: "input->submit-form#submit"
    }
  ) %>
<% end %>

On the text field we’ve added data-action="input->submit-form#submit". The value assigned to the data-action attribute encodes which event to listen for and which controller and action (method) to invoke when the event happens. The generic markup is event->controller-name#action. In our example,event is input, controller is submit-form and our action is submit.

The input event name comes from the JS DOM events, and we want to listen to any changes in the input. The controller name submit-form is just the name we gave the controller we want to invoke here - it is expected to be defined in a file named submit_form_controller.js. Its name in Javascript land will then be SubmitFormController. The submit action is just a public method defined on that controller.

On the form element we’ve added data-controller="submit-form" and data-submit-form-target="form". The former simply tells Stimulus to attach a new instance of the SubmitFormController to the form element, allowing us to invoke its actions from the form element or any of its children. The latter attribute makes the form element available from the controller as a ‘target’ called form - from the controller we will be able to access the element by calling this.formTarget.

The SubmitFormController is very simple.

import { Controller } from "stimulus";

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

  submit() {
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      this._submit();
    }, 300);
  }

  _submit() {
    this.formTarget.requestSubmit();
  }
}

When the submit action is called it submits the target, which is assumed to be a form. The action itself is debounced. This stops us from generating too many simultaneous requests which makes the submissions overlap.

The this.formTarget.requestSubmit(); is very important, if we used submit() rather than requestSubmit(), the event that Turbo hooks into the intercept the form submission would not happen and you would have a normal form submission - with a full page refresh.

A Small Problem

This works really well - until the first time the form is submitted - then the focus in the text field is lost and we can’t keep typing. That’s definitely a bug!

What’s happening here is that when the response is returned, we’re replacing the whole contents of the :index turbo frame - including the search form itself. To avoid this we can move the search form outside of the turbo frame, and then add turbo-target: :index to the form element. This will mean when the form is submitted, turbo will replace the contents of the :index turbo-frame and leave the search field completely untouched.

Conclusion

And there we have it. We’ve added a single page search to an app and all we needed was a small amount of markup and very small amount of Javascript. That’s the power of Turbo.

The resulting code can be found on Github.