InertiaCable
ActionCable broadcast DSL for Inertia.js Rails applications. Three lines of code to get real-time updates.
InertiaCable broadcasts lightweight JSON signals over ActionCable. The client receives them and calls router.reload() to re-fetch props through Inertia's normal HTTP flow — your controller stays the single source of truth. For ephemeral data like job progress or notifications, direct messages stream data over the WebSocket without triggering a reload.
Model save → after_commit → ActionCable broadcast (signal)
↓
React hook subscribes → receives signal → router.reload({ only: ['messages'] })
↓
Inertia HTTP request → controller re-evaluates props → React re-renders
Coming from Turbo Streams?
broadcasts_toreplacesbroadcasts_refreshes_to, andbroadcast_message_tocovers use cases where you'd reach forbroadcast_append_toorbroadcast_replace_to— but without HTML partials, since Inertia reloads props from your controller instead.
Table of Contents
- Installation
- Quick Start
- Model DSL
- Controller Helper
- React Hook
- Direct Messages
- Suppressing Broadcasts
- Server-Side Debounce
- Testing
- Configuration
- Security
- Troubleshooting
- Requirements
- Development
- License
Installation
Add the gem to your Gemfile:
gem "inertia_cable"Install the frontend package:
npm install @inertia-cable/react @rails/actioncableOptionally run the install generator:
rails generate inertia_cable:installQuick Start
1. Model — declare what broadcasts
class Message < ApplicationRecord
belongs_to :chat
broadcasts_to :chat
end2. Controller — pass a signed stream token as a prop
class ChatsController < ApplicationController
def show
chat = Chat.find(params[:id])
render inertia: 'Chats/Show', props: {
chat: chat.as_json,
messages: -> { chat.messages.order(:created_at).as_json },
cable_stream: inertia_cable_stream(chat)
}
end
end3. React — subscribe to the stream
import { useInertiaCable } from '@inertia-cable/react'
export default function ChatShow({ chat, messages, cable_stream }) {
useInertiaCable(cable_stream, { only: ['messages'] })
return (
<div>
<h1>{chat.name}</h1>
{messages.map(msg => <Message key={msg.id} message={msg} />)}
</div>
)
}That's it. When any user creates, updates, or deletes a message, all connected clients automatically reload the messages prop.
Model DSL
broadcasts_to
Broadcasts a refresh signal to a named stream whenever the model is committed (via a single after_commit callback).
class Post < ApplicationRecord
belongs_to :board
broadcasts_to :board # stream to associated record
broadcasts_to ->(post) { [post.board, :posts] } # stream to a lambda
broadcasts_to "global_feed" # stream to a static string
endbroadcasts_refreshes_to is available as a legacy alias.
Stream resolution
| Argument | Resolves to |
|---|---|
:symbol |
Calls the method on the record (post.board) |
Proc / lambda
|
Calls with the record (->(post) { ... }) |
String |
Used as-is |
| ActiveRecord model | GlobalID (gid://app/Board/1) |
Array |
Joins elements with : after resolving each |
Options
class Post < ApplicationRecord
# on: — limit which events trigger broadcasts (default: all)
broadcasts_to :board, on: [:create, :destroy]
# if: / unless: — standard Rails callback conditions
broadcasts_to :board, if: :published?
broadcasts_to :board, unless: -> { draft? }
# extra: — attach custom data to the payload (Hash or Proc)
broadcasts_to :board, extra: { priority: "high" }
broadcasts_to :board, extra: ->(post) { { category: post.category } }
# debounce: — coalesce rapid broadcasts server-side (requires shared cache store)
broadcasts_to :board, debounce: true # uses global InertiaCable.debounce_delay
broadcasts_to :board, debounce: 1.0 # custom delay in seconds
# Options compose
broadcasts_to :board, on: [:create, :destroy], if: :published?
endbroadcasts
Convention-based version that broadcasts to model_name.plural (e.g., "posts"):
class Post < ApplicationRecord
broadcasts # broadcasts to "posts"
broadcasts on: [:create, :destroy] # with options
endbroadcasts_refreshes is available as a legacy alias.
Instance methods
post = Post.find(1)
# Sync
post.broadcast_refresh_to(board)
post.broadcast_refresh_to(board, :posts) # compound stream
post.broadcast_refresh # to model_name.plural
# Async (via ActiveJob)
post.broadcast_refresh_later_to(board)
post.broadcast_refresh_later
# With extra payload or debounce
post.broadcast_refresh_to(board, extra: { priority: "high" })
post.broadcast_refresh_later_to(board, debounce: 2.0)
# With inline condition (block — skips broadcast if falsy)
post.broadcast_refresh_to(board) { published? }
# Direct messages (ephemeral data, no prop reload)
post.broadcast_message_to(board, data: { progress: 50 })
post.broadcast_message_later_to(board, data: { progress: 50 })
post.broadcast_message_to(board, data: { progress: 50 }) { running? }Broadcast payload
Refresh broadcasts send this JSON:
{
"type": "refresh",
"model": "Message",
"id": 42,
"action": "create",
"timestamp": "2026-02-02T18:15:19+00:00",
"extra": {}
}The action field is "create", "update", or "destroy". The extra field contains data from the extra: option (empty object if not set).
Message broadcasts send a minimal payload:
{
"type": "message",
"data": { "progress": 50, "total": 200 }
}Messages are ephemeral — no model, id, action, or timestamp fields.
Controller Helper
inertia_cable_stream generates a cryptographically signed stream token. Pass it as an Inertia prop.
inertia_cable_stream(chat) # signed "gid://app/Chat/1"
inertia_cable_stream("posts") # signed "posts"
inertia_cable_stream(chat, :messages) # signed "gid://app/Chat/1:messages"Each element is resolved individually: objects that respond to to_gid_param use GlobalID, otherwise to_param is called. Nested arrays are flattened and nil/blank elements are stripped.
The token is verified server-side when the client subscribes — invalid or tampered tokens are rejected.
React Hook
Only the React adapter (
@inertia-cable/react) is available. Vue and Svelte adapters are welcome as community contributions.
useInertiaCable(signedStreamName, options?)
Returns { connected } — a boolean indicating whether the WebSocket subscription is active.
const { connected } = useInertiaCable(cable_stream, {
only: ['messages'], // only reload these props
except: ['metadata'], // reload all except these
onRefresh: (data) => { // callback before each reload
console.log(`${data.model} #${data.id} was ${data.action}`)
if (data.extra?.priority === 'high') toast.warn('Priority update!')
},
onMessage: (data) => { // receive direct messages (no reload)
setProgress(data.progress)
},
onConnected: () => {}, // subscription connected
onDisconnected: () => {}, // connection dropped
debounce: 200, // client-side debounce in ms (default: 100)
enabled: isVisible, // disable/enable subscription (default: true)
})| Option | Type | Default | Description |
|---|---|---|---|
only |
string[] |
— | Only reload these props |
except |
string[] |
— | Reload all props except these |
onRefresh |
(data) => void |
— | Callback before each reload |
onMessage |
(data) => void |
— | Receive direct message data (no reload) |
onConnected |
() => void |
— | Called when subscription connects |
onDisconnected |
() => void |
— | Called when connection drops |
debounce |
number |
100 |
Debounce delay in ms |
enabled |
boolean |
true |
Enable/disable subscription |
Automatic catch-up on reconnection: When a WebSocket connection drops and reconnects (e.g., network interruption or backgrounded tab), the hook automatically triggers a router.reload() to fetch any changes missed while disconnected. This only fires on reconnection — not the initial connect. ActionCable handles the reconnection itself (with exponential backoff); the hook just ensures your props are fresh when it comes back.
Use connected to show connection state in the UI:
const { connected } = useInertiaCable(cable_stream, { only: ['messages'] })
if (!connected) return <Banner>Reconnecting…</Banner>Multiple streams on one page
function Dashboard({ stats, notifications, stats_stream, notifications_stream }) {
useInertiaCable(stats_stream, { only: ['stats'] })
useInertiaCable(notifications_stream, { only: ['notifications'] })
return (
<>
<StatsPanel stats={stats} />
<NotificationList notifications={notifications} />
</>
)
}InertiaCableProvider
Optional context provider for a custom ActionCable URL. Without it, the hook connects to the default /cable endpoint.
import { InertiaCableProvider } from '@inertia-cable/react'
createInertiaApp({
setup({ el, App, props }) {
createRoot(el).render(
<InertiaCableProvider url="wss://cable.example.com/cable">
<App {...props} />
</InertiaCableProvider>
)
},
})getConsumer() and setConsumer() are also exported for low-level access to the ActionCable consumer singleton.
TypeScript types (RefreshPayload, MessagePayload, CablePayload, UseInertiaCableOptions, UseInertiaCableReturn, InertiaCableProviderProps) are exported from @inertia-cable/react. CablePayload is a discriminated union of RefreshPayload | MessagePayload for type-safe handling of raw payloads.
Direct Messages
Push ephemeral data directly into React state over the same signed stream — no prop reload, no extra hook.
Job progress example
# app/jobs/csv_import_job.rb
class CsvImportJob < ApplicationJob
def perform(import)
rows = CSV.read(import.file.path)
rows.each_with_index do |row, i|
process_row(row)
import.broadcast_message_to(import.user, data: { progress: i + 1, total: rows.size })
end
import.broadcast_refresh_to(import.user) # final reload with completed data
end
endimport { useState } from 'react'
import { useInertiaCable } from '@inertia-cable/react'
export default function ImportShow({ import_record, cable_stream }) {
const [progress, setProgress] = useState<{ progress: number; total: number } | null>(null)
useInertiaCable(cable_stream, {
only: ['import_record'],
onMessage: (data) => setProgress({ progress: data.progress as number, total: data.total as number }),
})
return (
<div>
<h1>Import #{import_record.id}</h1>
{progress && <p>Processing {progress.progress} / {progress.total}</p>}
</div>
)
}Usage patterns
// Prop reload only (unchanged)
useInertiaCable(stream, { only: ['messages'] })
// Direct data only (no reload)
useInertiaCable(stream, {
onMessage: (data) => setProgress(data.progress)
})
// Both — progress during job, final reload on completion
useInertiaCable(stream, {
only: ['imports'],
onMessage: (data) => setProgress(data.progress)
})Broadcasting without a model instance
Use InertiaCable.broadcast_message_to from anywhere — jobs, services, controllers — without needing a model instance:
InertiaCable.broadcast_message_to("dashboard", data: { alert: "Deployment complete" })
InertiaCable.broadcast_message_to(user, :notifications, data: { count: 5 })Messages are delivered immediately with no debouncing. Each broadcast_message_to call triggers exactly one onMessage callback.
Suppressing Broadcasts
Thread-safe and nestable:
# Global — suppress all models
InertiaCable.suppressing_broadcasts do
1000.times { Post.create!(title: "Imported") }
end
# Class-level
Post.suppressing_broadcasts do
Post.create!(title: "Silent")
endServer-Side Debounce
Optionally coalesce rapid broadcasts using Rails cache. Not used by default — the client-side 100ms debounce handles most cases.
Server-side debounce is useful when a single operation triggers many model callbacks (e.g., bulk imports, cascading updates) and you want to reduce the number of ActionCable messages sent. The client-side debounce already coalesces rapid reloads into one, so server-side debounce is only needed when the volume of WebSocket messages itself is a concern.
Requires a shared cache store (Redis, Memcached, or SolidCache) in multi-process deployments. MemoryStore (the Rails default) only works within a single process.
# Via the model DSL
broadcasts_to :board, debounce: true # uses InertiaCable.debounce_delay (0.5s)
broadcasts_to :board, debounce: 1.0 # custom delay in seconds
# Via instance methods
post.broadcast_refresh_later_to(board, debounce: 2.0)
# Direct usage
InertiaCable::Debounce.broadcast("my_stream", payload)
InertiaCable::Debounce.broadcast("my_stream", payload, delay: 2.0)
# Configure the global default
InertiaCable.debounce_delay = 0.5 # seconds (default)Testing
InertiaCable ships a TestHelper module:
class MessageTest < ActiveSupport::TestCase
include InertiaCable::TestHelper
test "broadcasting on create" do
chat = chats(:general)
assert_broadcasts_on(chat) do
Message.create!(chat: chat, body: "hello")
end
end
test "no broadcasts when suppressed" do
chat = chats(:general)
assert_no_broadcasts_on(chat) do
Message.suppressing_broadcasts do
Message.create!(chat: chat, body: "silent")
end
end
end
test "inspect broadcast payloads" do
chat = chats(:general)
payloads = capture_broadcasts_on(chat) do
Message.create!(chat: chat, body: "hello")
end
assert_equal "create", payloads.first[:action]
end
end| Method | Description |
|---|---|
assert_broadcasts_on(*streamables, count: nil) { } |
Assert broadcasts occurred |
assert_no_broadcasts_on(*streamables) { } |
Assert no broadcasts occurred |
capture_broadcasts_on(*streamables) { } |
Capture and return payload array |
All three accept splat streamables: assert_broadcasts_on(chat, :messages) { ... }
Broadcast callbacks
InertiaCable.on_broadcast registers a callback that fires for every broadcast (including debounced ones). The test helpers use this internally, but you can use it for custom instrumentation or logging:
callback = ->(stream_name, payload) {
Rails.logger.info "[InertiaCable] #{payload[:type]} on #{stream_name}"
}
InertiaCable.on_broadcast(&callback)
# Later, to unregister:
InertiaCable.off_broadcast(&callback)Configuration
# config/initializers/inertia_cable.rb
InertiaCable.signed_stream_verifier_key = "custom_key" # default: secret_key_base + "inertia_cable"
InertiaCable.debounce_delay = 0.5 # server-side debounce (seconds)Security
Stream tokens are HMAC-SHA256 signed using secret_key_base and verified server-side on subscription. Invalid tokens are rejected. No data travels over the WebSocket — actual data is fetched via Inertia's normal HTTP cycle, which runs through your controller and its authorization logic on every reload. Token rotation follows secret_key_base rotation.
Troubleshooting
Stream token mismatch
The stream signed in the controller must match what the model broadcasts to:
# Model
broadcasts_to :board # broadcasts to gid://app/Board/1
# Controller — must sign the same object
inertia_cable_stream(@post.board) # ✓ signs gid://app/Board/1
inertia_cable_stream(@post) # ✗ signs gid://app/Post/1
only/except crashes
Always pass arrays, never undefined:
// Bad
useInertiaCable(stream, { only: someCondition ? ['messages'] : undefined })
// Good
useInertiaCable(stream, { ...(someCondition ? { only: ['messages'] } : {}) })Server-side debounce not working across processes
Rails.cache defaults to MemoryStore (per-process). Use a shared store in production:
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }Requirements
- Ruby >= 3.1
- Rails >= 7.0 (ActionCable, ActiveJob, ActiveSupport)
- Inertia.js >= 1.0 with React (
@inertiajs/react) - ActionCable configured with Redis or SolidCable (production) or async (development)
Development
bundle install && bundle exec rspec # Ruby specs
cd frontend && npm install && npm test # FrontendLicense
MIT