LiveCable is a Phoenix LiveView-style live component system for Ruby on Rails that tracks state server-side and allows you to call actions from the frontend using Stimulus with a React style state management API.
- Server-side state management: Component state is maintained on the server using ActionCable
- Reactive variables: Automatic UI updates when state changes with smart change tracking
- Automatic change detection: Arrays, Hashes, and ActiveRecord models automatically trigger updates when mutated
- Action dispatch: Call server-side methods from the frontend
- Lifecycle hooks: Hook into component lifecycle events
- Stimulus integration: Seamless integration with Stimulus controllers and blessings API
Add this line to your application's Gemfile:
gem 'live_cable'And then execute:
bundle installTo use LiveCable, you need to set up your ApplicationCable::Connection to initialize a LiveCable::Connection.
Add this to your app/channels/application_cable/connection.rb:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :live_connection
def connect
self.live_connection = LiveCable::Connection.new(self.request)
end
end
endRegister the LiveController in your Stimulus application (app/javascript/controllers/application.js):
import { Application } from "@hotwired/stimulus"
import LiveController from "live_cable_controller"
import "live_cable" // Automatically starts DOM observer
const application = Application.start()
application.register("live", LiveController)The live_cable import automatically starts a DOM observer that watches for LiveCable components and transforms custom attributes (live-action, live-form, live-reactive, etc.) into Stimulus attributes.
If you want to call LiveCable actions from your own Stimulus controllers, add the LiveCable blessing:
import { Application, Controller } from "@hotwired/stimulus"
import LiveController from "live_cable_controller"
import LiveCableBlessing from "live_cable_blessing"
import "live_cable" // Automatically starts DOM observer
// Enable the blessing for all controllers
Controller.blessings = [
...Controller.blessings,
LiveCableBlessing
]
const application = Application.start()
application.register("live", LiveController)This adds the liveCableAction(action, params) method to all your Stimulus controllers:
// In your custom controller
export default class extends Controller {
submit() {
// Dispatch an action to the LiveCable component
this.liveCableAction('save', {
title: this.titleTarget.value
})
}
}The action will be dispatched as a DOM event that bubbles up to the nearest LiveCable component. This is useful when you need to trigger LiveCable actions from custom controllers or third-party integrations.
LiveCable's subscription manager keeps connections alive between renders if the Stimulus controller is disconnected and reconnected, for example, sorting a list of live components. The subscription is only destroyed when the parent component does another render cycle and sees that the child is no longer rendered.
- Reduced WebSocket churn: No reconnection overhead during navigation
- State preservation: Server-side state persists across page transitions
- Better performance: Eliminates subscription setup/teardown cycles
- No race conditions: Avoids issues from rapid connect/disconnect
Subscription persistence is handled automatically. Components are identified by their live_id, and the
subscription manager ensures each component has exactly one active subscription at any time.
When the server sends a destroy status, the subscription is removed from the client side and the server side
channel is destroyed and unsubscribed.
Note on component location and namespacing:
- Live components must be defined inside the
Live::module so they can be safely loaded from a string name. - We recommend placing component classes under
app/live/(soLive::Countermaps toapp/live/counter.rb). - Corresponding views should live under
app/views/live/...(e.g.app/views/live/counter/component.html.erb). - When rendering a component from a view, pass the namespaced underscored path, e.g.
live/counter(which camelizes toLive::Counter).
LiveCable uses ActiveModel::Callbacks to provide lifecycle callbacks that you can hook into at different stages of a component's lifecycle.
-
before_connect/after_connect: Called when the component is first subscribed to the channel. Useafter_connectfor initializing timers, subscribing to external services, or loading additional data. -
before_disconnect/after_disconnect: Called when the component is unsubscribed from the channel. Usebefore_disconnectfor cleanup: stop timers, unsubscribe from external services, or save state before disconnection. -
before_render/after_render: Called before and after each render and broadcast, including the initial render. Usebefore_renderfor preparing data, performing calculations, or validating state. Useafter_renderfor triggering side effects or cleanup after the DOM has been updated.
Use standard ActiveModel callback syntax to register your callbacks:
module Live
class ChatRoom < LiveCable::Component
reactive :messages, -> { [] }
reactive :timer_id, -> { nil }
after_connect :start_polling_timer
before_disconnect :stop_timer
after_render :log_render_time
actions :add_message
def add_message(params)
messages << { text: params[:text], timestamp: Time.current }
end
private
def start_polling_timer
# Start a timer after connection is established
self.timer_id = SetInterval.new(5.seconds) do
check_for_updates
end
end
def stop_timer
# Clean up timer before disconnecting
timer_id&.cancel
end
def log_render_time
Rails.logger.info "Rendered at #{Time.current}"
end
end
endYou can also use callbacks with conditionals:
module Live
class Dashboard < LiveCable::Component
before_connect :authenticate_user, if: :requires_auth?
after_render :track_analytics, unless: :development_mode?
private
def requires_auth?
# Your auth logic
end
def development_mode?
Rails.env.development?
end
end
endWhen a component is subscribed:
- Component is instantiated
before_connectcallbacks are called- Connection is established and stream starts
after_connectcallbacks are calledbefore_rendercallbacks are called- Component is rendered and broadcast
after_rendercallbacks are called
On subsequent updates (action calls, reactive variable changes):
- State changes occur
before_rendercallbacks are called- Component is rendered and broadcast
after_rendercallbacks are called
When a component is unsubscribed:
before_disconnectcallbacks are called- Connection is cleaned up and streams are stopped
after_disconnectcallbacks are called- Component is cleaned up
# app/components/live/counter.rb
module Live
class Counter < LiveCable::Component
reactive :count, -> { 0 }
actions :increment, :decrement
def increment
self.count += 1
end
def decrement
self.count -= 1
end
end
endComponent partials should start with a root element. LiveCable will automatically add the necessary attributes to wire up the component:
<%# app/views/live/counter/component.html.erb %>
<div>
<h2>Counter: <%= count %></h2>
<button live-action="increment">+</button>
<button live-action="decrement">-</button>
</div>LiveCable automatically injects the required attributes (live-id, live-component, live-actions, and live-defaults) into your root element and transforms them into Stimulus attributes.
Render components using the live helper method:
<%# Simple usage %>
<%= live('counter', id: 'my-counter') %>
<%# With default values %>
<%= live('counter', id: 'my-counter', count: 10, step: 5) %>The live helper automatically:
- Creates component instances with unique IDs
- Wraps the component in proper Stimulus controller attributes
- Passes default values to reactive variables
- Reuses existing component instances when navigating back
If you already have a component instance, use render directly:
<%
@counter = Live::Counter.new('my-counter')
@counter.count = 10
%>
<%= render(@counter) %>Reactive variables automatically trigger re-renders when changed. Define them with default values using lambdas:
module Live
class ShoppingCart < LiveCable::Component
reactive :items, -> { [] }
reactive :discount_code, -> { nil }
reactive :total, -> { 0.0 }
actions :add_item, :remove_item, :apply_discount
def add_item(params)
items << { id: params[:id], name: params[:name], price: params[:price].to_f }
calculate_total
end
def remove_item(params)
items.reject! { |item| item[:id] == params[:id] }
calculate_total
end
def apply_discount(params)
self.discount_code = params[:code]
calculate_total
end
private
def calculate_total
subtotal = items.sum { |item| item[:price] }
discount = discount_code ? apply_discount_rate(subtotal) : 0
self.total = subtotal - discount
end
def apply_discount_rate(subtotal)
discount_code == "SAVE10" ? subtotal * 0.1 : 0
end
end
endLiveCable automatically tracks changes to reactive variables containing Arrays, Hashes, and ActiveRecord models. You can mutate these objects directly without manual re-assignment:
module Live
class TaskManager < LiveCable::Component
reactive :tasks, -> { [] }
reactive :settings, -> { {} }
reactive :project, -> { Project.find_by(id: params[:project_id]) }
actions :add_task, :update_setting, :update_project_name
# Arrays - direct mutation triggers re-render
def add_task(params)
tasks << { title: params[:title], completed: false }
end
# Hashes - direct mutation triggers re-render
def update_setting(params)
settings[params[:key]] = params[:value]
end
# ActiveRecord - direct mutation triggers re-render
def update_project_name(params)
project.name = params[:name]
end
end
endChange tracking works recursively through nested structures:
module Live
class Organization < LiveCable::Component
reactive :data, -> { { teams: [{ name: 'Engineering', members: [] }] } }
actions :add_member
def add_member(params)
# Deeply nested mutation - automatically triggers re-render
data[:teams].first[:members] << params[:name]
end
end
endWhen you store an Array, Hash, or ActiveRecord model in a reactive variable:
- Automatic Wrapping: LiveCable wraps the value in a transparent Delegator
- Observer Attachment: An Observer is attached to track mutations
- Change Detection: When you call mutating methods (
<<,[]=,update, etc.), the Observer is notified - Smart Re-rendering: Only components with changed variables are re-rendered
This means you can write natural Ruby code without worrying about triggering updates:
# These all work and trigger updates automatically:
tags << 'ruby'
tags.concat(%w[rails rspec])
settings[:theme] = 'dark'
user.update(name: 'Jane')Primitive values (String, Integer, Float, Boolean, Symbol) cannot be mutated in place, so you must reassign them:
reactive :count, -> { 0 }
reactive :name, -> { "" }
# âś… This works (reassignment)
self.count = count + 1
self.name = "John"
# ❌ This won't trigger updates (mutation, but primitives are immutable)
self.count.+(1)
self.name.concat("Doe")For more details on the change tracking architecture, see ARCHITECTURE.md.
Shared variables allow multiple components on the same connection to access the same state. There are two types:
Shared reactive variables trigger re-renders on all components that use them:
module Live
class ChatMessage < LiveCable::Component
reactive :messages, -> { [] }, shared: true
reactive :username, -> { "Guest" }
actions :send_message
def send_message(params)
messages << { user: username, text: params[:text], time: Time.current }
end
end
endWhen any component updates messages, all components using this shared reactive variable will re-render.
Use shared (without reactive) when you need to share state but don't want updates to trigger re-renders in the component that doesn't display that data:
module Live
class FilterPanel < LiveCable::Component
shared :cart_items, -> { [] } # Access cart but don't re-render on cart changes
reactive :filter, -> { "all" }
actions :update_filter
def update_filter(params)
self.filter = params[:filter]
# Can read cart_items.length but changing cart elsewhere won't re-render this
end
end
end
module Live
class CartDisplay < LiveCable::Component
reactive :cart_items, -> { [] }, shared: true # Re-renders on cart changes
actions :add_to_cart
def add_to_cart(params)
cart_items << params[:item]
# CartDisplay re-renders, but FilterPanel does not
end
end
endUse case: FilterPanel can read the cart to show item count in a badge, but doesn't need to re-render every time an item is added—only when the filter changes.
For security, explicitly declare which actions can be called from the frontend:
module Live
class Secure < LiveCable::Component
actions :safe_action, :another_safe_action
def safe_action
# This can be called from the frontend
end
def another_safe_action(params)
# This can also be called with parameters
end
private
def internal_method
# This cannot be called from the frontend
end
end
endNote on params argument: The params argument is optional. Action methods only receive params if you declare the argument in the method signature:
# These are both valid:
def increment
self.count += 1 # No params needed
end
def add_todo(params)
todos << params[:text] # Params are used
endIf you don't need parameters from the frontend, simply omit the params argument from your method definition.
The params argument is an ActionController::Parameters instance, which means you can use strong parameters and all the standard Rails parameter handling methods:
module Live
class UserProfile < LiveCable::Component
reactive :user, ->(component) { User.find(component.defaults[:user_id]) }
reactive :errors, -> { {} }
actions :update_profile
def update_profile(params)
# Use params.expect (Rails 8+) or params.require/permit for strong parameters
user_params = params.expect(user: [:name, :email, :bio])
if user.update(user_params)
self.errors = {}
else
self.errors = user.errors.messages
end
end
end
endYou can also use assign_attributes if you want to validate before saving:
def update_profile(params)
user_params = params.expect(user: [:name, :email, :bio])
user.assign_attributes(user_params)
if user.valid?
user.save
self.errors = {}
else
self.errors = user.errors.messages
end
endThis works seamlessly with form helpers:
<form live-form="update_profile">
<div>
<label>Name</label>
<input type="text" name="user[name]" value="<%= user.name %>" />
<% if errors[:name] %>
<span class="error"><%= errors[:name].join(", ") %></span>
<% end %>
</div>
<div>
<label>Email</label>
<input type="email" name="user[email]" value="<%= user.email %>" />
<% if errors[:email] %>
<span class="error"><%= errors[:email].join(", ") %></span>
<% end %>
</div>
<div>
<label>Bio</label>
<textarea name="user[bio]"><%= user.bio %></textarea>
</div>
<button type="submit">Update Profile</button>
</form>LiveCable provides custom HTML attributes that are automatically transformed into Stimulus attributes. These attributes use a shortened syntax similar to Stimulus but are more concise.
Triggers a component action when an event occurs.
Syntax:
live-action="action_name"- Uses Stimulus default event (click for buttons, submit for forms)live-action="event->action_name"- Custom eventlive-action="event1->action1 event2->action2"- Multiple actions
Examples:
<!-- Default event (click) -->
<button live-action="save">Save</button>
<!-- Custom event -->
<button live-action="mouseover->highlight">Hover Me</button>
<!-- Multiple actions -->
<button live-action="click->save focus->track_focus">Save and Track</button>Transformation: live-action="save" becomes data-action="live#action_$save"
Serializes a form and submits it to a component action.
Syntax:
live-form="action_name"- Uses Stimulus default event (submit)live-form="event->action_name"- Custom eventlive-form="event1->action1 event2->action2"- Multiple actions
Examples:
<!-- Default event (submit) -->
<form live-form="save">
<input type="text" name="title">
<button type="submit">Save</button>
</form>
<!-- On change event -->
<form live-form="change->filter">
<select name="category">...</select>
</form>
<!-- Multiple actions -->
<form live-form="submit->save change->auto_save">
<input type="text" name="content">
</form>Transformation: live-form="save" becomes data-action="live#form_$save"
Passes parameters to actions on the same element.
Syntax: live-value-param-name="value"
Examples:
<!-- Single parameter -->
<button live-action="update" live-value-id="123">Update Item</button>
<!-- Multiple parameters -->
<button live-action="create"
live-value-type="task"
live-value-priority="high">
Create Task
</button>Transformation: live-value-id="123" becomes data-live-id-param="123"
Updates a reactive variable when an input changes.
Syntax:
live-reactive- Uses Stimulus default event (input for text fields)live-reactive="event"- Single eventlive-reactive="event1 event2"- Multiple events
Examples:
<!-- Default event (input) -->
<input type="text" name="username" value="<%= username %>" live-reactive>
<!-- Specific event -->
<input type="text" name="search" live-reactive="keydown">
<!-- Multiple events -->
<input type="text" name="query" live-reactive="keydown keyup">Transformation: live-reactive becomes data-action="live#reactive", and live-reactive="keydown" becomes data-action="keydown->live#reactive"
Adds debouncing to reactive and form updates to reduce network traffic.
Syntax: live-debounce="milliseconds"
Examples:
<!-- Debounced reactive input (300ms delay) -->
<input type="text" name="search" live-reactive live-debounce="300">
<!-- Debounced form submission (1000ms delay) -->
<form live-form="change->filter" live-debounce="1000">
<select name="category">...</select>
</form>Transformation: live-debounce="300" becomes data-live-debounce-param="300"
<div>
<h2>Search Products</h2>
<!-- Reactive search with debouncing -->
<input type="text"
name="query"
value="<%= query %>"
live-reactive
live-debounce="300">
<!-- Form with multiple actions and parameters -->
<form live-form="submit->filter change->auto_filter" live-debounce="500">
<select name="category">
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
</form>
<!-- Action buttons with parameters -->
<button live-action="add_to_cart"
live-value-product-id="<%= product.id %>"
live-value-quantity="1">
Add to Cart
</button>
<!-- Multiple events -->
<button live-action="click->save mouseover->preview">
Save & Preview
</button>
</div>When a form action is triggered, the controller manages potential race conditions with pending reactive updates:
- Priority: Any pending debounced
reactivemessage is sent immediately before the form action message in the same payload. - Order: This guarantees that the server applies the reactive update first, then the form action.
- Debounce Cancellation: Any pending debounced form or reactive submissions are canceled, ensuring only the latest state is processed.
This mechanism prevents scenarios where a delayed reactive update (e.g., from typing quickly) could arrive after a form submission and overwrite the changes made by the form action.
LiveCable supports special HTML attributes to control how the DOM is updated during morphing.
When live-ignore is present on an element, LiveCable (via morphdom) will skip updating that element's children during a re-render.
- Usage:
<div live-ignore>...</div> - Behavior: Prevents the element's content from being modified by server updates.
- Default: Live components automatically have this attribute to ensure the parent component doesn't overwrite the child component's state.
The live-key attribute acts as a hint for the diffing algorithm to identify elements in a list. This allows elements to be reordered rather than destroyed and recreated, preserving their internal state (like input focus or selection).
- Usage:
<div live-key="unique_id">...</div> - Behavior: Matches elements across renders to maintain identity.
- Notes:
- The key must be unique within the context of the parent element.
idattributes are also used as keys iflive-keyis not present, butlive-keyis preferred in loops to avoid ID collisions or valid HTML ID constraints.- Do not use array indices as keys; use a stable identifier from your data (e.g., database ID). If you reorder or add / remove elements from your array the index will no longer match the proper component.
Example:
<% todos.each do |todo| %>
<li live-key="<%= todo.id %>">
...
</li>
<% end %>By default, components render the partial at app/views/live/component_name.html.erb. You can organize your templates differently by marking a component as compound.
module Live
class Checkout < LiveCable::Component
compound
# Component will look for templates in app/views/live/checkout/
end
endWhen compound is used, the component will look for its template in a directory named after the component. By default, it renders app/views/live/component_name/component.html.erb.
Override the template_state method to dynamically switch between different templates:
module Live
class Wizard < LiveCable::Component
compound
reactive :current_step, -> { "account" }
reactive :form_data, -> { {} }
actions :next_step, :previous_step
def template_state
current_step # Renders app/views/live/wizard/account.html.erb, etc.
end
def next_step(params)
form_data.merge!(params)
self.current_step = case current_step
when "account" then "billing"
when "billing" then "confirmation"
else "complete"
end
end
def previous_step
self.current_step = case current_step
when "billing" then "account"
when "confirmation" then "billing"
else current_step
end
end
end
endThis creates a multi-step wizard with templates in:
app/views/live/wizard/account.html.erbapp/views/live/wizard/billing.html.erbapp/views/live/wizard/confirmation.html.erbapp/views/live/wizard/complete.html.erb
In your component templates, you have access to a component local variable that references the component instance. You can use this to call methods instead of storing large datasets in reactive variables.
Why this matters: Reactive variables are stored in memory in the server-side container. For large datasets (like paginated results), this can add up quickly and consume unnecessary memory.
Best practice: Use reactive variables for state (like page numbers, filters), but call methods to fetch data on-demand during rendering:
module Live
class ProductList < LiveCable::Component
reactive :page, -> { 0 }
reactive :category, -> { "all" }
actions :next_page, :prev_page, :change_category
def products
# Fetched fresh on each render, not stored in memory
Product.where(category_filter)
.offset(page * 20)
.limit(20)
end
def next_page
self.page += 1
end
def prev_page
self.page = [page - 1, 0].max
end
def change_category(params)
self.category = params[:category]
self.page = 0
end
private
def category_filter
category == "all" ? {} : { category: category }
end
end
endIn your template:
<div class="products">
<% component.products.each do |product| %>
<div class="product">
<h3><%= product.name %></h3>
<p><%= product.price %></p>
</div>
<% end %>
</div>
<div class="pagination">
<button live-click="prev_page">Previous</button>
<span>Page <%= page + 1 %></span>
<button live-click="next_page">Next</button>
</div>This approach:
- Keeps only
pageandcategoryin memory (lightweight) - Fetches the 20 products fresh on each render
- Prevents memory bloat when dealing with large datasets
- Still provides reactive updates when
pageorcategorychanges
LiveCable components can subscribe to ActionCable channels using the stream_from method. This allows components to react to real-time broadcasts from anywhere in your application, making it easy to build collaborative features like chat rooms, live notifications, or shared dashboards.
Call stream_from in the after_connect lifecycle callback to subscribe to a channel:
module Live
module Chat
class ChatRoom < LiveCable::Component
reactive :messages, -> { [] }, shared: true
after_connect :subscribe_to_chat
private
def subscribe_to_chat
stream_from("chat_messages", coder: ActiveSupport::JSON) do |data|
messages << data
end
end
end
end
endAny part of your application can broadcast to the stream using ActionCable's broadcast API:
module Live
module Chat
class ChatInput < LiveCable::Component
reactive :message
actions :send_message
def send_message(params)
return if params[:message].blank?
message_data = {
id: SecureRandom.uuid,
text: params[:message],
timestamp: Time.now.to_i,
user: current_user.as_json(only: [:id, :first_name, :last_name])
}
# Broadcast to the chat stream
ActionCable.server.broadcast("chat_messages", message_data)
# Clear the input
self.message = ""
end
end
end
endWhen a broadcast is received:
- The stream callback is executed with the broadcast payload
- You can update reactive variables inside the callback
- LiveCable automatically detects the changes and broadcasts updates to all affected components
- All components sharing the same reactive variables are re-rendered
- Automatic re-rendering: Changes to reactive variables inside stream callbacks trigger re-renders
- Shared state: Combine with
shared: truereactive variables to sync state across multiple component instances - Connection-scoped: Each user's component instances receive broadcasts independently
- Coder support: Use
coder: ActiveSupport::JSONto automatically decode JSON payloads
- Chat applications: Real-time message updates across all participants
- Live notifications: Push notifications to specific users or groups
- Collaborative editing: Sync changes across multiple users viewing the same document
- Live dashboards: Update metrics and charts in real-time
- Presence tracking: Show who's currently online or viewing a resource
This project is available as open source under the terms of the MIT License.