class SubscriptionsController < ApplicationController
def new
@subscription = current_account.subscriptions.new
end
def create
@subscription = current_account.subscriptions.new(subscription_params)
@warnings = @subscription.soft_warnings
respond_to do |format|
if @subscription.save
format.turbo_stream # renders create.turbo_stream.erb with warnings
format.html { redirect_to @subscription, notice: "Subscription created" }
else
format.turbo_stream do
render :create, status: :unprocessable_entity
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
private
def subscription_params
params.require(:subscription).permit(:plan, :seats, :billing_email)
end
end
<%# Replace the form so inline field errors re-render %>
<%= turbo_stream.replace "subscription_form" do %>
<%= render "form", subscription: @subscription %>
<% end %>
<%# Keep the warnings container in sync; ids prevent duplicate stacking %>
<% @warnings.each do |warning| %>
<%= turbo_stream.append "warnings" do %>
<%= render "warning", warning: warning %>
<% end %>
<% end %>
<% if @warnings.empty? %>
<%= turbo_stream.update "warnings", "" %>
<% end %>
class Subscription < ApplicationRecord
belongs_to :account
PREMIUM_PLANS = %w[enterprise scale].freeze
validates :plan, presence: true, inclusion: { in: %w[starter growth scale enterprise] }
validates :seats, numericality: { greater_than: 0 }
validates :billing_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
def soft_warnings
warnings = []
if PREMIUM_PLANS.include?(plan) && seats.to_i < 5
warnings << { key: :low_seats, text: "Premium plans are usually billed for 5+ seats." }
end
if account&.trial_ending_soon?
warnings << { key: :trial, text: "This account's trial ends in #{account.trial_days_left} days." }
end
warnings
end
end
<div id="<%= dom_id(OpenStruct.new(id: warning[:key]), :warning) %>"
class="warning-banner"
role="status"
data-controller="dismissable">
<span class="warning-banner__icon" aria-hidden="true">⚠</span>
<p class="warning-banner__text"><%= warning[:text] %></p>
<button type="button"
class="warning-banner__close"
data-action="dismissable#dismiss"
aria-label="Dismiss warning">×</button>
</div>
This snippet shows how server-side validation warnings are streamed back into a form without a full page reload, using Turbo Streams. The pattern separates hard errors (which block submission) from soft warnings (which the user can acknowledge and submit anyway), a distinction plain HTML forms cannot express on their own.
In SubscriptionsController, the create action attempts to save the record and branches on the result. When @subscription.save fails, it responds to the turbo_stream format by rendering a create.turbo_stream.erb template rather than re-rendering the whole page, so only the affected DOM regions are updated. The controller also computes @warnings from a service object even on a successful save, which is the key idea: warnings are advisory and do not fail validation. Falling back to format.html keeps the endpoint usable for non-Turbo clients and progressive enhancement.
The model in Subscription model keeps real invariants in validates, while soft_warnings returns non-blocking advisories — a high price tier, or a trial ending soon. Because these are computed rather than stored, they never pollute errors and never prevent a save. This split matters: mixing warnings into errors.add would incorrectly block the form, and rendering them as blocking messages would train users to ignore them.
The create.turbo_stream.erb template drives the DOM diff. It uses turbo_stream.replace to swap the form partial (so field-level errors render inline) and turbo_stream.append to add each warning into a dedicated #warnings container, keyed by DOM id so repeated submissions replace rather than stack duplicates. The _warning partial wraps each message with dom_id, letting a later stream target and update it precisely.
The trade-off is that the server becomes responsible for HTML fragments and DOM ids, which couples backend templates to markup structure. In exchange there is no client-side validation duplication and the source of truth stays in Ruby. This approach fits forms where rules are non-trivial, change often, or depend on account state that only the server knows. A common pitfall is forgetting the id: on appended nodes, which causes warnings to accumulate on every keystroke or resubmit.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.