Dieser Blogpost ist auch auf Deutsch verfügbar

Hotwire is a new collection of tools that make it easier to achieve good results with this strategy. For “classic” web applications, it provides us with a series of best practices that make a comprehensive SPA framework superfluous.

“We have a backend with REST-based JSON interfaces and then an Angular application on top”

For many of us, a description like this has become a commonplace when referring to any web application built in recent years. The reasons for this separation often tend towards “that’s how you do it now” or “that’s what our team has experience with”. It is rare for a requirement to necessitate the use of a common SPA framework. [Elsewhere], (https://www.innoq.com/de/articles/2019/04/wider-die-spa-fixierung/) we have already discussed in detail what disadvantages this “modern” division entails.

At the end of last year, the Basecamp team introduced the Hotwire Toolkit. Based on their experiences of building Basecamp and Hey, they published a very manageable quantity of JavaScript modules as open source, to produce the behavior of an SPA without forgoing a solid SSR foundation.

You can find all of the coding examples that I use in this article in our GitHub repository. That is where we show you a working integration of Hotwire in a plain vanilla Spring boot application.

Batteries included

Hotwire is synonymous with the entire approach: sending HTML Over the Wire, using native Web formats to make the application as swift and reactive as possible for users, and as intuitive and consistent as possible for developers.

Three modules are in place to help Hotwire reach this target vision:

  • Turbo – replaces navigation in classic web applications to enable behavior analogous to SPAs: page transitions without reloads, splitting pages into components, updates in other parts of a page, and streaming updates. These 89kB of JavaScript deal with the heavy lifting and replace a large part of the tasks otherwise performed by Angular, React, Vue and the like.
  • Stimulus – uses ‘data’ attributes in HTML markup to permit the generation of controllers that provide the functionality of a custom element – without actually building a custom element. Controllers of this type also deal with the management of state and data.
  • Strada – will provide a method for integrating native code and WebViews in mobile apps. Strada has been announced but no code has been published yet.

The important point to note is that the three modules combine wonderfully with one another, but are not mutual prerequisites.

We will look at what each module provides in more detail in the following sections.

Turbo

As mentioned above, Turbo provides a whole raft of functions that build on one another. And although our Example Repository shows the integration in Spring, the majority of the functionality is completely independent of the framework, as it is only achieved through corresponding attributes in HTML.

SPA-style navigation with Turbo Drive

Seamless navigation, as seen in SPAs, is the easiest to implement: no reload should be visible for the user and the transition between pages remains seamless.

import * as turbo from "@hotwired/turbo"

console.log('Hotwire Demo App JS enabled');
Integration of Turbo

is all that is required – the import loads Turbo and starts a session (alternatively, you can include Turbo from a CDN like unpkg or skypack).

Turbo then takes over all link clicks and form submissions that remain within the same origin (i.e. point to the same protocol/host name/port combination). These are executed in the background as XHR and the browser history is adjusted (to ensure that Back and Forward function correctly). When the response arrives, the page content displayed at that moment in the browser is adjusted. Elements from the ‘

’ are merged, while those in the ‘’ are replaced by the new ones. If possible, Turbo Drive already displays a version of the page from the cache, which is then updated with the current result (stale-while-revalidate), leading to the provision of an even faster result for the user.

Further ‘data’ attributes for elements on the page can be used for more detailed control of the behavior of Turbo Drive. This ensures that Turbo is working exactly as intended – normally, the default is itself very good. The handbook provides a detailed explanation of the individual options.

Subdividing a page into blocks with Turbo Frames

Although Turbo Drive moves the page update into the background, you may sometimes want to control which parts of a page an interaction refers to.

As an example, let’s take a wiki page where you can edit each individual section.

<h3>Section</h3>
<turbo-frame id="section_1">
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit</p>
  <form action="/sections/1/edit" method="post">
    <button type="submit">Edit this section</button>
  </form>
</turbo-frame>
Example Wiki Page

The section that we want to isolate on the page is wrapped in an ‘’ element, and can therefore be addressed individually. Turbo automatically ensures that interactions between these “frames” really do only refer to them. After receiving the response, only the part of the page within the ‘’ is updated (as in an SPA, but without additional change detection in the virtual DOM).

If the response to ‘POST /sections/1/edit’ also contains a ‘’ with the same ‘id’, this is just extracted from the response and replaces the ‘’ for the original page.

<body>
  <h1>Editing Section</h1>

  <turbo-frame id="section_1">
    <form action="/sections/1">
      <textarea name="content">Lorem ipsum dolor sit amet consectetur adipisicing elit</textarea>
      <button type="submit">Save</button>
    </form>
  </turbo-frame>
</body>
Response with New View

This way, our example allows us to transparently replace the section on our wiki page with an editing form.

It does not matter whether the response only contains the ‘’ fragment that triggered the request, or a complete page. However, if you always send the complete page, you’ll directly build a “progressively enhanced” version of your page, as it continues to work without JavaScript being present.

We‘d love to show you a tweet right here. To do that, we need your consent to load third party content from twitter.com

Lazy Loading with Turbo Frames

Lazy loading for page components is one of the things that you get along with Turbo Frames, “just like that”. When loading a page, you can deliver individual sections just as empty fragments with placeholders, negating the need to fetch data (which may be slow) and providing an even faster result for the user.

<article>
<h2>My Blog Post</h2>
<p>Lorem ipsum dolor sit amet.</p>
<h3>✨ these comments are loaded automagically ✨</h3>
<turbo-frame id="comments" src="/message/1/comments">
  <img src="/images/spin.gif" alt="Waiting icon">
</turbo-frame>
</article>
Automatically reloaded Turbo Frame

The “src” attribute for the ‘’ informs Turbo, that it is to trigger a request automatically and where to fetch the content from. The response is handled in the same way as a request that the user triggers: the relevant ‘’ is extracted and replaces the static markup.

Hotwire with Lazy Loading for page contents
Hotwire with Lazy Loading for page contents

Update other page areas

<h3>Comments</h3>
<turbo-frame id="comments">
  <ul>
    <li>Comment 1</li>
    <li>Comment 2</li>
    <li>Comment 3</li>
  </ul>
</turbo-frame>

<h3>✨ Remove comments ✨</h3>
<p>The following form removes comments <em>from the list above</em>.</p>
<p>Submitting it should only trigger a reload of the frame above, not the whole site</p>
<form action="/messages/comments/remove" method="post" data-turbo-frame="comments">
  <button type="submit">Remove comment</button>
</form>
Addressing other page sections

The example snippet above has another role: it triggers an HTTP request (which takes place in the background as usual), but applies the result to a different area of the page (the ‘’ with the ‘id’ ‘comments’). This is controlled by the ‘data-turbo-frame’ attribute, which informs Turbo which frame is to be addressed.

Dynamic updates with Turbo Streams

Turbo Streams is certainly the topic that has generated the most interest. Quite understandably so, as it was introduced with ‘WebSockets Live Updates for a Website’. It is indeed possible to connect WebSockets (or other Event Streams), but we will come back to that later. Firstly, we want to take a look at what Turbo Streams actually does.

Turbo Streams permits multiple parallel actions for a website to be sent in one response. To do so, it defines a specific format for the response:

<turbo-stream action="append|prepend|replace|update|remove" target="ID">
    <template>
        HTML-Content
    </template>
</turbo-stream>
Basic Format for a Turbo Stream Action

Each object is enclosed by a <turbo-stream> element that specifies the action to be executed in action. The target attribute carries the ID for the addressed element. This does not have to be a Turbo Frame or anything like that, but can be any HTML element with the corresponding id attribute (in the same way as you would use it in document.querySelector()).

Possible actions are restricted to the five specified above (append,prepend,replace,update and remove). Taking a look at the implementation reveals that these are the ones that map easily to the operations for a node in the DOM API. Once again, existing functionality of the web platform is used to prevent inventing something specifically for Turbo. (And, to be honest, these actions are sufficient for plenty of cases.)

You take the actual content that you want to insert and put it into a <template> element to allow easy handling in the DOM. During processing, this element is added to the DOM, the content is used and then the <template> element is removed again.

You can put as many of these <turbo-stream> elements as you want into your response, assign the content type text/vnd.turbo-stream.html to the lot, and that’s it, the functionality is done. Turbo checks the content type for “normal” responses (resulting from form submits, for example) and applies the Turbo Streams updates instead of the normal merging logic, if the content type for the response is text/vnd.turbo-stream.html.

Alternatively, event streams can be used to transmit these updates. The advantage here is that user interaction is not necessarily required to get the updates. Event streams also represent an open connection between browser and server, enabling continuous updates (for a chat or a live ticker, for example).

As you register a stream of this type explicitly with the JavaScript API for Turbo, it is no longer necessary to set the content type. Turbo relies on the ability to interpret individual events as Turbo Stream actions (if this is not the case, you will be informed about this on the console).

Event Stream sources can either be server-sent events or WebSockets, which are each instantiated with their specific class in the browser:

import { connectStreamSource } from "@hotwired/turbo";

this.eventSource = new EventSource("http://localhost:8080/turbo-sse");
connectStreamSource(this.eventSource);
Including a Server-Sent Event Stream
import { connectStreamSource } from "@hotwired/turbo";

this.eventSource = new WebSocket("ws://localhost:8080/stream-updates");
connectStreamSource(this.eventSource);
Including a WebSocket

Handling these connections on the Java side does not need any Turbo-specific code, they are just general SSE or WebSocket connections. The only thing that matters is that the response format corresponds to the one I’ve outlined above.

@Controller
public class TurboStreamSSEController {

    private SseEmitter emitter;

    @GetMapping(path="/turbo-sse", produces=MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter handle() {
        this.emitter = new SseEmitter();
        this.emitter.onCompletion(() ->  this.emitter = null);
        return emitter;
    }
The SSE Controller
@Component
public class TurboStreamWebsocketHandler extends TextWebSocketHandler {

    private WebSocketSession session;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        super.afterConnectionEstablished(session);
        this.session = session;
    }

    private void sendUpdate(String text) throws IOException {
        if (this.session != null) {
            WebSocketMessage<String> message = new TextMessage(text);
            this.session.sendMessage(message);
        }
    }
The WebSocket Controller

Stimulus

The concept behind Stimulus is the transformation of elements of an existing website into ‘controllers’. These can react to events that occur within their scope, enabling any number of complex interactions on an HTML page. A controller also has references to all elements that it contains and can exert control over these. This allows retroactive adjustment of the existing markup. An example of this would be the addition of an option to a text field that copies the text within it to the clipboard (including the necessary buttons).

Using Stimulus allows you to do so without writing a custom element by yourself – just by adding a couple of attributes to your existing markup. This procedure again reflects the thinking of progressive enhancement, as you must already have the basis for your elements in the normal HTML code before you can extend these with the controller.

import { Controller } from "stimulus";
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo";

export default class ConnectWebsocketController extends Controller {
    static targets = ["toggle"]
    static values = { connected: Boolean }

    eventSource = undefined;

    toggleStream() {
        if (this.connectedValue) {
            disconnectStreamSource(this.eventSource);
            this.eventSource.close();
            this.eventSource = undefined;
            this.connectedValue = false;
            this.toggleTarget.innerText = 'Attach WS Stream';
        }
        else {
            this.eventSource = new WebSocket("ws://localhost:8080/stream-updates");
            connectStreamSource(this.eventSource);
            this.connectedValue = true;
            this.toggleTarget.innerText = 'Detach WS Stream';
        }
    }
}
Example for a Controller in Stimulus

Implementation takes place in an ES6 JavaScript class, which extends the base class Controller contained in Stimulus. Here you simply write any number of functions that you need to fulfill the functionality you want your Controller to have. No Turbo speficic code is necessary.

The additional benefit of deriving from controller lies in the two static fields values and targets. Stimulus automatically creates bindings to data attributes in HTML for these, allowing them to be addressed directly in the code without your own boilerplate coding. The documentation provides details about the usage and the supported file formats.

In the same way as for custom elements, you also can receive lifecycle events. You can implement methods for initialize, connect and deconnect, and use them to perform the required setup and teardown.

If you do not use webpack as bundler, you must now provide a small piece of code to connect the controller implementation with a logical name (in the same way as for the registration of a custom element):

import { Application } from "stimulus";

import ConnectSSEController from "./ConnectSSEController.js";
import ConnectWebsocketController from "./ConnectWebsocketController.js";

const application = Application.start();
application.register('connect-websocket', ConnectWebsocketController);
application.register('connect-sse', ConnectSSEController);
console.log("Stimulus controllers registered");
Registering Controllers in Stimulus

On the website, you now complete the binding: using data-controller attributes to specify which controller is to be bound to the element (the recently registered names are used):

<div data-controller="connect-websocket" data-connect-websocket-connected-value="false">
  <button data-action="click->connect-websocket#toggleStream" data-connect-websocket-target="toggle">Attach WS Stream</button>
</div>
Binding for Controller in HTML

The various data-connect-websocket attributes follow the Stimulus naming convention and thereby permit the binding described above to the static fields for the controller, which Stimulus does automatically.

Finally, data-action="click->connect-websocket#toggleStream" binds a click handler to the <button> and ensures that this calls up the toggle method for our ConnectWebsocketController class.

Embedding in Spring

As mentioned at the outset, we have simple embedding of all the functionality shown above in Spring Boot/Spring Web MVC published on GitHub. To be honest though, we have to say that most implementations are not really Spring-specific – in the end, it is about getting the right attributes or additional elements in your own HTML templates. Therefore, our templates contain most Hotwire-specific things, which could easily be transferred to a different templating library (we used Thymeleaf) or another Java framework (such as quarkus).

Content negotiation was the area requiring the most special effort, ensuring that you can return the name of a template as expected within the Spring @Controller, if you want to return a Turbo Streams update from a normal method.

In this case the ViewResolver should return a View that gets created with the right Turbo-specific Content-Type. The snag with this is that the Turbo Library always sends Accept: text/vnd.turbo-stream.html, but we only want to activate the Turbo Streams content type for specific responses.

Currently, our solution is a ViewResolver, that is configured with a hard list of the views it should handle.

public ThymeleafViewResolver turboStreamViewResolver(SpringTemplateEngine engine) {
    ThymeleafViewResolver resolver = new ThymeleafViewResolver();
    resolver.setContentType("text/vnd.turbo-stream.html");
    resolver.setOrder(2);
    resolver.setTemplateEngine(engine);
    resolver.setViewNames(new String[] {"comments-stream"});
    return resolver;
}
The Turbo-Streams-Specific View Resolver

If you prefer to use Webflux for Spring, we recommend the repository from Josh, which shows suitable implementation and describes this in quite some detail, too.

Conclusion

Hotwire feels a bit like the introduction of SSDs. Essentially, nothing really new is happening, but suddenly the time-honored method seems so simple, fast and obvious that you ask yourself why we ever searched for different answers.

Templating is centralized in one location, there are no endless battles with webpack or with a module configuration, no waiting 30 seconds for a bundle to be rebuilt and then 10 MB to be reloaded in the browser.

Without any configuration, the functionality does exactly what you expect, but still has enough options to let you make flexible adjustments. And all that is in declarative markup, with no need to dive into the depths of nested JS APIs. You can sense that the people involved in Hotwire know their way around web applications, and the trials and tribulations that they entail.

Turbo in particular is a rounded library (and lightweight too, when you consider what it can do) with step-by-step enabling of new features that give you the SPA feeling, without having to build an SPA.

Just as a response to normal form submits, Turbo Streams is a fantastic feature. In conjunction with event streams, it even feels a bit like magic. You can stream updates to the page “just like that”, without having to write huge chunks of code.

Stimulus does a good job of simulating custom elements without you having to commit to these yet. The target and value bindings also provide handy methods to define configurations declaratively in HTML instead of working with additional scripts. You feel that the focus really is HTML-first, reinforcing the idea of progressive enhancements.

Personally, I find Stimulus to be a good enhancement, but I prefer to use custom elements for the same functionality. That is simply because we then build directly on the web platform and not on a definition from Basecamp. There are already plenty of libraries that ease building Custom Elements for you, but we will approach that topic in another post.

In any case, it is worth taking a close look at Hotwire (whichever parts of it you want to use) and my advice to anyone starting work on something new is to try it out, and see if you really do miss anything from those big JavaScript frameworks.

TAGS