Notes to self

Live previews with Rails and Stimulus 2

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:

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.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS