Hotwire Simplified: Turbo and Stimulus in Rails — Ruby Deep Dive[16]
Turbo: Modern interactive features
Turbo is a powerful frontend framework for Rails applications that allows you to create fast, responsive web applications without writing complex JavaScript. It consists of three main components: Turbo Drive, Turbo Frames, and Turbo Streams. Each component serves a specific purpose and can be used independently or in combination to create dynamic, interactive web pages.
Turbo Drive
Turbo Drive is the foundation of Turbo. It accelerates links and form submissions by using AJAX to fetch new pages without a full page reload. This creates a smoother, more app-like experience for users.
Key Features:
- Intercepts link clicks and form submissions
- Fetches new content via AJAX
- Updates the browser’s history and URL
- Swaps the `<body>` of the page with the new content
When to Use:
- When you want to improve the overall speed and responsiveness of your application
- For traditional multi-page applications that you want to make feel more like single-page apps
Turbo Frames
Turbo Frames allow you to update specific parts of a page independently. They’re perfect for creating modular, reusable components that can be loaded and updated asynchronously.
Key Concepts:
- Frame Definition:
<%= turbo_frame_tag "my_frame" do %>
<!-- Frame content -->
<% end %>
2. Targeting Frames:
- Use `data-turbo-frame` attribute on links or forms to target a specific frame
- Example:
<%= link_to "Edit", edit_item_path(@item), data: { turbo_frame: "@item.id">@item.id">item_#{@item.id}" } %>
Targeting frames allows you to load content into a specific Turbo Frame from anywhere on the page. This is particularly useful for updating a frame’s content without reloading the entire page.
How it works:
- You add a
data-turbo-frame
attribute to a link or form. - When the link is clicked or the form is submitted, Turbo intercepts the request.
- Turbo fetches only the content of the targeted frame from the server.
- The new content replaces the existing content of the targeted frame.
Example:
<!-- In a list of items -->
<ul>
<% @items.each do |item| %>
<li>
<%= item.name %>
<%= link_to "View Details", item_path(item), data: { turbo_frame: "item_details" } %>
</li>
<% end %>
</ul>
<!-- Somewhere else on the page -->
<%= turbo_frame_tag "item_details" do %>
<!-- This content will be replaced when a "View Details" link is clicked -->
<p>Select an item to view details</p>
<% end %>
In this example, clicking a “View Details” link will load the item’s details into the “item_details” frame without reloading the entire page.
3. Lazy Loading:
- Use `src` attribute to load content asynchronously
- Example:
<%= turbo_frame_tag "lazy_loaded_content", src: items_path %>
Lazy loading with Turbo Frames allows you to defer loading frame content until it’s needed. This can significantly improve initial page load times, especially for content-heavy pages.
How it works:
- You create a Turbo Frame with a
src
attribute pointing to a URL. - Initially, the frame is empty or contains placeholder content.
- When the frame becomes visible (e.g., by scrolling), Turbo automatically fetches the content from the specified URL.
- The fetched content replaces the frame’s initial content.
Example:
<%= turbo_frame_tag "lazy_loaded_comments", src: post_comments_path(@post) do %>
<p>Loading comments...</p>
<% end %>
In this example, the comments for a post will only be loaded when the frame becomes visible in the viewport. Until then, “Loading comments…” is displayed as a placeholder.
4. Frame Responses:
Controllers should respond with just the frame content for frame requests
When to Use:
- For updating specific parts of a page without reloading the entire page
- Creating reusable components (e.g., forms, lists, detail views)
- Implementing lazy loading for better performance
Example: Edit Form in a Frame
<! - In index.html.erb →
<%= turbo_frame_tag "menu_item_#{@menu_item.id}_form" do %>
<%= link_to "Edit", edit_menu_item_path(@menu_item) %>
<% end %>
<! - In edit.html.erb →
<%= turbo_frame_tag "menu_item_#{@menu_item.id}_form" do %>
<%= render 'form', menu_item: @menu_item, restaurant: @restaurant %>
<% end %>
This setup allows you to load the edit form within the same frame where the “Edit” link was, providing a seamless editing experience.
Another practical example
<!-- app/views/todos/index.html.erb -->
<%= turbo_frame_tag "todo_list" do %>
<% @todos.each do |todo| %>
<%= render todo %>
<% end %>
<% end %>
<!-- app/views/todos/_todo.html.erb -->
<%= turbo_frame_tag dom_id(todo) do %>
<h2><%= todo.title %></h2>
<%= link_to "Edit", edit_todo_path(todo) %>
<% end %>
<!-- app/views/todos/edit.html.erb -->
<%= turbo_frame_tag dom_id(@todo) do %>
<%= form_with(model: @todo) do |f| %>
<%= f.text_field :title %>
<%= f.submit %>
<% end %>
<% end %>
Explanation:
- In
index.html.erb
:
- We create a Turbo Frame with the ID “todo_list”.
- This frame contains all the todos, each rendered using the
_todo.html.erb
partial.
2. In _todo.html.erb
(the partial for a single todo):
- Each todo is wrapped in its own Turbo Frame.
- The frame ID is generated using
dom_id(todo)
, which creates a unique ID like "todo_1", "todo_2", etc. - It displays the todo title and an “Edit” link.
3. In edit.html.erb
:
- The edit form is wrapped in a Turbo Frame with the same ID as the todo (
dom_id(@todo)
). - This ensures that when the “Edit” link is clicked, only this specific todo’s frame is updated with the edit form.
How it works:
- When the index page loads, it displays all todos within the “todo_list” frame.
- Clicking the “Edit” link for a specific todo sends a request to the server.
- The server responds with the content of
edit.html.erb
. - Turbo intercepts this response and replaces only the content of the matching frame (e.g., “todo_1”) with the edit form.
- The user can edit and submit the form, and only that specific todo’s frame will be updated with the new content.
This setup allows for seamless in-place editing without full page reloads, providing a smooth, app-like experience.
Turbo Streams
Turbo Streams enable real-time updates to multiple parts of a page using a set of CRUD-like actions. They’re perfect for creating live-updating interfaces and handling complex interactions.
Key Concepts:
- Stream Actions:
- append
- prepend
- replace
- update
- remove
2. Controller-based Streams:
- Create
*.turbo_stream.erb
views - Respond with
format.turbo_stream
in controllers
3. Model-based Streams:
- Use callbacks and the
broadcasts_to
method - Leverage the Broadcastable module for more advanced functionality
When to Use:
- Real-time updates (e.g., chat applications, live notifications)
- Complex form submissions with multiple update targets
- Any scenario where you need to update multiple parts of the page simultaneously
Example: Controller-based Stream
# posts_controller.rb
def create
@post = Post.new(post_params)
if @post.save
respond_to do |format|
format.turbo_stream
end
else
render :new
end
end
<!-- create.turbo_stream.erb -->
<%= turbo_stream.prepend "posts_list", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.update "new_post_form", partial: "posts/form", locals: { post: Post.new } %>
Example: Model-based Stream
# post.rb
class Post < ApplicationRecord
after_create_commit { broadcast_append_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
end
Controller-based vs. Model-based Streams
Controller-based Streams
Use controller-based streams when:
- You need to update multiple models or perform complex logic before streaming updates.
- The update depends on user input or other request parameters.
- You want fine-grained control over what gets updated and how.
Example:
# posts_controller.rb
def create
@post = Post.new(post_params)
if @post.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("posts", partial: "posts/post", locals: { post: @post }),
turbo_stream.update("post_count", Post.count),
turbo_stream.replace("flash_messages", partial: "shared/flash_messages")
]
end
end
else
render :new
end
end
Model-based Streams
Use model-based streams when:
- You want to broadcast updates to all subscribers automatically.
- The update is straightforward and doesn’t require complex logic.
- You want to keep your controllers slim and move broadcasting logic to the model.
Example:
# post.rb
class Post < ApplicationRecord
broadcasts_to ->(post) { "posts" }
after_create_commit { broadcast_prepend_to "posts" }
after_update_commit { broadcast_replace_to "posts" }
after_destroy_commit { broadcast_remove_to "posts" }
end
Broadcastable Module Methods
The Turbo::Broadcastable
module provides several methods for broadcasting updates. Here's a comprehensive list:
broadcasts_to
:
- Defines the stream name for broadcasting.
- Example:
broadcasts_to ->(record) { "posts" }
broadcast_prepend_to
:
- Prepends the record to the specified target.
- Example:
broadcast_prepend_to "posts", target: "post_list"
broadcast_append_to
:
- Appends the record to the specified target.
- Example:
broadcast_append_to "posts", target: "post_list"
broadcast_replace_to
:
- Replaces the existing record in the specified target.
- Example:
broadcast_replace_to "posts", target: "post_#{id}"
broadcast_update_to
:
- Updates the content of the specified target.
- Example:
broadcast_update_to "posts", target: "post_count", html: Post.count
broadcast_remove_to
:
- Removes the record from the specified target.
- Example:
broadcast_remove_to "posts"
broadcast_action_to
:
- Broadcasts a custom action.
- Example:
broadcast_action_to "posts", action: :highlight, target: "post_#{id}"
broadcast_render_to
:
- Broadcasts rendered content to the specified target.
- Example:
broadcast_render_to "posts", partial: "posts/post", locals: { post: self }
broadcast_before_to
and broadcast_after_to
:
- Inserts content before or after the specified target.
- Example:
broadcast_before_to "posts", target: "post_#{id}", partial: "posts/new_post_notification"
These methods accept various parameters:
target
: The ID of the HTML element to update.partial
: The partial to render (for methods that render content).locals
: Local variables to pass to the partial.html
: Raw HTML content to insert (use with caution).action
: Custom action forbroadcast_action_to
.
Example using multiple parameters:
broadcast_prepend_to "restaurants",
target: "restaurant_list",
partial: "restaurants/restaurant",
locals: { restaurant: self, highlighted: true }
This would prepend the rendered partial to the element with ID “restaurant_list” within the “restaurants” stream, passing the restaurant object and a highlighted
local variable to the partial.
By understanding these methods and their parameters, you can create sophisticated real-time updates in your Rails application with minimal effort.
Stimulus: Enhancing Interactivity
Stimulus is a JavaScript framework that complements Turbo by adding client-side interactivity to your Rails application. It follows a “modest JavaScript” approach, focusing on enhancing existing HTML rather than taking over the entire front-end.
Key Concepts:
- Controllers: JavaScript classes that add behavior to HTML elements.
- Actions: DOM events that trigger controller methods.
- Targets: Important elements that the controller needs to access.
- Values: Controller-specific data attributes.
Controller Basics:
- Define a controller:
// app/javascript/controllers/flash_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("Flash controller connected")
}
close() {
this.element.remove()
}
}
2. Use the controller in your HTML:
<div data-controller="flash">
<p>This is a flash message</p>
<button data-action="click->flash#close">Close</button>
</div>
Actions:
Actions in Stimulus connect DOM events to controller methods. The syntax is:
data-action="[EVENT]->[CONTROLLER]#[METHOD]"
Example:
<button data-action="click->flash#close">Close</button>
This triggers the close()
method of the flash
controller when the button is clicked.
Here’s a comprehensive list of commonly used actions:
- click: Triggered when an element is clicked.
<button data-action="click->controller#method">Click me</button>
2. submit: Used with forms, triggered when a form is submitted.
<form data-action="submit->controller#handleSubmit">
3. input: Triggered when the value of an input element changes.
<input type="text" data-action="input->controller#handleInput">
4. change: Similar to input, but only triggered when the element loses focus.
<select data-action="change->controller#handleChange">
5. keydown, keyup, keypress: Keyboard events.
<input data-action="keydown->controller#handleKeydown">
6. focus and blur: Triggered when an element gains or loses focus.
<input data-action="focus->controller#handleFocus blur->controller#handleBlur">
7. mouseenter and mouseleave: Triggered when the mouse enters or leaves an element.
<div data-action="mouseenter->controller#handleMouseEnter mouseleave->controller#handleMouseLeave">
8. dragstart, dragend, dragover, dragenter, dragleave, drop: Used for drag and drop functionality.
<div data-action="dragstart->controller#handleDragStart dragend->controller#handleDragEnd">
You can use any valid DOM event as an action in Stimulus. Multiple actions can be attached to a single element:
<input data-action="input->controller#handleInput focus->controller#handleFocus blur->controller#handleBlur">
Targets:
Targets are important elements that your controller needs to interact with. They’re defined in the controller and referenced in the HTML.
- Define targets in the controller:
export default class extends Controller {
static targets = [ "output" ]
display() {
this.outputTarget.textContent = "Hello, Stimulus!"
}
}
2. Use targets in HTML:
<div data-controller="hello">
<div data-hello-target="output"></div>
<button data-action="click->hello#display">Say Hello</button>
</div>
Targets allow you to easily reference specific elements within your controller’s scope.
Values:
Values in Stimulus provide a way to pass data from HTML to your controller and keep it in sync. They’re especially useful for configuration and state management.
Defining Values
In your Stimulus controller, define values like this:
export default class extends Controller {
static values = {
count: Number,
message: String,
items: Array,
config: Object,
enabled: Boolean
}
// ...
}
Using Values in HTML
In your HTML, you can set these values using data attributes:
<div data-controller="example"
data-example-count-value="5"
data-example-message-value="Hello, Stimulus!"
data-example-items-value='["apple", "banana", "cherry"]'
data-example-config-value='{"key": "value", "another": 42}'
data-example-enabled-value="true">
</div>
Note that for Array and Object values, you need to use valid JSON in the attribute.
Accessing and Updating Values
In your controller, you can access and update values like this:
export default class extends Controller {
static values = {
count: Number,
message: String
}
initialize() {
console.log(this.countValue) // 5
console.log(this.messageValue) // "Hello, Stimulus!"
}
incrementCount() {
this.countValue++
}
updateMessage(newMessage) {
this.messageValue = newMessage
}
}
Value Change Callbacks
Stimulus automatically generates change callbacks for your values. These are called whenever the value changes:
export default class extends Controller {
static values = {
count: Number
}
countValueChanged(newValue, oldValue) {
console.log(`Count changed from ${oldValue} to ${newValue}`)
}
}
Default Values
You can set default values in your controller:
export default class extends Controller {
static values = {
count: { type: Number, default: 0 },
message: { type: String, default: "Hello" }
}
}
Benefits of Using Values
- Declarative Configuration: Values allow you to configure your controllers directly in the HTML, making it easy to see how a controller is being used.
- Type Safety: Stimulus enforces the types you declare for your values, helping to prevent errors.
- Reactivity: With value change callbacks, you can easily react to changes in your data.
- State Management: Values provide a clean way to manage state within your controllers.
Best Practices for Values
- Use values for configuration that might change between different instances of your controller.
- Leverage value change callbacks to update the UI when data changes.
- Use default values to ensure your controller always has valid data to work with.
- For complex data structures, consider using a single Object value instead of multiple primitive values.
Values vs. Targets
While values and targets in Stimulus are both ways to connect your HTML with your JavaScript controller, they serve different purposes and have some key differences. Let’s compare them:
Key Differences:
- Purpose:
- Targets: Reference specific DOM elements within the controller’s scope.
- Values: Pass data from HTML to the controller or manage state.
2. Data Type:
- Targets: Always reference DOM elements.
- Values: Can be various data types (String, Number, Boolean, Array, Object).
3. HTML Syntax:
- Targets: Use
data-[controller-name]-target="targetName"
- Values: Use
data-[controller-name]-[value-name]-value="value"
4. Access in Controller:
- Targets: Accessed as
this.outputTarget
orthis.outputTargets
(for multiple) - Values: Accessed as
this.countValue
orthis.messageValue
5. Mutability:
- Targets: Reference to DOM elements, which can be manipulated but not “changed”
- Values: Can be updated, triggering change callbacks
6. Change Detection:
- Targets: No built-in change detection
- Values: Automatic change callbacks (e.g.,
countValueChanged()
)
7. Default Values:
- Targets: No concept of default values
- Values: Can have default values specified in the controller
Examples:
Targets:
<div data-controller="example">
<input data-example-target="input">
<div data-example-target="output"></div>
</div>
export default class extends Controller {
static targets = ["input", "output"]
updateOutput() {
this.outputTarget.textContent = this.inputTarget.value
}
}
Values:
<div data-controller="counter" data-counter-count-value="0">
Count: <span data-counter-target="display"></span>
</div>
export default class extends Controller {
static targets = ["display"]
static values = { count: Number }
initialize() {
this.updateDisplay()
}
increment() {
this.countValue++
}
countValueChanged() {
this.updateDisplay()
}
updateDisplay() {
this.displayTarget.textContent = this.countValue
}
}
In this example, we use both a target (for the display element) and a value (for the count). The target allows us to easily update the DOM, while the value manages the state and provides a change callback.
Understanding these differences will help you choose the right tool for each job in your Stimulus controllers, leading to cleaner and more maintainable code.
Best Practices for Hotwire integration:
- Keep controllers focused on a single responsibility.
- Use Stimulus for enhancing existing HTML rather than creating complex UI from scratch.
- Combine Stimulus with Turbo for a powerful, interactive user experience.
- Use values for configuration and targets for DOM interactions to keep your controllers flexible and reusable.
Conclusion
Turbo provides a powerful set of tools for creating dynamic, responsive Rails applications with minimal JavaScript. By understanding when and how to use Turbo Drive, Turbo Frames, and Turbo Streams, you can create sophisticated user interfaces that feel fast and interactive. Combine Stimulus concepts with Turbo, to create rich, interactive experiences in your Rails application with minimal custom JavaScript.
Remember that the key to mastering Hotwire is practice and experimentation. Start with simple use cases and gradually incorporate more advanced techniques as you become comfortable with the framework. With time, you’ll develop an intuition for when to use each component and how to combine them effectively in your Rails applications.