Building Nested Forms in Rails with Stimulus

Happy Russian dolls with warm and bright colors with a perspective from the top-right.

In a previous article I wrote about nested forms with Turbo and no other dependencies. This is a great solution that works remarkably well, but I want to explore another option using Stimulus.

Why explore another option? While the Turbo solution works great, there might be cases the round-trip to the server might be a bit much for simple, static HTML. After all, there’s no extra data needed from the server to render the nested fields.

So let’s build the same feature, but now with Stimulus instead of Turbo.

Image description

Also for this article, I assume you have a modern Rails app ready and the following basics in place:

  • Survey; rails generate model Survey name
  • Question; rails generate model Question survey:belongs_to content:text

Update the Survey model:

class Survey < ApplicationRecord
  has_many :questions
  accepts_nested_attributes_for :questions
end

Add a simple controller to go with it. rails generate controller Surveys show new. Then update it as follows:

class SurveysController < ApplicationController
  def show
    @survey = Survey.find(params[:id])
  end

  def new
    @survey = Survey.new
  end

  def create
    @survey = Survey.new(survey_params)

    if @survey.save
      redirect_to @survey
    else
      render :new
    end
  end

  private

  def survey_params
    params.require(:survey).permit(:name, questions_attributes: [:content])
  end
end

As mentioned in the earlier article, make sure the survey_params are set up like above.

Now update the two views:

<h1>New Survey</h1>

<%= form_with model: @survey do |form| %>
  <div>
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div></div>

  <div>
    <%= button_tag "Add Question", type: :button %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

And the show action (just so there’s something to see after create):

<h1><%= @survey.name %></h1>

<% @survey.questions.each do |question| %>
  <p>
    <%= question.content %>
  </p>
<% end %>

And as a last step add resources :surveys, only: %w[show new create] to config/routes.rb (and remove the generated routes).

Now all the basics are in place and it is exactly the same as before with the Turbo version.

Nested Forms using Stimulus

The approach will be to render a _questions_fields partial in a <template /> element that can then be injected into a HTML element.

Let’s create that partial (app/views/surveys/_question_fields.html.erb) first:

<div>
  <%= form.label :content, "Question" %>
  <%= form.text_area :content %>
</div>

Next up is the Stimulus controller that will make this all work: rails generate stimulus nested-fields.

Let’s first wire up the HTML and then look at the Stimulus controller. Inside the app/views/surveys/new.html.erb let’s make a few changes:

<%= form_with model: @survey, data: {controller: "nested-fields"} do |form| %>
   # …

  <div data-nested-fields-target="fields"></div>

  <div>
    <%= button_tag "Add Question", type: :button, data: {action: "nested-fields#append"} %>

    <template>
      <%= fields_for 'survey[questions_attributes][]', Question.new, index: "__INDEX__" do |form| %>
        <%= render "surveys/question_fields", form: form %>
      <% end %>
    </template>
  </div>

    # …
<% end %>

From top to bottom:

  • add the nested-fields controller to the form;
  • add the target for the questions;
  • add the action to the button;
  • insert the template with the question_fields partial (notice the index: "__INDEX__").

Alright, let’s look at the Stimulus controller. It will be pretty straightforward!

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["fields", "template"];

  append() {
    this.fieldsTarget.insertAdjacentHTML("beforeend", this.#templateContent);
  }

  // private

  get #templateContent() {
    return this.templateTarget.innerHTML.replace(/__INDEX__/g, Date.now());
  }
}

Most of this is simple enough. Within the append() function, the result of this.#templateContent is added before the end of this.fieldsTarget.

Within #templateContent() there is something more interesting going on. It generates an unique “identifier” for new nested form fields by replacing theINDEX placeholder with a timestamp. This is needed so Rails can differentiate between multiple new records in a single form submission, allowing it to correctly process and save all added nested attributes. If the value would be static, it would only save the last added nested field.

Check out localhost:3000/surveys/new and you should be able to insert multiple question fields. 🥳

And there you have it: the basics of nested fields using Stimulus and no third-party dependencies. 🤯 Again, just with the Turbo version, there is still room for improvement here: removing a nested field or maybe even re-ordering nested fields. I will leave that up to you. 💪

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Last updated at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built using ViewComponent

  • Designed with Tailwind CSS

  • Enhanced with Hotwire