Who wouldn’t want a live preview for writing their great content? If you happen to be running Rails with Hotwire, it’s surprisingly easy with a small Stimulus controller.
Based on the feedback to this post (primarily thanks to edwinvlieg from HN), I decided to include a solution using Turbo as well. So the first example uses a regular AJAX with Rails UJS and then we change it to a Turbo Frame.
Here’s the simple demo:
Rails UJS + Stimulus 2
The core idea of our preview is that it’s rendered entirely on the backend. This is useful since we can reuse the logic both for presentation as well as actual changes. Whatever happens with the content can come from a single Ruby method. We’ll start with a small controller and a preview
method:
class PreviewController < ApplicationController
def preview
preview = "<strong>#{request.raw_post}</strong"
render plain: preview
end
...
end
I depend on request.raw_post
to get the whole POST body sent to the backend. We also render it as plain text, avoiding any HTML safe escaping.
Now we have to open a form for the model we want to preview. Let’s say it’s a Tweet
model:
<%= form_with(model: tweet) do |form| %>
<div data-controller="composer">
<% if tweet.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(tweet.errors.count, "error") %> prohibited this tweet from being saved:</h2>
<ul>
<% tweet.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body, placeholder: "Type a tweet", data: { "composer-target": "tweet", "action": "input->composer#preview" } %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<div data-composer-target="output">
</div>
</div>
<% end %>
As you can see, I adjusted the default template with a little bit Stimulus 2 data attributes. I wrapped the whole form in a div with data-controller
set to composer
, which will be the Stimulus controller’s name. I added two data attributes to the text area: data-composer-target
identifying the text area and data-action
specifying composer#preview
action on any input. Then at the bottom, we just have a <div>
with data-composer-target
set to output
. This target will be used for inserting the rendered HTML from the backend.
For the view to work, we’ll need to write a composer_controller.js
that will react to input changes taking data from the text area and present them to the output div.
But before we do, we have to make sure we have both Rails UJS and Stimulus set up as we’ll use UJS Rails.ajax
call. The application.js
in app/javascript/packs
needs to look similar to this:
import Rails from "@rails/ujs"
import "@hotwired/turbo-rails"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
Rails.start()
ActiveStorage.start()
// Stimulus.js
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start()
const context = require.context("./controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
The final piece is the controller itself:
import { Controller } from "stimulus"
import Rails from "@rails/ujs"
export default class extends Controller {
static targets = [ "tweet", "output" ]
connect() {
this.preview()
}
preview() {
var content = this.tweetTarget.value;
var preview = this.outputTarget;
Rails.ajax({
type: "post",
url: "/preview",
contentType: "text/plain",
data: content,
success: function(data) {
preview.innerHTML = data
}
})
}
}
In the beginning, we define the tweet
and output
targets. We call preview
on connect()
to make sure the initial preview will be rendered and then define the primary preview
method.
The preview()
method then takes the content from the tweet
target and sends it using the Rails.ajax
call as text/plain
. Once we receive a preview response back, we directly inject it into the rest of the page by setting the innerHTML
attribute on the output
target.
If you didn’t modify the initial method, you’d get the whole text previewed in bold.
Turbo Frame + Stimulus 2
The basic idea of a Turbo Frame is that is will automatically load a remote HTML page or partial from a given URL:
<%= form_with(model: tweet) do |form| %>
<div data-controller="composer" data-composer-preview-url-value="<%= tweets_preview_url %>">
<% if tweet.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(tweet.errors.count, "error") %> prohibited this tweet from being saved:</h2>
<ul>
<% tweet.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body, placeholder: "Type a tweet", data: { "composer-target": "tweet", "action": "input->composer#preview" } %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<turbo-frame id="output" data-composer-target="output" url="">
</turbo-frame>
</div>
<% end %>
The form is the same except two changes. First, we change the regular div for the output to be the Turbo Frame:
<turbo-frame id="output" data-composer-target="output" url="">
</turbo-frame>
And we include the preview URL we’ll need as part of the Stimulus value attribute:
<div data-controller="composer" data-composer-preview-url-value="<%= tweets_preview_url %>">
The idea is to take this URL (it has to be an absolute URL), append the text from the text area, and update the url
attribute of the Turbo Frame. Turbo Frame then reloads the preview automatically.
On the backend, the preview method renders the full preview from a body
param:
...
def preview
@preview = "<strong>#{params[:body]}</strong>"
end
...
But this time we have to include the frame as well, so I will create a proper preview.html.erb
template:
<turbo-frame id="output" data-composer-target="output">
<%= raw @preview %>
</turbo-frame>
The Stimulus controller then has to take the value from the textarea and append it to the preview URL:
import { Controller } from "stimulus"
import Rails from "@rails/ujs"
export default class extends Controller {
static targets = [ "tweet", "output" ]
static values = { previewUrl: String }
connect() {
this.preview()
}
preview() {
let url = new URL(this.previewUrlValue);
url.searchParams.append('body', this.tweetTarget.value);
this.outputTarget.src = url.toString();
}
}
Turbo Frame does the the AJAX for us and all is well.
And that’s it. Two different approaches of how to start doing previews with Rails and Hotwire. There are even more ways how to achieve it, but I deliberately avoided depending on passing a model around.
Get Test Driving Rails while it's in prerelease.