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
Scanmodel with attributes. - You have a
ScanController#showaction. - A user is viewing a
Scanthrough theshow.html.slim|haml|erbview template.http://localhost:3000/scans/1
- When attributes change for the
Scanmodel, 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 whichDOMelement 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.rbshould have the following line uncommented.require "action_cable/engine"
-
Your
config/cable.ymlfile 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.jsshould 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.jsshould 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.jslooks like this:
require("@rails/ujs").start()
require("turbolinks").start()
import "stylesheets/application"
import "controllers"
- The
app/javascript/controllers/index.jslooks 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/actioncablein theyarn.lockandpackage.jsonfiles. 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.