A pattern for live form updates in Rails applications using Turbo Streams and Stimulus. This repository serves as both a demo application and the source code for the pattern.
๐ค LLM Users: See LLM.md for a token-efficient implementation guide.
The Dynamic Form Pattern enables real-time form updates without full page reloads. As users interact with form fields, the form is submitted in the background and morphed in-place using Turbo Streams. This allows for:
- Live previews โ Show rendered content (e.g., Markdown) as the user types
- Dynamic field updates โ Update dependent fields, calculations, or UI based on input
- Conditional validation โ Display validation errors only after the user has attempted to submit
- Enhanced UX โ Provide immediate feedback without page refreshes
To run this demo application locally:
# Clone the repository
git clone https://github.com/cmer/rails-dynamic-form-preview-pattern.git
cd rails-dynamic-form-preview-pattern
# Install dependencies
bundle install
# Set up the database
bin/rails db:create db:migrate db:seed
# Start the server
bin/rails serverVisit http://localhost:3000/posts to see the form preview pattern in action. Try:
- Creating a new post and typing in the body field to see live Markdown preview
- Submitting an invalid form, then editing fields to see live validation updates
- Changing the publish date to see the formatted date update instantly
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Browser โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Form with data-controller="form-preview" โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Hidden field: _wus (was user submitted) โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Input field with data-action="blur->form-preview#preview" โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โ 1. User triggers event (blur/input/change โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Stimulus Controller โ โ
โ โ - Debounces the request โ โ
โ โ - Adds form_preview=true param โ โ
โ โ - Submits form via Turbo โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ 2. Form submitted with form_preview=true
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Server โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Controller Action (create/update) โ โ
โ โ โ โ
โ โ return if render_form_preview(@model) โ โ
โ โ โ โ โ
โ โ โผ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ FormPreviewHelper โ โ โ
โ โ โ - Checks form_preview? param โ โ โ
โ โ โ - Validates model (if _wus present) โ โ โ
โ โ โ - Renders Turbo Stream morph response โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ 3. Turbo Stream response
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Browser โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Turbo morphs the form in-place โ โ
โ โ - Updates preview content โ โ
โ โ - Shows validation errors (if applicable) โ โ
โ โ - Preserves focus and scroll position โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The pattern includes smart validation handling:
- Before first submission: Preview updates occur but validation errors are hidden
- After first submission: Preview updates include validation errors
This is tracked via a hidden _wus (was user submitted) field that persists across preview requests.
Copy these two files to your Rails application:
Copy app/helpers/form_preview_helper.rb to your application:
cp app/helpers/form_preview_helper.rb /path/to/your/app/helpers/Copy app/javascript/controllers/form_preview_controller.js to your application:
cp app/javascript/controllers/form_preview_controller.js /path/to/your/app/javascript/controllers/If using import maps, register the controller in app/javascript/controllers/index.js:
import FormPreviewController from "./form_preview_controller"
application.register("form-preview", FormPreviewController)A basic form with live preview on blur:
Controller:
class PostsController < ApplicationController
include FormPreviewHelper # <<- Add this
def new
@post = Post.new
end
def create
@post = Post.new(post_params)
return if render_form_preview(@post) # <<- Add this
if @post.save
redirect_to @post, notice: "Post created."
else
render :new, status: :unprocessable_entity
end
end
private
def post_params
params.require(:post).permit(:title, :body)
end
endForm partial (app/views/posts/_form.html.erb):
<%= form_with model: post,
id: form_preview_form_id(post),
data: { controller: "form-preview" } do |f| %> <!-- Add this -->
<%= form_preview_hidden_field %> <!-- Add this -->
<div>
<%= f.label :title %>
<%= f.text_field :title, data: { action: "blur->form-preview#preview" } %> <!-- Add action -->
<% if post.errors[:title].any? %>
<span class="error"><%= post.errors[:title].first %></span>
<% end %>
</div>
<div>
<%= f.label :body %>
<%= f.text_area :body, data: { action: "blur->form-preview#preview" } %> <!-- Add action -->
<% if post.errors[:body].any? %>
<span class="error"><%= post.errors[:body].first %></span>
<% end %>
</div>
<%= f.submit %>
<% end %>A form with live Markdown preview, per-field debounce, and multiple event types:
Form partial:
<%= form_with model: post,
id: form_preview_form_id(post),
data: {
controller: "form-preview",
form_preview_debounce_value: 0
} do |f| %>
<%= form_preview_hidden_field %>
<%# Text field - preview on blur (no debounce needed) %>
<div>
<%= f.label :title %>
<%= f.text_field :title,
"aria-invalid": post.errors[:title].any? || nil,
data: { action: "blur->form-preview#preview" } %>
<% if post.errors[:title].any? %>
<small class="error"><%= post.errors[:title].first %></small>
<% end %>
</div>
<%# Textarea - preview on input with 300ms debounce for live typing preview %>
<div>
<%= f.label :body %>
<%= f.text_area :body,
rows: 10,
"aria-invalid": post.errors[:body].any? || nil,
data: {
action: "input->form-preview#preview",
form_preview_debounce_value: 300
} %>
<% if post.errors[:body].any? %>
<small class="error"><%= post.errors[:body].first %></small>
<% end %>
</div>
<%# Select - preview on change %>
<div>
<%= f.label :category %>
<%= f.select :category,
%w[Technology Design Business],
{ include_blank: "Select category" },
data: { action: "change->form-preview#preview" } %>
</div>
<%# Date field - preview on change %>
<div>
<%= f.label :publish_on %>
<%= f.date_field :publish_on,
data: { action: "change->form-preview#preview" } %>
<% if post.publish_on.present? %>
<small>Scheduled for <%= post.publish_on.strftime("%B %d, %Y") %></small>
<% end %>
</div>
<%# Live Markdown preview %>
<article>
<header>Preview</header>
<div id="preview">
<%= render_markdown(post.body) %>
</div>
</article>
<%= f.submit %>
<% end %>Handles form preview requests. Call this at the start of your create/update actions.
| Option | Type | Default | Description |
|---|---|---|---|
:id |
String | "#{model_name}-form" |
DOM ID of the form to replace |
:partial |
String | "form" |
Partial to render |
:locals |
Hash | { model_name: model } |
Local variables for the partial |
Returns: true if preview was rendered, nil otherwise.
# Basic usage
return if render_form_preview(@post)
# With custom options
return if render_form_preview(@post,
id: "inline-post-form",
partial: "posts/compact_form",
locals: { post: @post, show_preview: true })form_preview_hidden_field
Renders the hidden field that tracks user submission state. Required in every form.
<%= form_preview_hidden_field %>Generates a consistent form ID based on the model name.
<%= form_with model: post, id: form_preview_form_id(post) do |f| %>
<%# Generates id="post-form" %>Returns true if the current request is a form preview request.
if form_preview?
# Handle preview-specific logic
endReturns true if validation should run (user has previously submitted the form).
| Value | Type | Default | Description |
|---|---|---|---|
debounce |
Number | 0 |
Default debounce in milliseconds |
url |
String | (none) | Optional URL to submit preview requests to (overrides form action) |
httpMethod |
String | (none) | Optional HTTP method for preview requests: "get" or "post" only |
Set at controller level:
<form data-controller="form-preview" data-form-preview-debounce-value="200">Override per element:
<textarea data-action="input->form-preview#preview"
data-form-preview-debounce-value="300">You can submit preview requests to a different URL and/or HTTP method than the form's default action. This is useful when you want to use a dedicated preview endpoint:
<%= form_with model: post,
id: form_preview_form_id(post),
data: {
controller: "form-preview",
form_preview_url_value: post_preview_path(post),
form_preview_http_method_value: "get"
} do |f| %>When these values are set, preview submissions will use the specified URL/method while the form's normal submit behavior remains unchanged.
Note: POST preview requests may cause password managers like 1Password to display "Save Identity" or "Save Password" dialogs on every preview request, which is disruptive. To avoid this, submit previews via GET to a dedicated endpoint.
| Action | Description |
|---|---|
preview |
Triggers a debounced form preview submission |
| Field Type | Event | Debounce | Rationale |
|---|---|---|---|
text_field |
blur |
0ms | Preview when user leaves field |
text_area |
input |
200-500ms | Live preview while typing |
select |
change |
0ms | Immediate feedback on selection |
date_field |
change |
0ms | Immediate feedback on date pick |
check_box |
change |
0ms | Immediate feedback on toggle |
radio_button |
change |
0ms | Immediate feedback on selection |
- Rails 7.0+ with Turbo
- Stimulus 3.0+
- Turbo 8.0+ (for morphing support)
MIT