This example will show how to push updates to the view for a Model instance that has changed without the user having to refresh the page.
This example focuses more on getting ActionCable working with Stimulus. If you don't already have stimulus setup in your app, here's a great write up on how to set it up: https://medium.com/better-programming/how-to-add-stimulus-js-to-a-rails-6-application-4201837785f9
- You have a
Scan
model with attributes. - You have a
ScanController#show
action. - A user is viewing a
Scan
through theshow.html.slim|haml|erb
view template.http://localhost:3000/scans/1
- When attributes change for the
Scan
model, we can push those changes to the view. No page reload needed.
Nothing new here.
# app/controllers/scans_controller.rb
class ScansController < ApplicationController
def show
@scan = Scan.find(params[:id])
end
end
Add data-*
attributes to the view that will be used by Stimulus and ActionCable.
# app/views/scans/show.html.slim
h1 Scan
.scan data-controller="scan" [email protected]
.status data-target="scan.status"
= render partial: "scans/statuses/#{@scan.status}"
data-controller="scan"
tells Stimulus which controller to use.[email protected]
will be used by Stimulus and ActionCable.data-target="scan.status"
tells Stimulus whichDOM
element to update
# app/javascript/controllers/scan_controller.js
import { Controller } from "stimulus";
import consumer from "channels/consumer";
export default class extends Controller {
static targets = ["status"];
connect() {
this.subscription = consumer.subscriptions.create(
{
channel: "ScanChannel",
id: this.data.get("id"),
},
{
connected: this._connected.bind(this),
disconnected: this._disconnected.bind(this),
received: this._received.bind(this),
}
);
}
_connected() {}
_disconnected() {}
_received(data) {
const element = this.statusTarget
element.innerHTML = data
}
}
static targets = ["status"];
- Seedata-target="scan.status"
in the view template.channel: "ScanChannel"
- ActionCable channel used.id: this.data.get("id"),
- See[email protected]
in the view template.
When data is received on the channel, this code will update the target.
_received(data) {
const element = this.statusTarget
element.innerHTML = data
}
# app/channels/scan_channel.rb
class ScanChannel < ApplicationCable::Channel
def subscribed
stream_from "scan_#{params[:id]}"
end
end
If a user is viewing a Scan
with id
of 1
, then an ActionCable channel of scan_1
will be created.
ActionCable.server.broadcast("scan_1", "FooBar")
You can also use a partial. Here's an example from an ActiveJob/Sidekiq job.
# app/jobs/update_scan_progress_job.rb
class UpdateScanProgressJob < ApplicationJob
queue_as :default
def perform(message)
message = JSON.parse(message)
scan_id = message["scan_id"].to_i
scan = Scan.find(scan_id)
scan.update(status: message["status"])
partial = ApplicationController.render(partial: "scans/statuses/#{message["status"]}")
ActionCable.server.broadcast("scan_#{scan.id}", partial)
end
end
If you encounter issues, verify you have the following in your application.
-
Your
config/application.rb
should have the following line uncommented.require "action_cable/engine"
-
Your
config/cable.yml
file should be setup.
default: &default
adapter: redis
url: <%= ENV.fetch("REDIS_HOST") %>
test:
adapter: async
development:
<<: *default
production:
<<: *default
- Need to have the following files:
# app/channels/application_cable/channel.rb
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
- Your
app/javascript/channels/consumer.js
should look like this:
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
import { createConsumer } from "@rails/actioncable"
export default createConsumer()
- Your
app/javascript/channels/index.js
should look like this:
// Load all the channels within this directory and all subdirectories.
// Channel files must be named *_channel.js.
const channels = require.context('.', true, /_channel\.js$/)
channels.keys().forEach(channels)
- The
app/javascript/packs/application.js
looks like this:
require("@rails/ujs").start()
require("turbolinks").start()
import "stylesheets/application"
import "controllers"
- The
app/javascript/controllers/index.js
looks like this:
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start()
const context = require.context(".", true, /\.js$/)
application.load(definitionsFromContext(context))
- Should see
@rails/actioncable
in theyarn.lock
andpackage.json
files. If not, run the following command to update the files.yarn add @rails/actioncable
I created this gist because I wasn't able to find an example that was clear to me on how to do this. Using the resources below, I was able to piece together the example above. Thank you to the authors of the resources below.
- https://docs.stimulusreflex.com/setup
- https://mentalized.net/journal/2018/05/18/getting-realtime-with-rails/
- https://dennishellweg.com/using-actioncable-with-stimulus
- https://onrails.blog/2019/02/19/stimulus-actioncable-activejob-loading-asynchronous-api-data/
- https://gist.github.com/davidpaulhunt/9bc21bbf792cb3565315
- https://dev.to/dstull/stimulusjs-with-rails-action-cable-and-a-bit-of-sidekiq-i0a
- https://github.com/dstull/sidekiq-actioncable-stimulus-demo/tree/actioncable/config
- https://mariochavez.io/desarrollo/2020/06/09/i-created-the-same-application-with-rails-no-javascript.html
Thank you for consolidating all this information. It has been a challenge finding this all in one place.