Rails scaffolding gives you a great starting point with basic CRUD operations, but the default forms use full page reloads. Let's upgrade them to use Turbo Streams for a snappier, modern user experience.
What We're Building
We'll take a standard Article scaffold and convert all form interactions to use Turbo Streams, so creating, updating, and deleting articles happens without full page refreshes.
Starting Point: The Scaffold
Let's say you've generated a basic Article scaffold:
rails generate scaffold Article title:string body:text
rails db:migrateThis gives you the standard CRUD operations with full page reloads. Let's make it dynamic.
Step 1: Update Your Controller
First, modify your ArticlesController to respond with Turbo Stream formats:
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
before_action :set_article, only: %i[ show edit update destroy ]
def index
@articles = Article.all
end
def show
end
def new
@article = Article.new
end
def edit
end
def create
@article = Article.new(article_params)
respond_to do |format|
if @article.save
format.turbo_stream
format.html { redirect_to @article, notice: "Article was successfully created." }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("article_form", partial: "form", locals: { article: @article }) }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def update
respond_to do |format|
if @article.update(article_params)
format.turbo_stream
format.html { redirect_to @article, notice: "Article was successfully updated." }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("article_form", partial: "form", locals: { article: @article }) }
format.html { render :edit, status: :unprocessable_entity }
end
end
end
def destroy
@article.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to articles_path, status: :see_other, notice: "Article was successfully destroyed." }
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body)
end
endWhat it does:
- Receives the PATCH or POST request
- Updates @article with new status via article_params
- Checks request format (Turbo sends text/vnd.turbo-stream.html)
- Calls format.turbo_stream which looks for app/views/articles/update.turbo_stream.erb or app/views/articles/create.turbo_stream.erb depending on the call
Step 2: Create Turbo Stream Views
Now create the corresponding Turbo Stream view files:
<!-- app/views/articles/create.turbo_stream.erb -->
<%= turbo_stream.prepend "articles", partial: "article", locals: { article: @article } %>
<%= turbo_stream.replace "new_article_form", partial: "form", locals: { article: Article.new } %>
<%= turbo_stream.update "notice", partial: "shared/notice", locals: { message: "Article was successfully created." } %><!-- app/views/articles/update.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(@article), partial: "article", locals: { article: @article } %>
<%= turbo_stream.update "notice", partial: "shared/notice", locals: { message: "Article was successfully updated." } %><!-- app/views/articles/destroy.turbo_stream.erb -->
<%= turbo_stream.remove dom_id(@article) %>
<%= turbo_stream.update "notice", partial: "shared/notice", locals: { message: "Article was successfully destroyed." } %>What it does:
- turbo_stream.replace - Turbo action command
- dom_id(@article) - Generates article_123 (matches the <tr id="article_123"> in the
original HTML)
- The block contains the NEW HTML to replace the old <tr>
Step 3: Update Your Index View
Modify your index view to include proper IDs for Turbo Stream targeting:
<!-- app/views/articles/index.html.erb -->
<div id="notice"></div>
<h1>Articles</h1>
<div id="articles">
<%= render @articles %>
</div>
<div id="new_article_form">
<%= render "form", article: Article.new %>
</div>Step 4: Update the Article Partial
Make sure each article has a proper DOM ID:
<!-- app/views/articles/_article.html.erb -->
<div id="<%= dom_id(article) %>" class="article">
<h2><%= article.title %></h2>
<p><%= article.body %></p>
<%= link_to "Show", article %>
<%= link_to "Edit", edit_article_path(article) %>
<%= button_to "Delete", article, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
</div>Step 5: Update the Form Partial
Wrap your form with a proper ID:
<!-- app/views/articles/_form.html.erb -->
<div id="article_form">
<%= form_with(model: article) do |form| %>
<% if article.errors.any? %>
<div style="color: red">
<h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>
<ul>
<% article.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
</div>Step 6: Create a Notice Partial
Create a reusable notice partial for flash messages:
<!-- app/views/shared/_notice.html.erb -->
<% if local_assigns[:message] %>
<div class="notice"><%= message %></div>
<% end %>What's Happening?
When you submit a form or click delete, Rails now responds with Turbo Stream instructions that tell the browser exactly what to update. The prepend action adds new articles to the top of the list, replace swaps out updated content, and remove deletes items from the DOM - all without a page refresh.
The beauty of this approach is that it degrades gracefully. If JavaScript is disabled, the HTML format responses kick in and everything still works with traditional page reloads.
Now your Rails forms are turbocharged with Turbo Streams, giving users a smooth, modern experience without writing a single line of JavaScript!
TL;DR
The Client-Side Magic (Turbo JavaScript) explained.
Turbo (automatically included) does:
1. Intercepts the form submission
- Prevents default browser form submit
- Makes an AJAX request instead
2. Receives the Turbo Stream response
- Parses the <turbo-stream> element
- Reads action="replace" and target="article_123"
3. Updates the DOM
- Finds element with id="article_123"
- Replaces it with the HTML from <template>
- No page reload, no flash of content