Dynamic Forms with Hotwire/ Turbo/ Stimulus

Recently I was faced with the challenge of needing to dynamically populate a form based on a previous selection the user made. This is a common problem for web apps, and there have been a myriad of solutions devised, usually using AJAX calls to get the dynamic content.

But this time I was using Hotwire/ Turbo/ Stimulus, and one of the main goals of these libraries is to reduce the amount of Javascript we need to write while building web apps. And yeah, I could have used the same solutions everyone else uses to do this and resort to writing AJAX calls, and that is certainly a viable solution still. But I kinda hate Javascript, and the promise of Hotwire is to let me ignore my Javascript skills even more than I already do, so I went searching for another way – the Hotwire/ Turbo way.

The problem I ran into is that nobody had really figured this out yet and written about it, at least not anywhere I that I could find. I did recently find a GoRails episode that demonstrates a solution to this problem (only for dynamic select fields) using Hotwire, but it still makes a manual AJAX call like all the old solutions, and the only benefit Hotwire provides when used this way is saving you from manually replacing the HTML in the DOM.

In this post, I am going to demonstrate a 99% Ruby/ERB solution that doesn’t require an AJAX call and can dynamically populate as much or as little of your forms as you want with very little effort.

I am going to show snippets of code from my current side project that I have in the works which is a Rails 7 app using all the latest aforementioned technologies. I am a sci-fi nerd and this app is for a table top spaceship game I am creating, so please don’t mind the space-ship references. For brevity’s sake, I will be removing all unrelated code, including CSS, etc, and only showing the stuff that matters.

I am not going over Hotwire/ Turbo/ Stimulus basics here so you will already need to understand how that all works. Checkout the handbooks for turbo and stimulus to get the basics down.

Now without further rambling, let’s get to the code.

First, I have a model:

class ShipDesign < ApplicationRecord
  has_many :ship_design_upgrades, autosave: true, dependent: :destroy

  accepts_nested_attributes_for :ship_design_upgrades, allow_destroy: true

end 

As you can see, the ShipDesign model can accept attributes for its has_many association, :ship_design_upgrades. That model looks something like this:

class ShipDesignUpgrade < ApplicationRecord
  belongs_to :upgrade, :polymorphic => true
end

The polymorphic association can point to either a Weapon or an Ability, which are other models defined in my app.

In the form to create or edit ShipDesigns, I need the following:

  • I needs to dynamically add rows for new ShipDesignUpgrades.
  • It needs to dynamically populate each row with a selection of either Weapons or Abilities after the user selects the upgrade type.

The key to accomplishing the above with no AJAX calls is to wrap my entire form in a turbo_frame, which will allow me to seamlessly replace that turbo_frame with updated server-rendered HTML. This is already a standard practice for using Turbo with forms. My form looks like this:

<%= turbo_frame_tag(dom_id(@ship_design)) do %>

  <%= form_with(model: @ship_design, data: { turbo_frame: dom_id(@ship_design), 
                                             controller: "form-submitter" }) do |form| %>

    <%= form.hidden_field :submission_type, id: "submission-type" %>
    
    #... ship_design fields here

    # Iterate over ship_design_upgrades and output a nested form for each one
    <%= form.fields_for :ship_design_upgrades, @ship_design.ship_design_upgrades  do |upgrade_form| %>

      #... ship_design_upgrade fields here. 

    <% end %>
      
    <%= link_to "",
          data: {
            turbo: false,
            action: "click->form-submitter#specialSubmit"
          } do %>
      Add Upgrade
    <% end %>
    <%= form.hidden_field :add_nested_item %>

    #... form submit button here

  <% end %>
<% end %>

As you can see, the form is wrapped in a turbo_frame, has a hidden input field called submission_type, has a nested form for each of the model’s nested ShipDesignUpgrades using the form.fields_for helper, a link to add a new ShipDesignUpgrade, another hidden input field called add_nested_item, and a stimulus controller connected to the form that responds to the link when it is clicked with its specialSubmit function. The link could just as easily be a button, but that’s not important. What is important is the stimulus controller and the function that is called by clicking the link or button:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  submit(event){
    //...
  }

  specialSubmit(event){
    event.preventDefault();
    let input = event.currentTarget.nextElementSibling
    input.value = true;

    let submission_type = this.element.querySelector('#submission-type')
    if(submission_type !== null){
      submission_type.value = "auto"
    }
    this.element.requestSubmit()
  }

}

When the link is clicked, the stimulus controller is setting both hidden input fields, then submitting the form. The idea here is that by setting the submission_type hidden field to "auto", we can tell our Rails controller that this isn’t a real form submission and that we just want it to re-render our form. By setting the add_nested_item hidden field to true, we are telling our controller to build a new ShipDesignUpgrade on our model before re-rendering, so the form will render a new row inside the form.fields_for block.

I think it’s time to look at how our Rails app is responding to these form submissions from our stimulus controller:

class ShipDesignsController < ApplicationController  
  
  def new
    respond_to do |format|
      format.html do |html|
        html.turbo_frame do
          ship_design = ShipDesign.new(version: current_version)
          render partial: "form", locals: { ship_design: ship_design }
        end
      end
    end
  end

  def create
    ship_design = ShipDesign.new(ship_design_params)
    respond_to do |format|
      if auto_submission?
        format.turbo_stream do
          ship_design.ship_design_upgrades.build if add_nested_item?
          
          render turbo_stream: turbo_stream.update(helpers.dom_id(ship_design), partial: "form", locals: { ship_design: ship_design })
        end
      else
        #...
      end

    end
  end

  def ship_design_params
    ship_design_params = params.require(:ship_design).permit(
      ...,
      ship_design_upgrades_attributes: [
        :id,
        :ship_design_id,
        :upgrade_type,
        :upgrade_id,
        :_destroy
      ]
    )
  end

  def auto_submission?
    params.require(:ship_design).permit(:submission_type)[:submission_type] == "auto"
  end

  def add_nested_item?
    params.require(:ship_design).permit(:add_nested_item)[:add_nested_item] == "true"
  end

end

Here’s the key logic in the create action:

if auto_submission?
  format.turbo_stream do
    ship_design.ship_design_upgrades.build if add_nested_item?
          
    render turbo_stream: turbo_stream.update(helpers.dom_id(ship_design), partial: "form", locals: { ship_design: ship_design })
  end
else

As you can see, it’s checking to see if the form was auto submitted, and then deliberately not trying to save the model if that is the case. Instead, it checks if the add_nested_item param was set to true, and builds a new nested ShipDesignUpgrade if it was. Lastly, it re-renders the form with a model that reflects the requested changes and sends the response back as a turbo_stream.

When the form is re-rendered, the ERB logic will output a new row for the new nested ShipDesignUpgrade here:

<%= form.fields_for :ship_design_upgrades, @ship_design.ship_design_upgrades  do |upgrade_form| %>

  #... ship_design_upgrade fields here

<% end %>

And because of the Hotwire/ Turbo magic, the new content will simply replace the old content seamlessly.

Now, it also needs to dynamically populate a dropdown of selections for each ShipDesignUpgrade. If the user selects the Weapon upgrade_type, it should offer a selection of weapons. If the user selects the Ability upgrade_type, it should offer a selection of Abilities.

The form fields that are rendered for each ShipDesignUpgrade look like this:

<% ship_design_upgrade = upgrade_form.object %>
<%= upgrade_form.hidden_field :id %>
<%= upgrade_form.hidden_field :ship_design_id %>
<%= upgrade_form.hidden_field :_destroy %>
    
<% if ship_design_upgrade.marked_for_destruction? %>
  Deleted
<% else %>
  <%= upgrade_form.select(:upgrade_type,
                          options_for_select([["Weapon", "weapon"], ["Ability", "ability"]]),
                          {},
                          data: {
                            action: "change->form-submitter#submit"
                          } %>

  <% if ship_design_upgrade.upgrade_type == "weapon" %>

    <%= upgrade_form.select(:upgrade_type, 
                            options_for_select(Weapon.all),
                            {} %>

  <% elsif ship_design_upgrade.upgrade_type == "ability" %>
      
    <%= upgrade_form.select(:upgrade_type, 
                            options_for_select(Ability.all),
                            {} %>
        
  <% end %>
<% end %>

The nested form first checks if the the nested item was marked for destruction, and doesn’t render any fields if it was. Otherwise, it renders a select for choosing the upgrade_type of the ShipDesignUpgrade, and then only renders the select for the actual selection of either Abilities or Weapons once the upgrade_type has been chosen.

Now, notice the the data-action on the first select. When the user changes the selection, it’s going to call the submit function on our Stimulus controller. That function is almost identical to the other one we looked at, but sets only the submission_type hidden field:

submit(event){
  let submission_type = this.element.querySelector('#submission-type')
  if(submission_type !== null){
    submission_type.value = "auto"
  }
  this.element.requestSubmit()
}

So once again, the form gets auto submitted, but this time with the newly chosen value for the nested item’s upgrade_type field. The form will then get re-rendered and the ERB logic here will output the selection of Abilities or Weapons accordingly:

<% if ship_design_upgrade.upgrade_type == "weapon" %>

  <%= upgrade_form.select(:upgrade_type, 
                          options_for_select(Weapon.all),
                          {} %>

<% elsif ship_design_upgrade.upgrade_type == "ability" %>
      
  <%= upgrade_form.select(:upgrade_type, 
                            options_for_select(Ability.all),
                            {} %>
       
<% end %>

No updates to our Rails controller code is necessary for this to just start working, since it already know to simply re-render the form and return a turbo_stream whenever the submission_type param is set to true.

Eventually, though, we’ll want to actually save our model by clicking the submit button. That’s where the rest of out controller logic comes in:

if auto_submission?
  #...
else
  if ship_design.save

    format.turbo_stream do
      render turbo_stream: turbo_stream.replace(helpers.dom_id(ship_design), partial: "ship_design", locals: { ship_design: ship_design })
    end

  else

    format.turbo_stream do
      render turbo_stream: turbo_stream.update(helpers.dom_id(ship_design), partial: "form", locals: { ship_design: ship_design }), status: 422

    end
  end
end

When the user clicks the submit button, the submission_type field does not get set so the controller will try to save the model. The code that does this is the standard pattern for Hotwire/ Turbo apps – it basically re-renders the form with errors if the save fails, and returns some other turbo_stream to update your DOM if it succeeds.

Using the pattern for edit/ update actions is almost identical. You just need to retrieve the model from that database, but otherwise everything else is the same.

I hope the appeal of this pattern is obvious. I certainly love to not write Javascript, and when I do write Javascript, AJAX requests are one of my least favorite things to write. This saves me from having to make the annoying choice between a thousand different libraries and APIs to use for making an AJAX request and then reading yet another example of how to do a basic GET call with whatever tool I chose. It seems like whatever I used last time is always obsolete before I need to use it again, since I only do frontend work on the side (my day job is all back end stuff) and struggle to stay fluent in the latest frontend tech.

This approach lets me play to my strengths, which is Ruby and ERB templates, and keeps me out of Javascript land more than any other approach I have found. This is what drew me into being an early adopter for Hotwire/ Turbo, and so far I am very pleased with the kinds of patterns and solutions these technologies make possible.

Some other advantages to this approach which set it apart from the method shown in the GoRails episode I linked to above are:

  • View logic stays right where it should be: in the view. You can easily see the conditions in which the dynamic content will render.
  • You don’t have to implement a bunch of new partials for different types of dynamic content.
  • You don’t need any additional turbo_frames in your form designating different sections of dynamic content.
  • You don’t need any additional controller actions or routes for the dynamic content.

That said, the pattern I have described above may not be the best and certainly has room for improvement, and I am not claiming otherwise. I am sure it has drawbacks, and I can guess at some that are likely to be thrown back at me. But this solution has been working out pretty well for me so far. I would love to hear any feedback you may have about this, and whether you find this useful or not.

Thank you so much for reading. Cheers!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: