Nested forms with just Turbo Streams
Edit 11.12.2024: I’ve updated turbo stream responses to include field index.
Recently I was working on implementing dynamic nested forms using Turbo Streams, focusing on handling both new and persisted records without custom JavaScript. Well, mostly for fun, to try something new. And while there are multiple approaches to this problem, including Stimulus controllers or plain JavaScript, Turbo Streams offer a clean, server-driven solution that leverages Rails conventions.
The Core Concept: Form Manipulation vs Persistence
A key aspect of this implementation that might not be immediately obvious is that the IngredientsController
doesn’t actually persist any data. Its sole responsibility is to manipulate the form structure through Turbo Stream responses.
The IngredientsController
class IngredientsController < ApplicationController
def create
@recipe = Recipe.new
@ingredient = @recipe.ingredients.build
@ingredient.id = Time.current.to_i
@ingredient.fallback_id = @ingredient.id
respond_to do |format|
format.turbo_stream
end
end
def destroy
@ingredient = Ingredient.find_or_initialize_by id: params[:id]
@ingredient.fallback_id = params[:idx]
respond_to do |format|
format.turbo_stream
end
end
end
What’s happening here:
-
The
create
action:- Doesn’t actually create anything in the database
- Simply builds a new in-memory ingredient object
- Assigns temporary IDs for DOM manipulation
- Renders a Turbo Stream response that appends new form fields
-
The
destroy
action:- Doesn’t actually delete anything from the database
- Either removes form fields for unpersisted records
- Or adds a hidden
_destroy
field for persisted records - All actual deletion happens when the parent form is submitted
The form
<%= form_with(model: recipe, class: "contents") do |form| %>
<div class="my-5">
<%= form.label :name, class: "label" %>
<%= form.text_field :name, class: "input input-bordered w-full max-w-xs" %>
</div>
<fieldset class="border p-2 flex flex-col gap-2" id="ingredients">
<%= form.fields_for :ingredients do |ff| %>
<%= render "ingredients/form_field", f: ff, fallback_id: SecureRandom.uuid %>
<% end %>
</fieldset>
<%= turbo_frame_tag "new_form_field" do %>
<%= link_to "Add new ingredient", ingredients_path, data: { turbo_method: :post } %>
<% end %>
<div class="pt-4 border-t border-red-600 flex w-full justify-end items-center gap-2">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
We’re wrapping “Add new ingredient” link into turbo_frame_tag
to elegantly enforce turbo request. Nested fields are wrapper withing #ingredient
fieldset, so we can easily append new fields to it.
Ingredient form_field
looks like this:
<%= turbo_frame_tag "ingredient_#{f.object.id || f.object.fallback_id}-field" do %>
<%= f.hidden_field :fallback_id %>
<div id="<%= "#{dom_id(f.object)}" %>" class="flex flex-row border border-rose-200 rounded-md p-2">
<div class="flex flex-row gap-1 w-full grow">
<%= f.text_field :quantity, placeholder: "qty", class: 'input input-bordered shrink' %>
<%= f.text_field :unit, placeholder: "unit", class: 'input input-bordered shrink' %>
<%= f.text_field :name, placeholder: "name", class: 'input input-bordered grow' %>
</div>
<%= link_to ingredient_path(f.object.id || f.object.fallback_id, idx: f.index), data: { turbo_method: :delete }, class: "w-8" do %>
trash icon
<% end %>
</div>
<% end %>
The Turbo Stream Responses
# create.turbo_stream.erb
<%= turbo_stream.append "ingredients" do %>
<%- idx = Time.current.to_i %>
<%= fields_for "recipe[ingredients_attributes]", @ingredient, index: idx do |ff| %>
<%= render "ingredients/form_field", f: ff, fallback_id: idx %>
<% end %>
<% end %>
# destroy.turbo_stream.erb
<%- id = "#{dom_id(@ingredient)}-field" %>
<% if @ingredient.persisted? %>
<%= turbo_stream.update id do %>
<%= hidden_field_tag "recipe[ingredients_attributes][#{@ingredient.fallback_id}][_destroy]", "1" %>
<% end %>
<% else %>
<%= turbo_stream.remove id %>
<% end %>
The actual persistence happens only when the parent recipe form is submitted. The RecipesController
handles all the actual database operations through Rails’ nested attributes:
def recipe_params
params.require(:recipe).permit(
:name,
ingredients_attributes: [:id, :name, :unit, :quantity,
:fallback_id, :_destroy]
)
end
This separation of concerns is crucial:
- The
IngredientsController
handles form manipulation - The
RecipesController
handles actual data persistence - Turbo Streams provide the bridge between these operations
This approach offers several benefits:
- Clear separation between form manipulation and data persistence
- Simpler error handling (form validation happens only on final submission)
- Atomic updates (all changes are saved together)
- Better user experience (immediate form updates with deferred saving)
Understanding this distinction is crucial when implementing similar patterns. The IngredientsController
actions might look incomplete at first glance, but they’re doing exactly what they need to do: managing the form’s structure through Turbo Streams, while leaving the actual persistence to the parent form’s submission.
This pattern can be particularly useful when:
- Building complex nested forms
- Handling multiple levels of nested attributes
- Implementing dynamic form manipulation
- Maintaining clean, separated concerns in your controllers
Remember: The goal isn’t to persist data with every form change, but to provide a smooth, dynamic form experience while maintaining data integrity through the final form submission.
The Fallback ID Pattern: A Necessary Evil
One of the trickier aspects of handling dynamic nested forms is managing identifiers for new records that haven’t been persisted yet. Rails needs consistent field names and indexes, and DOM elements need stable IDs. Enter the fallback_id
pattern - a bit hack-ish, but effective solution.
The Problem
When working with nested forms, we face several identifier-related challenges:
- New records don’t have database IDs yet
- DOM elements need stable, unique identifiers
- Form field names must maintain consistent indexing
- We need to track which fields correspond to which records
The Fallback ID Solution
def create
@recipe = Recipe.new
@ingredient = @recipe.ingredients.build
@ingredient.id = Time.current.to_i # Temporary ID
@ingredient.fallback_id = @ingredient.id # Store for later reference
respond_to do |format|
format.turbo_stream
end
end
The form field partial uses this fallback mechanism:
<%= turbo_frame_tag "ingredient_#{f.object.id || f.object.fallback_id}-field" do %>
<%= f.hidden_field :fallback_id %>
<div id="<%= dom_id(f.object) %>">
<!-- form fields -->
</div>
<% end %>
How It Works
-
For new records:
- Generate a temporary ID (using timestamp)
- Store it in both
id
andfallback_id
- Use it for DOM IDs and form field indexes
-
For persisted records:
- Use the actual database ID
fallback_id
serves as a backup reference
-
During form submission:
- Fields maintain their indexes via
fallback_id
- Rails can properly associate nested attributes
- Fields maintain their indexes via
The Hack-ish Parts
This approach has some “code smell” aspects:
- Using timestamps as temporary IDs isn’t guaranteed unique
- We’re hijacking the
id
attribute of unpersisted records fallback_id
isn’t a “real” model concern- It’s a workaround for Rails’ form helper limitations
This pattern could have been refactored to nicely hide fallback_id
, but to illustrate the solution and to make it easier to grasp, I’ve decided to leave it as it is.
I’ve uploaded working example (full source) to GitHub.