TLDR; If you just want to fire up the code and try it out, here's the git repository: https://github.com/Cause-of-a-Kind/nest-hotwire-boilerplate
A few years ago, 37 Signals announced Hotwire, a new approach to building web applications without writing front-end JavaScript. Hotwire was developed by the team at 37 Signals while building Hey.com and allows you to have the responsiveness of a single-page app using only back-end code.
I've been intrigued by similar frameworks like Phoenix LiveView and Laravel Livewire, but as a JavaScript developer, I didn't want to have to learn a whole new backend language to try them out. While I had a bunch of Ruby on Rails experience a number of years ago, my knowledge of the framework has grown rusty.
Hotwire is backend agnostic, so I decided to test it out using NestJS, a Node.js framework that has an MVC structure similar to Rails. The idea is I could kick the tires on this new paradigm without my opinion being clouded by my unfamiliarity with a different backend language.
Hotwire is composed of three main libraries:
- Turbo
- Stimulus
- Strada
What I focused on is Turbo for this demo. This is the library that powers the HTML over the wire (H-OT-WIRE) approach to building your application. Stimulus is specifically for manage your client side javascript, and isn't a required part of this. You can use plain Vanilla JS or you can use a different client-side library like Alpine JS as an alternative.
In the demo I go over the three main components of Turbo for web apps:
- Turbo Drive
- Turbo Frames
- Turbo Streams
Let's dive in.
Turbo Drive
The first part of Hotwire I tried was Turbo Drive. This eliminates full page reloads when navigating between pages. To enable it, I simply added the Hotwire package script from Skypack to the document <head>
and voila.
<head>
...
<script type="module">
import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
</script>
...
</head>
With Turbo Drive, clicking between pages fetched the new page in the background and replaced the <body>
contents instead of fully reloading.
Turbo Frames
Next, I added Turbo Frames, which are effectively the components of your application that can update independently of each other.
For example, I added a <turbo-frame>
that loads a list of messages from the path in its src
attribute. The contents of this initial frame serve as the loading state before that html is replaced by the server response from /messages
.
<turbo-frame id="messages" src="/messages">
<p>Loading...</p>
</turbo-frame>
I then created an endpoint that returns those messages as a fragment of HTML. You can serve this as a complete HTML document or just a piece of HTML like I have and Turbo will pluck that fragment from the response and swap it with the existing HTML.
@Get('/messages')
@Render('turbo-frames/messages')
getMessages() {
return {
messages: this.appService.getMessages(),
};
}
Here's the turbo-frames/messages
fragment:
<turbo-frame id="messages">
<h1>Messages</h1>
<ul>
{{#each messages}}
<turbo-frame id="message_{{this.id}}">
<li>{{message.text}}</li>
<a href="/messages/{{message.id}}/edit">Edit</a>
</turbo-frame>
{{/each}}
</ul>
</turbo-frame>
And here's the result:
When clicking edit on a message, it loads the edit form into the frame without reloading the whole page. And this is really the paradigm shift you have to wrap your mind around if you're coming from another framework like React. Instead of managing the state of the UI on the client, we simply use standard link tags and Turbo loads the returned frame in place of what's there.
If you take a look at the code above again, we have another turbo-frame
element inside of our messages frame that holds each message.
{{#each messages}}
<turbo-frame id="message_{{this.id}}">
<li>{{message.text}}</li>
<a href="/messages/{{message.id}}/edit">Edit</a>
</turbo-frame>
{{/each}}
When we click on the link, we create another endpoint that returns the new piece of UI we want to load in place of what is within that frame.
Here is our controller:
@Get('/messages/:id/edit')
@Render('turbo-frames/edit-message')
editMessage(@Param('id') id: string) {
return {
message: this.appService.findMessage(+id),
};
}
And this is the turbo-frames/edit-message
template:
<turbo-frame id="message_{{message.id}}">
<form action="/messages/{{message.id}}/edit" method="POST">
<label>
Message:
<input type="text" name="text" value="{{message.text}}" />
</label>
<button type="submit">Update Message</button>
</form>
</turbo-frame>
Which renders like this when we click edit:
We can submit the form just like a normal form without any client side js on our part. Our /messages/:id/edit
endpoint can return the HTML fragment it should replace the form with. In this world the server just returns the new state of the world for our frames and Turbo will replace what's there with the response.
@Post('/messages/:id/edit')
@Render('turbo-frames/view-message')
updateMessage(
@Param('id') id: string,
@Body('text') text: string,
) {
const newMessage = this.appService.editMessage(+id, text);
return {
message: newMessage,
};
}
In this case, after submitting the form successfully, it switches back to our standard message view. Turbo Frames allow you to break the page into components that update independently.
Turbo Streams
The most exciting part was getting real-time updates working with Turbo Streams. This uses Server-Sent Events to push data updates from the server to the client.
I connected a stream source in my layout and set up an event emitter in NestJS. Now when a message is edited, it automatically streams the update to all connected clients.
So returning to our script in the <head>
of our document we can add our connection.
<script type="module">
import hotwiredTurbo, { connectStreamSource } from 'https://cdn.skypack.dev/@hotwired/turbo';
const es = new EventSource("/turbo-streams");
connectStreamSource(es);
</script>
Next we have to enable the /turbo-streams
endpoint to allow for our client to connect to the server and receive events.
@Sse('/turbo-streams')
async sse(): Promise<Observable<MessageEvent>> {
return fromEvent(this.eventEmitter, 'turbo-stream.event').pipe(
map((payload: { template: string }) => {
return {
data: payload.template,
} as MessageEvent;
}),
);
}
In this case, we're going to use and event emitter so that our backend can emit an event called turbo-stream.event
and deliver the template as a payload to the client.
So now we can modify our edit messages controller to emit an event that will tell anyone else connected to the app to update their UI in real time.
This enables real-time features like chat without any of our own front-end code!
@Post('/messages/:id/edit')
@Render('turbo-frames/view-message')
updateMessage(
@Param('id') id: string,
@Body('text') text: string,
@Req() req: Request,
) {
const newMessage = this.appService.editMessage(+id, text);
this.appService.sendTurboStreamEvent(req, {
template: 'turbo-streams/update-message',
data: { message: newMessage },
});
return {
message: newMessage,
};
}
I created a small helper in our app service to handle rendering the template string and emitting the event when complete.
sendTurboStreamEvent(
req: Request,
{
template,
data,
}: {
template: string;
data: object;
},
) {
req.app.render(template, data, (_err, html) => {
this.eventEmitter.emit('turbo-stream.event', {
template: html,
});
});
}
Now when if we load our application side by side with another instance you can see them both update automatically! This reminded me a lot of the magic that was baked into the Meteor framework, but with a lot less code.
Final Thoughts
I was impressed by how much I could build with Hotwire and NestJS. It seems promising for rapid prototyping and MVPs. I'm interested to try migrating an existing app that is using React to compare the difference. But now I can get a more accurate comparison being in the comfort of my daily programming language.
I've published the demo app code to GitHub if you want to try playing with Hotwire yourself. Let me know if you build something cool with it!
Top comments (2)
Is it possible to use NestJS even if TypeScript is removed in Turbo 8?
I should have mentioned this in the video but totally. If you take a look at the actual use of turbo it's a few lines of code entirely on the frontend so I don't really feel the loss of typescript at all there. Reading through the API, the actual surface area itself of the library you would need to write client side typescript too is also small enough that I think you could just write a few of your own types to handle it. So I really don't think this would be a major loss at all since nearly all of your mission critical, business logic should exist entirely on the backend.
Now the bigger thing that I think could be helpful is if there was a typescript driven template language instead of handlebars. In a perfect world my template language of choice would be TSX so perhaps I have to look into how Astro built our their rendering and replace handlebars with that.