More at rubyonrails.org:

Using Hotwire With Rails

Hotwire is Rails' default JavaScript front-end stack. This guide covers Rails' integration with the Hotwire libraries Turbo and Stimulus.

After reading this guide you will know:

  • The libraries that the Hotwire suite consists of.
  • What Turbo is, and how to use Turbo Drive, Turbo Frames, and Turbo Streams in Rails.
  • How Turbo Streams can be delivered over WebSockets with Action Cable.
  • How to emit Turbo Stream updates when Active Record models are created and updated.
  • What Stimulus is and how to use it in Rails.

1. What is Hotwire?

Hotwire is a suite of front-end libraries for building rich, high-fidelity, and modern web applications without the complexities of a single-page application.

Hotwire for the web consists of two libraries:

Rails integrates with Turbo and Stimulus using the turbo-rails and stimulus-rails gems. They're installed by default in all new Rails apps. Install them in existing applications using:

$ bundle add turbo-rails
$ bin/rails turbo:install
$ bundle add stimulus-rails
$ bin/rails stimulus:install

Hotwire also includes Hotwire Native. This library is focused on native mobile applications and hence we won't cover it in this guide.

This guide focuses on Rails' integration with Turbo and Stimulus, and not on the libraries themselves. If you're unfamiliar with them, read the Hotwire docs before continuing with this guide.

2. Turbo

Turbo is the nucleus of Hotwire. It consists of 3 parts: Turbo Drive, Turbo Frames, and Turbo Streams.

Turbo Drive accelerates links and form submissions by intercepting the relevant events and making those HTTP requests using JavaScript. It then swaps out the document's <body> element with the one from the response, eliminating the need for full page loads.

Turbo Frames allow you to decompose pages into independent contexts where navigation and updates can occur without affecting the rest of the page.

Turbo Streams are used to make fine-grained, targeted updates to specific DOM elements using a range of actions such as: prepend, append, replace, and more.

See the Turbo handbook for more information on how Turbo works and its features.

2.1. Turbo Drive

Turbo Drive largely works automatically when imported into your HTML document. It offers a few configuration options, and the ability to define data- attributes and <meta> tags in your HTML to customize behavior. See the handbook and reference for further details.

The turbo-rails gem provides helper methods which define <meta> tags to customize Turbo Drive on specific pages.

All these helpers use provide :head to render the <meta> tag so they can be used in your views — just ensure <%= yield :head %> is declared in your layout's <head> so the tags are injected correctly.

Control a page's caching behavior by setting a turbo-cache-control meta tag:

<%# app/views/posts/show.html.erb %>

<%# Renders <meta name="turbo-cache-control" content="no-cache"> %>
<%= turbo_exempts_page_from_cache %>

<%# Renders <meta name="turbo-cache-control" content="no-preview"> %>
<%= turbo_exempts_page_from_preview %>

Force a full page reload for specific pages with a turbo-visit-control meta tag:

<%# app/views/posts/show.html.erb %>

<%# Renders <meta name="turbo-visit-control" content="reload"> %>
<%= turbo_page_requires_reload %>

Configure morphing page refreshes with:

<%# Renders: %>
<%# <meta name="turbo-refresh-method" content="morph"> %>
<%# <meta name="turbo-refresh-scroll" content="preserve"> %>

<%= turbo_refreshes_with(method: :morph, scroll: :preserve) %>

View the source code for more details.

2.2. Turbo Frames

Turbo Frames use a <turbo-frame> element to isolate parts of a web page into its own navigation context, allowing it to be updated independently from the rest of the page.

Declare a Turbo Frame using turbo_frame_tag:

<%= turbo_frame_tag dom_id(post) do %>
  <div>
     <%= link_to post.title, post_path(post) %>
  </div>
<% end %>

which will render:

<turbo-frame id="post_1">
  <div>
    <a href="/posts/1">My first post</a>
  </div>
</turbo-frame>

All Turbo Frame elements require a unique HTML id attribute, which is used to match the content being replaced when requesting new pages from the server.

An id based on one or more Active Record objects can be generated using the dom_id or dom_target methods.

turbo_frame_tag implicitly calls dom_id — the above example demonstrates it explicitly for clarity, but it may be simplified as: turbo_frame_tag(post). dom_id will be called automatically under the hood.

Options passed to turbo_frame_tag will be applied to the <turbo-frame>:

<%= turbo_frame_tag post src: edit_post_path(post), loading: :lazy, refresh: :morph %>

will render:

<turbo-frame loading="lazy" refresh="morph" id="post_1" src="/posts/1/edit"></turbo-frame>

2.3. Turbo Streams

Turbo Streams are used to perform a series of actions (such as append, update, remove, replace etc.) on specific DOM elements via a <turbo-stream> element. As soon as a <turbo-stream> tag is added to the document, Turbo will execute it and perform the action it defines.

Turbo Streams can be delivered as an HTTP response with the MIME type text/vnd.turbo-stream.html (specified in the content-type HTTP header), or using an alternative delivery mechanism such as WebSockets or server-sent events.

turbo-rails provides helpers to create HTTP responses consisting of Turbo Streams, as well as an integration with Action Cable to broadcast Turbo Streams from the server over WebSockets.

Here's an example showing how to render an ERB template containing Turbo Streams:

<%# app/views/posts/create.turbo_stream.erb %>

<%= turbo_stream.prepend("posts", partial: "posts/post", locals: { post: @post }) %>
<%= turbo_stream.replace("posts_title") do %>
  <%= Post.count %> posts
<% end %>
# app/controllers/posts_controller.rb

# ...

def create
  @post = Post.new(post_params)

  respond_to do |format|
    if @post.save
      format.turbo_stream
      format.html { redirect_to @post, status: :see_other }
    else
      render :new, status: :unprocessable_entity
    end
  end
end

# ...

This action will render the following Turbo Stream response:

<turbo-stream action="prepend" target="posts">
  <template>
    <!-- posts/post.html.erb partial -->
  </template>
</turbo-stream>

<turbo-stream action="replace" target="posts_title">
  <template>
    10 Posts
  </template>
</turbo-stream>

The above approach is useful when defining multiple Turbo Streams. Alternatively, you can render a single Turbo Stream action within your controller:

def create
  @post = Post.new(post_params)

  respond_to do |format|
    if @post.save
      format.turbo_stream do
        # Renders:
        # <turbo-stream action="prepend" target="posts">
        #   <template>
        #     <h2>My New Post</h2>
        #   </template>
        # </turbo-stream>
        render turbo_stream: turbo_stream.prepend("posts", helpers.tag.h2(@post.title))
      end
      format.html { redirect_to @post, status: :see_other }
    else
      render :new, status: :unprocessable_entity
    end
  end
end

2.3.1. Custom Stream Actions

Turbo allows the creation of custom actions for Streams. Consider the below example showing an action which adds a class to an element:

import { StreamActions } from "@hotwired/turbo"

StreamActions.add_class = function() {
  let className = this.getAttribute("class")
  this.targetElements.forEach(e => {
    e.classList.add(className)
  })
}

Since this is a custom action, turbo-rails doesn't define a helper method to render it. It can be rendered using the generic tag helper:

<%= turbo_stream_action_tag(:add_class, target: :posts, class: "font-bold") %>

Alternatively, you can define your own custom helper:

# config/initializers/custom_turbo_streams.rb

ActiveSupport.on_load :turbo_streams_tag_builder do
  def add_class(target, class_name)
    turbo_stream_action_tag(
      :add_class,
      target: target,
      class: class_name
    )
  end
end

The custom action can now be rendered with a more readable syntax:

<%= turbo_stream.add_class(:posts, "font-bold") %>

2.4. Content Security Policy

Rails supports the use of a Content Security Policy (CSP) along with a nonce to restrict the loading of external content such as scripts, stylesheets, or images. If you're unfamiliar with how a Content Security Policy works or what a nonce is, refer to the Security guide.

This section outlines the extra considerations required when using a CSP in Rails with Turbo.

Rails sets the CSP using an HTTP response header, and it is activated by the browser when an HTML document is loaded. Since Turbo intercepts all link clicks and form submissions to perform the HTTP request and update the page using JavaScript, the HTML document is never reloaded during page navigation. Hence, the CSP defined on the page's first load is active until the document is fully reloaded (by a page refresh, for example).

Due to this behavior, Turbo requires a nonce that is constant for the duration of a session — such as the session ID.

# config/initializers/content_security_policy.rb

# ...

# The session ID will be defined in the CSP, and be set as a
# `nonce` attribute in `<script>` elements, allowing them
# to execute.
config.content_security_policy_nonce_generator = ->(request) {
  request.session.id.to_s
}

A user will not have a valid session for their first ever request to your app, resulting in an invalid nonce for that request. To work around this issue, you can use a randomly generated value and store it in the session.

# config/initializers/content_security_policy.rb

# ...

config.content_security_policy_nonce_generator = ->(request) {
  request.session[:nonce] ||= SecureRandom.base64(16)
  request.session[:nonce]
}

Generating a unique nonce for each request isn't compatible with Turbo because the HTML document isn't reloaded during navigation, meaning the CSP cannot be updated with a new request-specific nonce.

Rails renders the nonce in a meta tag in the document's head. Turbo reads the nonce from this tag and applies it to every script element in the content it loads. If an inline script without a valid nonce has been injected into a page's content through an XSS vulnerability, it will be blocked on a full page load, but will execute after a Turbo Drive navigation to the same page.

This functionality can be disabled by excluding the csp_meta_tag directive in your document's head, so the nonce isn't rendered within the page.

Do not add 'strict-dynamic' to your CSP when using Turbo. Turbo uses the createElement method to insert received script tags into the document. 'strict-dynamic' allows scripts inserted in this way to execute regardless of their nonce. If an attacker managed to inject a script into a page that is navigated to using Turbo, the 'strict-dynamic' directive in your CSP would allow it to run even without a nonce.

Further information can be found in the security guide.

3. Turbo Streams over Action Cable

Broadcasting Turbo Streams using Action Cable requires the @hotwired/turbo-rails JavaScript package. It's installed by default in all new Rails apps, and also when you run the turbo:install task provided by the turbo-rails gem:

$ bin/rails turbo:install

Ensure that Action Cable is set up in your application. It is recommended to create an ApplicationCable::Connection class to authenticate Turbo Stream connections:

# app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = authenticate_user
    end

    private
      def authenticate_user
        # Authenticate the user using cookies.

        # Reject the connection using `reject_unauthorized_connection`
        # if authentication fails.
      end
  end
end

Turbo Streams can be received over an Action Cable connection by subscribing to broadcasts on a stream. This can be done in any view template:

<%= turbo_stream_from "feed" %>

Broadcast a Turbo Stream action to this stream using:

Turbo::StreamsChannel.broadcast_action_to(
  "feed",
  action: :append,
  target: "feed_container",
  partial: "feed/item",
  locals: { feed_item: feed_item }
)

The above example broadcasts a Turbo Stream append action to the feed stream. Clients subscribed to this stream will receive the action and execute it, inserting the supplied partial into the DOM element with the id feed_container.

3.1. Subscribing to Streams

turbo_stream_from renders a <turbo-cable-stream-source> element which uses the Action Cable JavaScript library to create connections and subscribe to streams.

<%# Subscribes to a stream named `feed` %>
<%= turbo_stream_from "feed" %>

will render

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="ImZlZWQi--5ce5ecd99ed18f99c989afbb35bfae9f9caebdb8daa050c0ae57adf473be79cd">
</turbo-cable-stream-source>

The supplied stream name is Base64 encoded and signed using ActiveSupport::MessageVerifier to ensure it can't be tampered with.

3.1.1. Stream Names

The stream name must be a string, but other types of objects are often utilized to generate the final string used. Objects used in the generation of stream names must respond to to_gid_param or to_param. Hence, they're usually Active Record objects, strings, or symbols. These are called broadcastables or streamables.

Multiple arguments may be passed to turbo_stream_from to namespace the stream name. In this case, to_gid_param or to_param will be called on each object and then joined with a :.

<%= turbo_stream_from @product, :feed %>

will subscribe to a stream named:

"Z2lkOi8vcmFpbHMtZ3VpZGVzLWRlbW8vUHJvZHVjdC8x:feed"

where Z2lkOi8vcmFpbHMtZ3VpZGVzLWRlbW8vUHJvZHVjdC8x is the Base64 encoded Global ID of the @product object.

The above stream name is presented in plain-text for demonstration — however, when it is rendered to HTML using turbo_stream_from, it will be signed as shown in the previous section.

3.1.2. Custom Channels

<turbo-cable-stream-source> uses the Turbo::StreamsChannel to manage subscriptions by default. This is a generic class provided by the turbo-rails gem and doesn't run any authorization checks on the resource before accepting subscription to a stream. It verifies the signed stream name and then wires up the stream.

class Turbo::StreamsChannel < ActionCable::Channel::Base
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if stream_name = verified_stream_name_from_params
      stream_from stream_name
    else
      reject
    end
  end
end

Since there is no authorization check, a user could theoretically subscribe to another user's stream if they were able to acquire their signed stream name. Prevent this issue by creating your own channel and authorizing the user's permissions before allowing subscription. turbo-rails provides primitives for verifying stream names in custom channels.

Consider the below example where we have a ChatRoomChannel used to broadcast messages to its members:

# app/channels/chat_room_channel.rb

class ChatRoomChannel < ApplicationCable::Channel
  extend Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if chat_room&.authorize(current_user)
      stream_from stream_name
    else
      reject
    end
  end

  private
    def stream_name
      @stream_name ||= verified_stream_name_from_params
    end

    def chat_room
      @chat_room ||= GlobalID::Locator.locate(stream_name)
    end
end

Specify this channel in your view:

<%# Renders:  %>
<%# <turbo-cable-stream-source channel="ChatRoomChannel" signed-stream-name="..."></turbo-cable-stream-source> %>
<%= turbo_stream_from @chat_room, channel: "ChatRoomChannel" %>

The ChatRoomChannel will now manage subscriptions, and will check the user's permissions before accepting the stream ensuring that broadcasts are only delivered to authorized users.

3.2. Broadcasting to Streams

turbo-rails provides a plethora of helper methods to broadcast stream actions. Under the hood, they all call ActionCable.server.broadcast.

A generic broadcast operation:

Turbo::StreamsChannel.broadcast_action_to(
  @chat_room,
  action: :append,
  target: "messages_container",
  partial: "chat_rooms/message",
  locals: { message: message }
)

can be rewritten as an action specific broadcast operation:

Turbo::StreamsChannel.broadcast_append_to(
  @chat_room,
  target: "messages_container",
  partial: "chat_rooms/message",
  locals: { message: message }
)

You can also broadcast a Turbo Stream template containing multiple actions:

Turbo::StreamsChannel.broadcast_render_to(
  @chat_room,
  template: "chat_rooms/update" # Renders `app/views/chat_rooms/update.turbo_stream.erb`
)

or broadcast a refresh action which is useful for morphing:

Turbo::StreamsChannel.broadcast_refresh_to(@chat_room)

Even when using custom channels to handle subscriptions to Turbo Streams, you can use the Turbo::StreamsChannel broadcast helpers to deliver the Stream actions. It will compute the correct stream name based on the supplied broadcastables.

All the above examples render templates and broadcast them synchronously. They can be offloaded to a background job to improve performance by using the later version of the methods such as broadcast_append_later_to.

# enqueues a `Turbo::Streams::ActionBroadcastJob`
Turbo::StreamsChannel.broadcast_append_later_to(
  @chat_room,
  target: "messages_container",
  partial: "chat_rooms/message",
  locals: { message: message }
)

# enqueues a `Turbo::Streams::BroadcastJob`
Turbo::StreamsChannel.broadcast_render_later_to(
  @chat_room,
  template: "chat_rooms/update"
)

Check out the source code for all available helpers.

3.2.1. Broadcastable Model Helpers

The turbo-rails gem contains a Broadcastable concern which is included in ActiveRecord::Base. It applies Rails conventions to succinctly broadcast model-specific Turbo Streams. Some example use cases are:

@product = Product.first

# These helpers implicitly broadcast Stream actions to
# the model object's stream.
#
# <%= turbo_stream_from @product %>

# Broadcasts an `append` action containing the partial
# `products/product` targeted at the DOM element with the id `"products"`.
# Conventionally, the target is the pluralized model name, and the
# partial is obtained by calling `@product.to_partial_path`.
@product.broadcast_append
@product.broadcast_append_later

# The `update` action targets the specific model's HTML element.
# In this case, it will target `"product_1"`, where `1` is the ID of
# the `@product` record. The content will be the partial `products/product`.
# The target is generated using `ActionView::RecordIdentifier.dom_id(@product)`,
# and the partial is obtained by calling `@product.to_partial_path`.
@product.broadcast_update
@product.broadcast_update_later

# The `remove` action targets the specific model's HTML element.
# In this case, it will target `product_1`, where `1` is the ID of
# the `@product` record.
@product.broadcast_remove
@product.broadcast_remove_later

# The partial and target can be explicitly defined if required.
@product.broadcast_append(target: "products", partial: "products/product", locals: { product: @product })
@product.broadcast_append_later(target: "products", partial: "products/product", locals: { product: @product })

# Broadcast to a specific stream
@product.broadcast_append_to("product_feed")
@product.broadcast_append_later_to("product_feed")

These Turbo Stream helpers provided by Broadcastable are most useful in lifecycle callbacks — for example, to append a product to the page whenever a new record is created:

class Product < ApplicationRecord
  after_create_commit -> { broadcast_append_later_to("products") }
end
3.2.1.1. broadcasts_to

The broadcasts_to method configures a model to emit Turbo Streams on creation, update, and deletion to the supplied stream name (provided via a block or method signature).

class Product < ApplicationRecord
  broadcasts_to ->(product) { "#{product.model_name}_stream" }
end
class Product < ApplicationRecord
  broadcasts_to :stream_name

  def stream_name
    "product_stream"
  end
end

The above snippets are equivalent to:

class Product < ApplicationRecord
  after_create_commit -> {
    broadcast_append_later_to("product_stream", target: "products", partial: "products/product")
  }
  after_update_commit -> {
    broadcast_replace_later_to("product_stream", target: ActionView::RecordIdentifier.dom_id(self), partial: "products/product")
  }
  after_destroy_commit -> {
    broadcast_remove_to("product_stream", target: ActionView::RecordIdentifier.dom_id(self))
  }
end

You can customize the partial or template rendered in the broadcast using:

class Product < ApplicationRecord
  # Renders `products/_tile.html.erb`
  broadcasts_to -> { "product_stream" }, partial: "products/tile"

  # Renders `products/update.html.erb`
  broadcasts_to -> { "product_stream" }, template: "products/update"

  # Renders `products/update.turbo_stream.erb`
  broadcasts_to -> { "product_stream" }, template: "products/update", formats: :turbo_stream
end
3.2.1.2. broadcasts

The broadcasts directive emits Turbo Streams to an inferred stream name (the pluralized model name for create, and the model instance's stream for update and remove):

class Product < ApplicationRecord
  broadcasts
end

This can be expanded as:

class Product < ApplicationRecord
  after_create_commit -> {
    broadcast_append_later_to("products", target: "products", partial: "products/product")
  }
  after_update_commit -> {
    broadcast_replace_later_to(self, target: ActionView::RecordIdentifier.dom_id(self), partial: "products/product")
  }
  after_destroy_commit -> {
    broadcast_remove_to(self, target: ActionView::RecordIdentifier.dom_id(self))
  }
end

Customize the rendered partial or template using:

class Product < ApplicationRecord
  # Renders `products/_tile.html.erb`
  broadcasts partial: "products/tile"

  # Renders `products/update.html.erb`
  broadcasts template: "products/update"

  # Renders `products/update.turbo_stream.erb`
  broadcasts template: "products/update", formats: :turbo_stream
end

When using morphing page refreshes, the broadcasts_refreshes declaration will trigger Turbo Streams with the action refresh whenever a model changes:

class Product < ApplicationRecord
  broadcasts_refreshes
end

This is equivalent to:

class Product < ApplicationRecord
  after_create_commit  -> { broadcast_refresh_later_to("products") }
  after_update_commit  -> { broadcast_refresh_later_to(dom_id(self)) }
  after_destroy_commit -> { broadcast_refresh_to(dom_id(self)) }
end

You can specify a stream name using broadcasts_refreshes_to:

class Product < ApplicationRecord
  broadcasts_refreshes_to :stream_name

  def stream_name
    "products"
  end
end

See the API documentation for all available helpers.

4. Stimulus

Stimulus is a lightweight library to manipulate HTML with reusable pieces of JavaScript logic encapsulated in a JavaScript object, called a controller.

Stimulus has an HTML-centric way of writing JavaScript. The markup is connected to the controller using a range of data- attributes.

Here's an example of a Stimulus controller:

// hello_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "name", "output" ]

  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

The above controller uses targets, which are named references to elements in its HTML scope, to grab an input's value and display a greeting. The greet() action reads the name target and writes it into the output target.

It can be attached to the DOM via the data-controller attribute:

<div data-controller="hello">
  <input data-hello-target="name" type="text">

  <button data-action="click->hello#greet">
    Greet
  </button>

  <span data-hello-target="output">
  </span>
</div>

Refer to the Stimulus handbook and reference for complete usage information.

4.1. Creating Controllers

The stimulus-rails gem provides a generator to create Stimulus controllers:

$ bin/rails generate stimulus hello
# Generates app/javascript/controllers/hello_controller.js

This generates the skeleton of a controller:

// app/javascript/controllers/hello_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="hello"
export default class extends Controller {
  connect() {
  }
}

When using an import map to deliver your JavaScript, Rails will automatically eager load all your Stimulus controllers:

// app/javascript/controllers/index.js

// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

In other JavaScript setups, controllers are loaded manually:

// app/javascript/controllers/index.js

import { application } from "./application"

import HelloController from "./hello_controller"
application.register("hello", HelloController)

This manifest is automatically updated when you create a controller using bin/rails generate stimulus. Force an update by running:

$ bin/rails stimulus:manifest:update

This might be useful after renaming a controller.

4.2. Using Controllers

In Rails, use Stimulus controllers for client-side use cases where Turbo doesn't apply. It's best used to modify HTML you already have.

In this section we'll discuss an example use case: implementing a button which allows a user to dynamically add text fields to a Rails form.

Consider the below form to create a post:

<%= form_with(model: @post) do |form| %>
  <fieldset>
    <%= form.label :title %>
    <%= form.text_field :title %>
  </fieldset>

  <fieldset>
    <%= form.label :body %>
    <%= form.text_area :body %>
  </fieldset>

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

We'd like to allow the user to create one or more tags to associate with the post. This requires an Add Tag button which dynamically adds a text field to the form where the user can define the tag. This is a perfect use case for Stimulus.

First, ensure the model and controller can accept nested attributes for the tag:

class Post < ApplicationRecord
  has_many :tags

  accepts_nested_attributes_for :tags, allow_destroy: true, limit: 5
end
class PostsController < ApplicationController
  def create
    @post = Post.new(post_params)
    # ...
  end

  private
    def post_params
      params.expect(post: [:title, :body, tags_attributes: [:content, :_destroy]])
    end
end
class Tag < ApplicationRecord
  belongs_to :post
end

Then, we need to add a <template> element to the form which the Stimulus controller will use to add the text field:

<%= form_with(model: @post) do |form| %>
  <%# ... %>

  <template>
    <%= form.fields_for :tags, @post.tags.build, child_index: "NEW_RECORD" do |tag_fields| %>
      <fieldset data-new-record="true">
        <%= tag_fields.text_field :content %>
        <%= tag_fields.hidden_field :_destroy %>

        <%= tag_fields.button type: :button do %>
          Remove
        <% end %>
      </fieldset>
    <% end %>
  </template>

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

Specifying the NEW_RECORD placeholder as the child_index allows us to replace it in the Stimulus controller to prevent clashes in elements' id and name attributes.

Next, generate and implement a Stimulus controller to read this <template> and add it to the form:

$ bin/rails generate stimulus nested_form
// app/javascript/controllers/nested_form_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="nested-form"
export default class extends Controller {
  static targets = ["container", "template"]

  add(event) {
    event.preventDefault()

    // Replace `NEW_RECORD` with the timestamp to ensure each new element is unique
    let timestamp = new Date().getTime().toString()
    let content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, timestamp)
    this.containerTarget.insertAdjacentHTML("beforeend", content)
  }

  remove(event) {
    event.preventDefault()

    let wrapper = event.target.closest("fieldset")

    // For new `Tag` records, we can simply remove the HTML element
    if (wrapper.dataset.newRecord === "true") {
      wrapper.remove()

    // When a `Tag` record exists, mark it for deletion on the server
    // and hide it in the UI.
    } else {
      wrapper.style.display = "none"

      let input = wrapper.querySelector("input[name*='_destroy']")
      input.value = "1"
    }
  }
}

The final step is to wire up the Stimulus controller in the DOM:

<%= form_with(model: @post, data: { controller: "nested-form" }) do |form| %>
  <%# ... %>

  <%# The container to which the text fields will be added %>
  <div data-nested-form-target="container">
  </div>

  <button type="button" data-action="nested-form#add">
    Add tag
  </button>

  <template data-nested-form-target="template">
    <%= form.fields_for :tags, @post.tags.build, child_index: "NEW_RECORD" do |tag| %>
      <fieldset data-new-record="true">
        <%= tag.text_field :content %>
        <%= tag.hidden_field :_destroy %>

        <%= tag.button type: :button, data: { action: "nested-form#remove" }  do %>
          Remove
        <% end %>
      </fieldset>
    <% end %>
  </template>

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

The user can now dynamically add tags to their post which will be processed on the server.

This is just one example where Stimulus fits the bill. Use it wherever you need to manipulate HTML in your web page. Some more example use cases are:

  • Keyboard hotkeys which trigger actions in your app.
  • Resize an HTML textarea automatically as a user types.
  • A drag and drop interface to upload files.


Back to top