Making a Single Page Search with Hotwire
Tags: ruby, rails, hotwire
Published: 2022-07-13 08:57:30

> Cover photo by <a href="https://unsplash.com/@timmeyer?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Tim Meyer</a> on <a href="https://unsplash.com/s/photos/turbo?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>

[Turbo][turbo] (part of the [Hotwire][hotwire] suite) 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 of the end result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fmdto5sq70828h5sy2fr.gif) 

## Starting Point

The starting point is heavily based on '[Using Hotwire with Rails for a SPA like experience][inspiration]' by Mike Wilson. If you want to follow along with this post you can use the starting point [here on Github][starting-point] 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 of starting point](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xcgq4cwltoo3up1359hh.png) 

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

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

## Adding Search

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

```ruby
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.

```erb
<%= 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][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.

```erb
<%= 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][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.

```js
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][result].

[turbo]: https://turbo.hotwire.dev
[hotwire]: https://hotwire.dev
[stimulus]: https://stimulus.hotwire.dev
[inspiration]: https://www.mikewilson.dev/posts/using-hotwire-with-rails-for-a-spa-like-experience
[starting-point]: https://github.com/tony-rowan/turbo-movies/tree/single-page-search-start
[result]: https://github.com/tony-rowan/turbo-movies
[js-dom-events]: https://www.w3schools.com/js/js_htmldom_events.asp