Streaming from ActionCable Channels
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.
Basic Usage
Call stream_from in a connection 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
endCallback Usage
Use after_connect to set up streams. Within a page, if Stimulus briefly disconnects and reconnects (e.g. during a parent re-render), the existing subscription is reused and connect does not fire again, so streams won't be recreated. When navigating to a new page with Turbo Drive, the subscription is closed and recreated, so connect will fire again on the new page.
Broadcasting to Streams
Any 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
endHow It Works
When 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
Complete Chat Example
This example splits the chat into two components — ChatRoom for displaying messages, and ChatInput for sending them. Both share the same messages and typing_users reactive variables via shared: true, so updates in one component are instantly reflected in the other.
ChatRoom Component
module Live
module Chat
class ChatRoom < LiveCable::Component
reactive :messages, -> { [] }, shared: true
reactive :typing_users, -> { [] }, shared: true
after_connect :subscribe_to_streams
private
def subscribe_to_streams
# Subscribe to chat messages
stream_from("chat_messages", coder: ActiveSupport::JSON) do |data|
messages << data
# Keep only last 100 messages
messages.shift if messages.size > 100
end
# Subscribe to typing indicators
stream_from("chat_typing", coder: ActiveSupport::JSON) do |data|
if data[:typing]
typing_users << data[:user] unless typing_users.include?(data[:user])
else
typing_users.delete(data[:user])
end
end
end
end
end
endView (app/views/live/chat/chat_room.html.live.erb):
<div>
<div class="chat-room">
<div class="messages">
<% messages.each do |message| %>
<div class="message" live-key="<%= message[:id] %>">
<strong><%= message[:user][:first_name] %></strong>
<p><%= message[:text] %></p>
<small><%= Time.at(message[:timestamp]).strftime('%I:%M %p') %></small>
</div>
<% end %>
</div>
<% if typing_users.any? %>
<div class="typing-indicator">
<%= typing_users.map { |u| u[:first_name] }.join(', ') %>
<%= typing_users.size == 1 ? 'is' : 'are' %> typing...
</div>
<% end %>
</div>
</div>ChatInput Component
module Live
module Chat
class ChatInput < LiveCable::Component
reactive :message, -> { "" }
actions :send_message, :typing
def send_message(params)
return if message.blank?
ActionCable.server.broadcast("chat_messages", {
id: SecureRandom.uuid,
text: message,
timestamp: Time.now.to_i,
user: current_user.as_json(only: [:id, :first_name, :last_name])
})
# Clear typing indicator
ActionCable.server.broadcast("chat_typing", {
user: current_user.as_json(only: [:id, :first_name]),
typing: false
})
self.message = ""
end
def typing(params)
ActionCable.server.broadcast("chat_typing", {
user: current_user.as_json(only: [:id, :first_name]),
typing: params[:message].present?
})
end
end
end
endView (app/views/live/chat/chat_input.html.live.erb):
<div>
<form live-form="send_message">
<input type="text"
name="message"
value="<%= message %>"
placeholder="Type a message..."
live-reactive
live-action="input->typing">
<button type="submit">Send</button>
</form>
</div>Use Cases
Live Notifications
module Live
class NotificationCenter < LiveCable::Component
reactive :notifications, -> { [] }
after_connect :subscribe_to_notifications
private
def subscribe_to_notifications
stream_from("user_notifications_#{current_user.id}", coder: ActiveSupport::JSON) do |notification|
notifications.unshift(notification)
# Keep only last 10 notifications
notifications.pop if notifications.size > 10
end
end
end
endBroadcast notifications from anywhere:
# In a background job, controller, or model callback
ActionCable.server.broadcast(
"user_notifications_#{user.id}",
{
id: notification.id,
title: "New message",
body: "You have a new message from #{sender.name}",
timestamp: Time.now.to_i
}
)Collaborative Editing
module Live
class DocumentEditor < LiveCable::Component
reactive :document, -> { nil }
reactive :active_users, -> { [] }
after_connect :load_document
after_disconnect :leave_document
actions :update_content
def update_content(params)
document.update(content: params[:content])
ActionCable.server.broadcast("document_#{document.id}", {
type: 'content_updated',
content: params[:content],
user: current_user.as_json(only: [:id, :name])
})
end
private
def load_document
self.document = Document.find(defaults[:document_id])
subscribe_to_document
end
def subscribe_to_document
stream_from("document_#{document.id}", coder: ActiveSupport::JSON) do |data|
case data[:type]
when 'content_updated'
document.reload if data[:user][:id] != current_user.id
when 'user_joined'
active_users << data[:user] unless active_users.any? { |u| u[:id] == data[:user][:id] }
when 'user_left'
active_users.reject! { |u| u[:id] == data[:user][:id] }
end
end
# Announce presence
ActionCable.server.broadcast("document_#{document.id}", {
type: 'user_joined',
user: current_user.as_json(only: [:id, :name, :avatar_url])
})
end
def leave_document
ActionCable.server.broadcast("document_#{document.id}", {
type: 'user_left',
user: current_user.as_json(only: [:id])
})
end
end
endRendered with the document ID as a default:
<%= live('document_editor', id: "doc-#{@document.id}", document_id: @document.id) %>Live Dashboards
module Live
class Dashboard < LiveCable::Component
reactive :metrics, -> { {} }
reactive :alerts, -> { [] }
after_connect :subscribe_to_metrics
private
def subscribe_to_metrics
stream_from("dashboard_metrics", coder: ActiveSupport::JSON) do |data|
metrics.merge!(data[:metrics])
if data[:alert]
alerts.unshift(data[:alert])
alerts.pop if alerts.size > 5
end
end
end
end
endUpdate from background jobs:
class MetricsUpdateJob < ApplicationJob
def perform
metrics = {
active_users: User.active.count,
revenue: Order.today.sum(:total),
pending_orders: Order.pending.count
}
ActionCable.server.broadcast("dashboard_metrics", {
metrics: metrics,
timestamp: Time.now.to_i
})
end
endChannel Authorization
LiveCable does not enforce who can receive a broadcast — that's your responsibility. Always scope channel names to the appropriate user or resource so broadcasts only reach the right people:
# ✅ User-scoped - only this user's component receives it
stream_from("notifications_#{current_user.id}", coder: ActiveSupport::JSON) do |data|
notifications.unshift(data)
end
# ❌ Global - every connected component receives it
stream_from("notifications", coder: ActiveSupport::JSON) do |data|
notifications.unshift(data)
endSimilarly, when broadcasting from anywhere in your app, make sure only authorized callers can trigger the broadcast (e.g., background jobs that verify ownership before calling ActionCable.server.broadcast).
Key Features
- 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 - Multiple streams: Components can subscribe to multiple streams simultaneously
- Automatic cleanup: Streams are automatically stopped when the component disconnects
Best Practices
Do
✅ Always scope channel names with a user or resource identifier ✅ Use descriptive channel names that indicate the data type ✅ Use JSON coder for structured data ✅ Clean up or limit collection sizes to prevent memory bloat ✅ Use live-key attributes for list items to preserve identity
Don't
❌ Don't use global channel names — every component subscribing to that name will receive the broadcast ❌ Don't broadcast sensitive data without verifying the caller is authorized ❌ Don't subscribe to broad channels that send unnecessary updates ❌ Don't perform expensive operations inside stream callbacks