← All posts

Rails Partials + Stimulus Controllers as Reusable Components

February 14, 2024 rails stimulus

You don’t need a JavaScript framework to build reusable UI components in Rails. A partial gives you the markup and a Stimulus controller gives you the behavior. Pair them together with a clear naming convention and you get something that works like a component library without any additional dependencies.

The Convention

For each component, create two files with matching names:

app/views/components/_dropdown.html.erb
app/javascript/controllers/dropdown_controller.js

The partial handles structure and accepts locals for configuration. The controller handles all client-side behavior. The data-controller attribute in the partial connects the two.

Example: Dropdown

The partial (app/views/components/_dropdown.html.erb):

<div data-controller="dropdown" class="dropdown">
  <button data-action="dropdown#toggle" class="dropdown-trigger">
    <%= label %>
  </button>
  <div data-dropdown-target="menu" class="dropdown-menu hidden">
    <%= yield %>
  </div>
</div>

The controller (app/javascript/controllers/dropdown_controller.js):

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["menu"]

  toggle() {
    this.menuTarget.classList.toggle("hidden")
  }

  close(event) {
    if (!this.element.contains(event.target)) {
      this.menuTarget.classList.add("hidden")
    }
  }

  connect() {
    this.boundClose = this.close.bind(this)
    document.addEventListener("click", this.boundClose)
  }

  disconnect() {
    document.removeEventListener("click", this.boundClose)
  }
}

Usage anywhere in the app:

<%= render "components/dropdown", label: "Options" do %>
  <a href="/settings">Settings</a>
  <a href="/logout">Log out</a>
<% end %>

Passing Configuration via Data Attributes

Use Stimulus values when a component needs options that vary per instance.

Partial (app/views/components/_auto_dismiss.html.erb):

<div data-controller="auto-dismiss"
     data-auto-dismiss-delay-value="<%= delay || 5000 %>"
     class="flash-message">
  <%= message %>
  <button data-action="auto-dismiss#dismiss">×</button>
</div>

Controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { delay: { type: Number, default: 5000 } }

  connect() {
    this.timeout = setTimeout(() => this.dismiss(), this.delayValue)
  }

  dismiss() {
    clearTimeout(this.timeout)
    this.element.remove()
  }
}

Now the same component can auto-dismiss after 3 seconds or 10 seconds depending on where it’s rendered:

<%= render "components/auto_dismiss", message: "Saved.", delay: 3000 %>

Nested Components

Components can contain other components. Just nest the partials — Stimulus controllers scope to their own data-controller element so they won’t interfere with each other.

<%= render "components/modal", title: "Confirm" do %>
  <p>Are you sure?</p>
  <%= render "components/dropdown", label: "Options" do %>
    <a href="#">Option A</a>
    <a href="#">Option B</a>
  <% end %>
<% end %>

Tips

  • Keep the components/ directory flat. If you need subdirectories, you probably need a ViewComponent instead.
  • One controller per partial. If a partial needs two controllers, it’s probably two components.
  • Use connect() and disconnect() for setup and teardown — event listeners, timers, observers. Stimulus calls these automatically when the element enters or leaves the DOM, which means they work with Turbo navigation out of the box.
  • Don’t put logic in the partial. Locals should be simple values. If you’re writing conditionals or querying data in the partial, move that to a helper or presenter.
  • Test them separately. System tests cover the full interaction. Controller tests (via stimulus-test or similar) can cover the JS behavior in isolation.

That’s it. No gem, no framework, no build configuration. Just a naming convention and the tools Rails already gives you.

Let’s work together! Tell Me About Your Project