Rails Partials + Stimulus Controllers as Reusable Components
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()anddisconnect()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-testor 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.