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.
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.
Adding Search
The first step is to add search support to the controller.
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.
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.
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.
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.