Reactive Variables
Reactive variables are the heart of LiveCable's state management. When a reactive variable changes, the component automatically re-renders and broadcasts the updated HTML to connected clients.
Defining Reactive Variables
Define reactive variables using the reactive class method with a lambda that provides the default value:
module Live
class ShoppingCart < LiveCable::Component
reactive :items, -> { [] }
reactive :discount_code, -> { nil }
reactive :total, -> { 0.0 }
actions :add_item, :apply_discount
end
endWhy Lambdas?
Default values are defined as lambdas to ensure each component instance gets its own copy of the value. Without lambdas, all instances would share the same object reference.
Setting Reactive Variables
Use the setter method (with self.) to update reactive variables:
def add_item(params)
items << { id: params[:id], name: params[:name], price: params[:price].to_f }
self.total = calculate_total(items)
end
def apply_discount(params)
self.discount_code = params[:code]
self.total = calculate_total_with_discount(items, discount_code)
endAutomatic Change Tracking
LiveCable 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, -> { nil }
actions :add_task, :update_setting, :update_project_name
after_connect :load_project
# 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.update(name: params[:name])
end
private
def load_project
self.project = Project.find(defaults[:project_id])
end
end
endThe component would be rendered with the project ID passed as a default:
<%= live('task_manager', id: "task-#{@project.id}", project_id: @project.id) %>How It Works
When 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')Nested Structures
Change 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
endPrimitive Values
LiveCable only wraps Arrays, Hashes, and ActiveRecord models in change-tracking Delegators. Other values — including Strings, Integers, Floats, Booleans, and Symbols — are not tracked for in-place mutation. You must reassign them to trigger updates:
reactive :count, -> { 0 }
reactive :name, -> { "" }
# ✅ This works (reassignment)
self.count = count + 1
self.name = "John"
# ❌ This won't trigger updates (not wrapped in a Delegator)
count + 1 # creates a new Integer but doesn't assign it
name.concat("!") # mutates the String but LiveCable doesn't detect itUsing the component Local for Memory Efficiency
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 held in memory on the server between renders. If you store 500 products in a reactive variable across 200 component instances, that's 100,000 product objects sitting in RAM at all times. Calling a method instead fetches the data fresh on each render and is immediately garbage collected afterwards.
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>
<div class="products">
<% products.each do |product| %>
<div class="product">
<h3><%= product.name %></h3>
<p><%= product.price %></p>
</div>
<% end %>
</div>
<div class="pagination">
<button live-action="prev_page">Previous</button>
<span>Page <%= page + 1 %></span>
<button live-action="next_page">Next</button>
</div>
</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
Shared Variables
Shared variables allow multiple components on the same connection to access the same state.
Shared Reactive Variables
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.
Shared Non-Reactive Variables
Use shared (without reactive) when you need to share state but don't want updates to trigger re-renders:
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.
Accessing Reactive Variables in Views
Reactive variables are automatically available as local variables in your component views:
<div>
<div class="shopping-cart">
<h2>Shopping Cart</h2>
<p>Items: <%= items.size %></p>
<p>Total: $<%= total %></p>
<% if discount_code %>
<p class="discount">Discount code: <%= discount_code %></p>
<% end %>
<ul>
<% items.each do |item| %>
<li><%= item[:name] %> - $<%= item[:price] %></li>
<% end %>
</ul>
</div>
</div>Default Values from Rendering
You can pass default values when rendering a component:
<%# Set initial count to 10 %>
<%= live('counter', id: 'my-counter', count: 10) %>
<%# Load user data %>
<%= live('profile', id: "profile-#{@user.id}", user_id: @user.id) %>These defaults are only applied when the component is first created, not on subsequent renders.