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:installVersion 0.4 targets Inertia.js 3.7+ and React 19, and ships ES modules only. Applications on Inertia 1 or 2 should stay on @inertia-cable/react@0.2 until upgrading.
Upgrading from 0.3
Upgrade @inertiajs/react to ^3.7.0. Existing subscriptions keep working. The new coordinator uses Inertia's built-in rest-mode polling; the Ruby broadcast protocol is unchanged and works with the 0.3 gem.
Upgrading from 0.2
Upgrade @inertiajs/react to ^3.0.0, React and React DOM to ^19.0.0, and inertia_rails to ~> 3.19 or newer compatible 3.x. Follow the Inertia Rails v3 upgrade guide for application configuration changes. Then upgrade inertia_cable and @inertia-cable/react to 0.3.
The hook and Ruby broadcast APIs are unchanged. Use ESM import statements; CommonJS require() is no longer supported. Background refreshes now preserve existing Inertia page validation errors, including reconnect catch-up reloads.
Quick 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, status, refreshing, lastRefreshedAt, refreshError }. status is disabled, connecting, connected, reconnecting, or rejected. lastRefreshedAt is a completion timestamp in milliseconds, initially null; a connected socket alone does not prove fresh page data. onRejected observes subscription rejection.
The optional refresh(context) callback replaces the default reload. Its context contains reason (broadcast or reconnect), only, and except. Return a promise to track completion/failure in this hook, or return void when another coordinator owns freshness. onRefresh(payload) remains an observer of broadcast signals and does not replace reloads.
Shared refresh coordination
Use one coordinator per mounted page and share it across subscriptions:
import { router } from '@inertiajs/react'
import { useInertiaCable, useInertiaRefresh } from '@inertia-cable/react'
const live = useInertiaRefresh({
scope: `${site.id}:${pageUrl}`,
only: ['orders', 'shipments'],
debounce: 100,
pollInterval: 60_000,
})
useInertiaCable(orderStream, {
only: ['orders'],
debounce: 0,
refresh: ({ only }) => live.refresh(only),
onConnected: () => live.refresh(),
})
useInertiaCable(shipmentStream, {
only: ['shipments'],
debounce: 0,
refresh: ({ only }) => live.refresh(only),
})
// Route scope-changing controls through the barrier.
const selectSite = (id: number) => live.visit(() => {
router.get('/operations', { site_id: id }, { preserveState: true })
})useInertiaRefresh({ scope, only, enabled?, debounce?, pollInterval? }) returns refresh(keys?), visit(run), refreshing, lastRefreshedAt, and error. It unions invalidated prop keys, permits one owned reload at a time, and retains a trailing refresh when signals arrive during a request. The default debounce is 100ms; polling is opt-in. Hidden tabs pause polling and catch up when visible. An optional onConnected catch-up also covers changes between the initial HTTP response and subscription.
Use live's freshness fields when delegating with a void callback. scope must change with the selected site/filter. visit(run) cancels the coordinator's request or waits for response processing to finish before running navigation, preserving the original closure and callbacks. Automatic router listeners also yield to other visits, but cannot delay a visit already started elsewhere: route same-page scope changes through visit. It never cancels unrelated requests. Inertia continues to own HTTP, prop merging, form state, and polling.
For non-hook integration, createInertiaRefresh({ only, debounce?, pollInterval? }) exposes the same refresh/visit operations plus getSnapshot(), subscribe(listener) (returns unsubscribe), and dispose(). Create it only in a browser lifecycle and always dispose it on scope changes/unmount. The React hook handles this lifecycle, including SSR.
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. Refresh signals carry model metadata and any extra fields over the WebSocket; page props are fetched through your controller and its authorization logic on every reload. Direct messages send their data payload over the WebSocket without running controller authorization again. Only issue stream tokens to authorized users, and ensure every subscriber is allowed to receive the stream's metadata and direct messages. 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/1Reloading selected props
Pass arrays for only and except. Omitted or undefined filters are safely ignored. Without either filter, refresh signals reload all normally included props.
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 >= 3.7, < 4 (
@inertiajs/react) with React and React DOM 19.x -
inertia_rails>= 3.19, < 4 for Inertia.js 3 applications - An ESM-capable frontend build setup (such as Vite)
- ActionCable configured with Redis or SolidCable (production) or async (development)
Development
bundle install && bundle exec rspec # Ruby specs
cd frontend
npm ci
npm run typecheck
npm test
npm run buildThe integration app imports the built package through a local file dependency. After building frontend, run npm ci, npm run check, and npx vite build in integration_test. Rebuild the package after changing its source; use npm run dev in frontend while developing the example app.
License
MIT