Supercharging Your Rails Forms with Turbo Streams

15 min read

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:migrate

This 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
end

What 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