DEV Community

Cover image for Real-time previews with Rails and StimulusReflex
David Colby
David Colby

Posted on • Originally published at colby.so

Real-time previews with Rails and StimulusReflex

Today we are going to build a live, server-rendered, liquid-enabled markdown previewer with Ruby on Rails and StimulusReflex. It’ll be pretty neat.

Allowing users to preview their content before saving it is a common need in web applications — posts, products, emails. Any user-created content that gets turned into HTML can benefit from a preview function to help users check their work before they save it.

Our StimulusReflex-powered previewer will parse user-generated markdown on the server, insert dynamic content with liquid, and update the DOM in ~100ms, fast enough that the preview feels instant.

When our work is done, the end product will look like this:

A screen recording of a user typing into a form on a web page. As they type, what they type is displayed in a box beside the form input, with markdown formatting applied.

If you’d like to try out the previewer in a production environment, you can try it out on Heroku. The demo application is hosted on Heroku’s free tier so expect a delay on the first page load!

Before diving in, this tutorial assumes a basic level of Rails knowledge. If you have never written Rails before, this tutorial may not be for you. You do not need any prior experience with Stimulus or StimulusReflex to follow along — in fact, this tutorial will be most useful to you if you are new to StimulusReflex and curious about how it can help you build great experiences faster.

Let’s get started!

Application setup

We are working from a base Rails 7 application with TailwindCSS and StimulusReflex installed. To follow along with this tutorial, start by cloning the starter repo so we can skip past the install steps and get to build the application. All of the code changes we will make in this tutorial can be found in this pull request.

Our application will allow users to create and edit Posts. As they modify a post, the preview display beside the post will update. Before we can preview posts, we’ll need a Post resource to work with, so let’s begin by scaffolding that resource up.

From your terminal:

rails g scaffold Post title:string body:text
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

After you scaffold the resource, start up the rails application and build CSS and JavaScript with bin/dev. Head to http://localhost:3000/posts and make sure that creating and editing posts works before moving on.

As a reminder, our end goal is a live, updated-as-you-type preview displayed beside the post form. We’ll construct the UI first. Create a new preview partial from your terminal:

touch app/views/posts/_preview.html.erb
Enter fullscreen mode Exit fullscreen mode

Fill the new partial in with:

<div class="bg-gray-200 p-4 shadow-sm rounded-sm">
  <article class="prose">
    <h2 class="text-2xl font-bold"><%= post.title %></h2>
    <div>
      <%= post.body %>
    </div>
  </article>
</div>
Enter fullscreen mode Exit fullscreen mode

Update app/views/posts/new.html.erb to render the preview partial beside the post form, like this:

<div class="mx-auto md:w-2/3 w-full">
  <div class="flex space-x-12">
    <div class="w-1/2">
      <%= render "form", post: @post %>
    </div>
    <div class="w-1/2">
      <%= render "preview", post: @post %>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Now update app/views/posts/edit.html.erb with the same content:

<div class="mx-auto md:w-2/3 w-full">
  <div class="flex space-x-12">
    <div class="w-1/2">
      <%= render "form", post: @post %>
    </div>
    <div class="w-1/2">
      <%= render "preview", post: @post %>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Here, we could have created a new partial with the content we added to new and edit, but it is not strictly necessary. Feel free to move this content to a partial if you prefer.

Now we have preview content displayed beside the post form, but the preview content is static — visit posts/new and see that the "preview" is just an empty gray box. Not very useful yet.

In the next section, we will create a StimulusReflex-enabled Stimulus controller to update the preview content as the user types, making the gray preview box a little more useful.

Adding a Stimulus controller

Our application will eventually rely on a server-side reflex to update the content of the preview partial that we created in the last section. However, before we do that, let’s build a purely client-side implementation of the preview function to ease into things.

From your terminal, use the stimulus_reflex generator:

rails g stimulus_reflex post
rails stimulus:manifest:update
Enter fullscreen mode Exit fullscreen mode

After this generator runs, you will see that the generator created both a client-side Stimulus controller and a server-side reflex: app/javascript/controllers/post_controller.js and app/reflexes/post_reflex.rb.

On the client-side, StimulusReflex enhances vanilla Stimulus controllers. StimulusReflex-powered Stimulus controllers have all of the same functionality of a regular Stimulus controller, with extra functionality layered on top.

To demonstrate this, we are going to start by using the new Stimulus controller, post_controller.js to update the content of the post preview as the user types. We will not have any markdown formatting or liquid substitution, but the page will react to user input and we will get to see a few Stimulus concepts in action.

Head to app/javascript/controllers/post_controller.js and update it:

import ApplicationController from './application_controller'

export default class extends ApplicationController {
  static targets = [ "title", "body", "titlePreview", "bodyPreview" ]

  preview() {
    this.titlePreviewTarget.innerText = this.titleTarget.value
    this.bodyPreviewTarget.innerText = this.bodyTarget.value
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, we inherit from ApplicationController, the base StimulusReflex controller.

In the controller, we define targets that we will use to obtain an easy reference to the elements that we care about. We use these targets in the preview function. Each time preview is called, the titlePreview and bodyPreview elements are updated with the current value of titleTarget and bodyTarget, respectively.

For the most part, this is regular JavaScript. If we wanted to, we could remove the targets and instead select each element by an id, with something like document.getElementById('body-target'). Stimulus targets give us a more convenient method to access any number of elements by a name, but nothing magical is happening.

To use this new preview function, we need to connect the new PostController to the DOM and add target elements and actions to the preview and form partials.

In new.html.erb:

<div class="mx-auto md:w-2/3 w-full">
  <div class="flex space-x-12" data-controller="post">
    <div class="w-1/2">
      <%= render "form", post: @post %>
    </div>
    <div id="post-preview" class="w-1/2">
      <%= render "preview", post: @post %>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Here we added data-controller="post" to the div wrapping the form and preview partials.

To use a Stimulus controller, you must connect the controller to a DOM element with the data-controller attribute. Stimulus scopes controllers based on the DOM hierarchy — the element with the data-controller attribute and all of the children of that element will be within that controller’s scope. targets and actions for a controller must be within the scope to function.

This scoping mechanism allows developers to have multiple independent instances of the same controller present on the page at once, when needed.

Update the form partial next:

<%= form_with(model: post, class: "contents") do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :title %>
    <%= form.text_field(
      :title,
      class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full",
      data: {
        post_target: "title",
        action: "input->post#preview"
      } 
    ) %>
  </div>

  <div class="my-5">
    <%= form.label :body %>
    <%= form.text_area(
      :body,
      rows: 4,
      class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full",
      data: {
        post_target: "body",
        action: "input->post#preview"
      } 
    ) %>
  </div>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The important updates here are on the two form inputs. On each, we added a data-post-target and a data-action attribute. The post-target attribute defines a target for the PostController, and the action attribute tells Stimulus to fire the PostController's preview function when an input event occurs on that input.

With this change in place, each time a user types a character in the title or body inputs, the PostController will run the preview function to update the titlePreview and bodyPreview elements.

This won’t work just yet though. Before it will, we need to set the titlePreview and bodyPreview targets. Head to app/views/posts/_preview.html.erb and update it:

<div class="bg-gray-200 p-4 shadow-sm rounded-sm">
  <article class="prose">
    <h2 class="text-2xl font-bold" data-post-target="titlePreview"><%= post.title %></h2>
    <div data-post-target="bodyPreview">
      <%= post.body %>
    </div>
  </article>
</div>
Enter fullscreen mode Exit fullscreen mode

Notice the data-post-target attributes on the header and the body tags.

With this change in place, refresh the new post page, start typing and see that the content of the preview updates as you type.

A screen recording of a user typing into a form on a web page. Everything they type is copied into a box beside the form.

This is a great start, but we aren’t really “previewing” anything because the Stimulus controller is not parsing markdown content or substituting liquid tags.

To add useful previews, we will use the server-side PostReflex that we generated at the beginning of this section.

Server-side previews with StimulusReflex

Stimulus controllers do their work on the client-side, adding and removing elements, updating classes or attributes. Even when we are using StimulusReflex, this is still true — Stimulus controllers stay on the client-side. To add server-side functionality, we need to move to the PostReflex, at app/reflexes/post_reflex.rb.

This reflex class will be responsible for transforming the content of the preview body from markdown to HTML, and for substituting any liquid tags present in the content. Once the content is transformed, StimulusReflex will send the transformed content back to the client, where it will be inserted seamlessly into the DOM, all in ~100ms.

Before adding markdown and liquid parsing, let’s move the client-side preview updates to the server-side reflex. Update PostReflex like this:

class PostReflex < ApplicationReflex  
  def preview
    morph '#preview-title', post_params[:title]
    morph '#preview-body', post_params[:body]
  end

  private

  def post_params
    params.require(:post).permit(:title, :body)
  end
end
Enter fullscreen mode Exit fullscreen mode

In this reflex, we are using two selector morphs to update the preview content, identifying the element to update with an id. Selector morphs allow us to run reflex actions without a full pass through a controller action with ActionDispatch, and are perfect for features that only update a small piece of the page, like our preview functionality.

post_params is identical to attribute whitelisting that you’ll find in a standard Rails controller — each time this reflex is called, the data from the post form will be serialized and sent to the server, giving us a handy way to reference the current content of the form.

In StimulusReflex, there are two ways to call a server-side reflex.

The first option is through a reflex data attribute on a DOM element. This approach completely bypasses the reflex’s related Stimulus controller and directly invokes the server-side reflex. This option works well when you have a simple reflex that does not need custom options to function.

Our use case requires us to use the second method for calling reflexes: using this.stimulate in a Stimulus controller. stimulate is extremely flexible and allows us to override the element passed to the server-side reflex, define options, and run any JavaScript we like before sending the reflex to the server.

To call a reflex with stimulate, head back to app/javascript/controllers/post_controller.js and update it like this:

import ApplicationController from './application_controller'

export default class extends ApplicationController {
  connect() {
    super.connect()
  }

  preview() { 
    this.stimulate("Post#preview", { serializeForm: true })
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, we removed the targets from the controller and preview now just calls this.stimulate.

The serializeForm option ensures the content of the closest form element in the DOM is passed to the reflex on the server. The data in the form is accessible on the server via params, as we saw in the PostReflex earlier in this section.

Now we have a reflex defined on the server, and a Stimulus controller ready to call that reflex. To make this work, we need to update the DOM to match the structure expected by the reflex.

Recall in the preview method in PostReflex, we have two morphs that rely on ids to target the right element in the DOM:

def preview
  morph '#preview-title', post_params[:title]
  morph '#preview-body', post_params[:body]
end
Enter fullscreen mode Exit fullscreen mode

For the reflex to work, we need an element with a preview-title id and another with a preview-body id. Update the preview partial like this:

<div class="bg-gray-200 p-4 shadow-sm rounded-sm">
  <article class="prose">
    <h2 id="preview-title" class="text-2xl font-bold"><%= post.title %></h2>
    <div id="preview-body">
      <%= post.body %>
    </div>
  </article>
</div>
Enter fullscreen mode Exit fullscreen mode

Now both the title element and the body element have ids that match the expectations of the reflex, so when the reflex runs, those elements will be updated.

Move over to the form partial to connect the post Stimulus controller to the form:

<%= form_with(model: post, class: "contents", data: { controller: "post" }) do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :title %>
    <%= form.text_field(
      :title,
      class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full",
      data: {
        action: "input->post#preview"
      } 
    ) %>
  </div>

  <div class="my-5">
    <%= form.label :body %>
    <%= form.text_area(
      :body,
      rows: 4,
      class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full",
      data: {
        action: "input->post#preview"
      } 
    ) %>
  </div>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Here, notice the form element now has a data-controller="post" attribute, scoping the Stimulus controller to the form. We also updated the form inputs to remove the unnecessary data-post-target attributes.

Because we are serializing the entire content of the form, we no longer need a direct reference to the individual input elements in the Stimulus controller, so we do not need the target attributes.

One last step to move the preview functionality to the server. Because we connected the PostController to the form, we need to remove the controller from the wrapper div in app/view/posts/new.html.erb:

<div class="mx-auto md:w-2/3 w-full">
  <div class="flex space-x-12">
    <div class="w-1/2">
      <%= render "form", post: @post %>
    </div>
    <div class="w-1/2">
      <%= render "preview", post: @post %>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Previously, we needed a direct reference in the Stimulus controller to the preview elements, so we needed both the form and preview partials to be within the scope of the PostController.

Because the preview elements are now updated in the server-side reflex and referenced by id, the preview partial no longer needs to be in the PostController’s scope.

With this last change in place, head back to http://localhost:3000/posts/new, start typing and see that the preview content updates each time the form changes. To confirm that StimulusReflex is doing the work on the server, watch the Rails server logs as you type. With each key press, the server logs will output information about the reflex:

A screen recording of a user typing into a form on a web page with a terminal window display server logs below the browser. As the user types, the characters they type are copied into a preview box and the server logs indicate that the StimulusReflex post#preview action has run.

Now we are “previewing” content as the user types, but that preview still is not doing anything useful. We just added a round trip to the server, but the server is not doing anything useful with the content yet.

In the next section, we will make the server-side reflex more valuable by adding markdown and liquid tag parsing when the preview content is updated.

Add markdown and liquid parsing

We will use redcarpet to parse markdown. To do so, We need to add redcarpet to our application.

From your terminal:

bundle add redcarpet
Enter fullscreen mode Exit fullscreen mode

Restart your Rails application and head to app/helpers/posts_helper.rb and add a method to parse markdown content:

module PostsHelper
  def to_markdown(content)
    return '' if content.blank?

    markdown = Redcarpet::Markdown.new(
      Redcarpet::Render::HTML,
      fenced_code_blocks: true,
      autolink: true
    )
    markdown.render(content).html_safe
  end
end
Enter fullscreen mode Exit fullscreen mode

This helper takes a string, initializes a new instance of Redcarpet::Markdown as described in the documentation, and then renders the content to HTML.

The to_markdown helper will be used in two places in our application: When the preview partial is loaded during a normal request (like when visiting the post edit page) and when parsing content on the fly in the PostReflex.

Update the preview partial first, at app/views/posts/_preview.html.erb:

<div class="bg-gray-200 p-4 shadow-sm rounded-sm">
  <article class="prose">
    <h2 class="text-2xl font-bold" id="preview-title">
      <%= post.title %>
    </h2>
    <div id="preview-body">
      <%= to_markdown(post.body) %>
    </div>
  </article>
</div>
Enter fullscreen mode Exit fullscreen mode

When a user visits the new or edit pages, markdown content in the body will be parsed and rendered as HTML.

Update the PostReflex to use the new to_markdown helper:

def preview
  morph '#preview-title', post_params[:title]
  morph '#preview-body', ApplicationController.helpers.to_markdown(post_params[:body])
end
Enter fullscreen mode Exit fullscreen mode

Head to new or edit now, type some markdown content in the body input and see that as you type, the markdown is transformed into HTML instantly.

A screen recording of a user typing into a form on a web page. As they type, what they type is displayed in a box beside the form input, with markdown formatting applied.

At this point, we have server-powered markdown parsing — nice work!

Our next step is adding liquid support, allowing users to transform liquid content into real data and see that content processed in real time.

From your terminal:

bundle add liquid
Enter fullscreen mode Exit fullscreen mode

Back to the PostsHelper:

module PostsHelper
  def to_markdown(content)
    return '' if content.blank?

    markdown = Redcarpet::Markdown.new(
      Redcarpet::Render::HTML,
      fenced_code_blocks: true,
      autolink: true
    )
    markdown.render(liquified(content)).html_safe
  end

  def liquified(content)
    Liquid::Template
      .parse(content)
      .render('company_name' => 'Hotwiring Rails')
  end
end
Enter fullscreen mode Exit fullscreen mode

liquified takes a string of content, scans it for matching liquid tags and objects, and translates them.

Our example implementation mocks up a simple company_name tag — in a real application we could use liquid tags to insert data about the object we are working on.

Restart your Rails application again and then go back to the new or editposts page, enter in a body with some markdown content and the {{ company_name }} tag and see that the tag is replaced as you type:

A screen recording of a user typing into a form on a web page. As they type, what they type is displayed in a box beside the form input, with markdown formatting applied.

Debouncing requests

Right now, every keystroke triggers a round trip to the server. Many of these requests are unnecessary because the user will have typed another character before the request completes. A simple way to reduce the load on the server is to debounce preview function, waiting for the user to pause before triggering the server-side reflex.

To debounce the preview function, we will use lodash’s debounce. From your terminal:

yarn add lodash
Enter fullscreen mode Exit fullscreen mode

And then update the Post Stimulus controller:

import ApplicationController from './application_controller'
import debounce from "lodash/debounce"

export default class extends ApplicationController {
  connect() {
    super.connect()
    this.preview = debounce(this.preview.bind(this), 50)
  }

  preview() { 
    this.stimulate("Post#preview", { serializeForm: true })
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, preview will wait 50ms before firing. Feel free to play around with the wait period to find the time that feels right to you.

And with that change, you have reached the end of this tutorial, great work today!

Wrapping up

Today we built a fully functional, StimulusReflex-powered markdown and liquid tag parser. Our application processes content on the server and returns it to the client without page turns, saving records in the database, or dealing with the overhead of a Rails controller action.

While building this application, we learned a bit about how to use Stimulus and StimulusReflex in Rails application and applied some basic techniques of both to create a real-time experience while staying close to core Rails principles.

Before using something like what we built today in production, a couple of things to think about:

Why not just parse things on the client?

Throughout this tutorial, you may have wondered “Why don’t we just parse the markdown on the client?”

Liquid processing was introduced to give us a reason to parse content like this on the server. We are here to learn about Stimulus and StimulusReflex, so I fit the requirements to that goal.

If your application just needs markdown parsing without the extra complications of liquid, a client-side parser is a completely reasonable (and more performant) choice!

Do you need real-time previews?

The live preview we built is useful for learning and showing off what StimulusReflex can do, but it may not be the most desirable user experience in a real application. The live preview experience works fine for some use cases, particularly when the content is only a few paragraphs in length.

As the content gets longer, a better approach is moving the “preview” content into a separate tab, hidden by default. As the user types, update the content in the hidden tab as usual, but keep the content hidden until the user requests it. That experience is likely to scale a bit better, while not being any more technically complex than the experience we built.

Further reading

The best resources for learning about Stimulus are the official handbook and reference. The official documentation does not cover more advanced use cases and best practices. For that, BetterStimulus is a great starting point.

To learn more about StimulusReflex, the documentation is exceptional, and is the best place to start your journey. The StimulusReflex discord is also a wonderful resource full of kind and helpful folks.

That’s all for today. As always, thank you for reading!

PS: If you enjoyed this post, you might enjoy my monthly newsletter on the latest in modern Rails development, Hotwiring Rails.

Top comments (0)