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:
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
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
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>
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>
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>
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
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
}
}
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>
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 %>
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>
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.
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
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 })
}
}
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
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>
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 %>
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>
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:
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
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
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>
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
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.
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
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
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 edit
posts page, enter in a body with some markdown content and the {{ company_name }}
tag and see that the tag is replaced as you type:
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
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 })
}
}
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)