Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Rails 6 + ActionCable + Stimulus example for pushing updates to the view.

Rails 6 + ActionCable + Stimulus example for pushing updates to the view.

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

Example scenario

  • You have a Scan model with attributes.
  • You have a ScanController#show action.
  • A user is viewing a Scan through the show.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.

Step 1 - Controller

Nothing new here.

# app/controllers/scans_controller.rb

class ScansController < ApplicationController
  def show
    @scan = Scan.find(params[:id])
  end
end

Step 2 - Update the view

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" data-scan-id=@scan.id
  .status data-target="scan.status"
    = render partial: "scans/statuses/#{@scan.status}"
  • data-controller="scan" tells Stimulus which controller to use.
  • data-scan-id=@scan.id will be used by Stimulus and ActionCable.
  • data-target="scan.status" tells Stimulus which DOM element to update

Step 3 - Create the Stimulus controller

# 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"]; - See data-target="scan.status" in the view template.
  • channel: "ScanChannel" - ActionCable channel used.
  • id: this.data.get("id"), - See data-scan-id=@scan.id 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
  }

Step 4 - Create the ActionCable channel

# 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.

Step 5 - Update the DOM

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

Notes/Requirements

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 the yarn.lock and package.json files. If not, run the following command to update the files.
    • yarn add @rails/actioncable

Resources

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment