Skip to main content

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:

  1. 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
  2. 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:

  1. Clear separation between form manipulation and data persistence
  2. Simpler error handling (form validation happens only on final submission)
  3. Atomic updates (all changes are saved together)
  4. 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

  1. For new records:

    • Generate a temporary ID (using timestamp)
    • Store it in both id and fallback_id
    • Use it for DOM IDs and form field indexes
  2. For persisted records:

    • Use the actual database ID
    • fallback_id serves as a backup reference
  3. During form submission:

    • Fields maintain their indexes via fallback_id
    • Rails can properly associate nested attributes

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.