Skip to content

Module 08 — Real-Time Charts via ActionCable

What We’re Building

A live stock price chart — four simulated stocks updating every second — with no custom JavaScript.

Any chart in this series can become real-time by adding one prop. The chart_controller.js we built in Module 01 — extended by a few lines in the appendix — handles everything. Our job is entirely in Ruby.

Here’s what it looks like:

live_market_feed.png


8.1 — What You Need

The stream_name: prop

Add stream_name: to any chart component and pass it to the mount div:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def view_template
  div(class: "p-2 rounded-lg bg-white border border-neutral-200", **@html) do
    div(
      data: {
        controller:              "chart",
        chart_target:            "mount",
        chart_options_value:     chart_options.to_json,
        chart_stream_name_value: @stream_name   # ← one addition
      },
      style: "height: #{@height}; width: 100%;"
    )
  end
end

When stream_name is present, chart_controller.js subscribes to the named ActionCable stream automatically. When a broadcast arrives it merges the incoming ECharts options with the current options and calls setOption. The chart updates in place — no flicker, no state loss.

Two chart options to set

animation: false — essential for real-time. Without it ECharts animates every setOption call, producing distracting transitions at 1Hz.

Empty initial data — the broadcaster fills the series and x axis on the first tick. Start with empty arrays:

1
2
x_axis: { type: "category", boundaryGap: false, data: [] },
series: SYMBOLS.map { |s| { name: s, type: "line", data: [], showSymbol: false } }

Custom tooltip formatter (optional)

For a multi-stock percentage change chart, add to custom_chart_formatters.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
"axis:stockChange": params => {
  const header = params[0]?.axisValueLabel ?? ""
  const rows = params.map(p => {
    const val    = parseFloat(p.value)
    const sign   = val >= 0 ? "+" : ""
    const colour = val >= 0 ? "#16a34a" : "#dc2626"
    return `<tr>
      <td style="padding-right:16px">${p.marker}${p.seriesName}</td>
      <td style="text-align:right;color:${colour}">
        <strong>${sign}${val.toFixed(3)}%</strong>
      </td>
    </tr>`
  }).join("")
  return `${header}<br/>
    <table style="width:100%;border-collapse:collapse">${rows}</table>`
}

Then reference it from Ruby:

1
tooltip: { trigger: "axis", formatter: "stockChange" }

8.2 — The Channel

One generic channel handles all real-time charts in the application. Different charts subscribe to different streams through the same channel:

1
2
3
4
5
6
7
8
9
# app/channels/chart_channel.rb
class ChartChannel < ApplicationCable::Channel
  def subscribed
    stream_from params[:stream]
  end

  def unsubscribed
  end
end

params[:stream] receives the stream name from the JavaScript subscription — { channel: "ChartChannel", stream: "live_stocks" }. This channel is written once and never touched again regardless of how many real-time charts you add.


8.3 — The Broadcaster

The broadcaster is the Ruby code that generates or receives data and sends it to the stream. In a real application this would be a Sidekiq job, a WebSocket client connected to an external API, or a webhook receiver.

The broadcaster sends ECharts options JSON — full or partial. The controller merges incoming data with the current options, so you only need to send what changed:

1
2
3
4
5
# Send only the series data — axes, colours, formatters stay in place
ActionCable.server.broadcast("live_stocks", {
  xAxis:  { type: "category", boundaryGap: false, data: timestamps },
  series: series_data
})

The receiving controller does:

1
2
const updated = { ...this.optionsValue, ...data }
this.element.dataset.chartOptionsValue = JSON.stringify(updated)

Stimulus detects the attribute change, optionsValueChanged fires, setOption is called. The chart updates.

Scalability: The broadcaster sends one message per tick regardless of how many clients are connected. ActionCable fans it out — one message in, N deliveries out. Whether you have 10 clients or 10,000, the broadcaster does the same work. Scalability is determined by ActionCable and its backing store (Redis in production) — not by client-side code.


8.4 — The Component and View

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# app/views/components/charts/live_stocks.rb
module Components
  module Charts
    class LiveStocks < Components::Chart
      prop :prices,      Hash,   default: -> { {} }
      prop :stream_name, String, default: -> { "" }

      SYMBOLS = %w[MIN FIN TEC RET].freeze
      COLORS  = %w[#ef4444 #3b82f6 #8b5cf6 #f59e0b].freeze

      private

      def chart_options
        ::Chart::Options.new(
          color:     COLORS,
          animation: false,
          tooltip:   { trigger: "axis", formatter: "stockChange" },
          legend:    { data: SYMBOLS, bottom: 5 },
          x_axis:    { type: "category", boundaryGap: false, data: [] },
          y_axis:    {
            type:      "value",
            scale:     true,
            axisLabel: { formatter: "{value}%" }
          },
          grid:      { left: 8, right: 8, bottom: 40, containLabel: true },
          series:    SYMBOLS.map { |s|
            { name: s, type: "line", data: [], showSymbol: false }
          }
        )
      end

      def view_template
        div(class: "p-2 rounded-lg bg-white border border-neutral-200", **@html) do
          div(
            data: {
              controller:              "chart",
              chart_target:            "mount",
              chart_options_value:     chart_options.to_json,
              chart_stream_name_value: @stream_name
            },
            style: "height: #{@height}; width: 100%;"
          )
        end
      end
    end
  end
end
1
2
3
4
# app/controllers/charts_controller.rb
def live_stocks
  @prices = StockFeed.tick
end
1
2
# config/routes.rb
get "charts/live_stocks", to: "charts#live_stocks"
<%# app/views/charts/live_stocks.html.erb %>
<%= content_for :head do %>
  <meta name="turbo-cache-control" content="no-cache">
<% end %>

<div class="max-w-4xl mx-auto px-4 py-8">
  <div class="flex items-center justify-between mb-6">
    <div>
      <h1 class="text-2xl font-bold">Live Market Feed</h1>
      <p class="text-neutral-500 text-sm mt-1">
        Simulated prices updating every second via ActionCable.
        No custom JavaScript.
      </p>
    </div>
    <div class="flex items-center gap-2">
      <span class="relative flex h-3 w-3">
        <span class="animate-ping absolute inline-flex h-full w-full
                     rounded-full bg-green-400 opacity-75"></span>
        <span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
      </span>
      <span class="text-sm font-medium text-neutral-600">LIVE</span>
    </div>
  </div>

  <%= render Components::Charts::LiveStocks.new(
    prices:      @prices,
    stream_name: "live_stocks",
    height:      "400px"
  ) %>
</div>

8.5 — Gallery

<%= render "charts/gallery_card",
  title:       "Live Market Feed",
  description: "Four simulated stocks updating every second via ActionCable. "\
               "No custom JavaScript.",
  path:        charts_live_stocks_path %>

8.6 — Housekeeping: ActionCable Setup

If ActionCable was not included when your app was generated, run:

1
rails action_cable:install

This creates the base channel classes, adds the ActionCable mount to routes.rb, and creates config/cable.yml. Then add two importmap pins manually:

1
2
3
# config/importmap.rb
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "channels/consumer",  to: "channels/consumer.js"

Add yield :head to the application layout — this allows individual views to inject content into <head>:

<%# app/views/layouts/application.html.erb %>
<head>
  <title>...</title>
  <%= yield :head %>
  ...
</head>

This is a general-purpose pattern useful beyond this module — any view can inject page-specific meta tags, preload hints, or canonical URLs using content_for :head.


8.7 — Housekeeping: The Simulation

StockFeed simulates a live data source using a random walk. In a real application replace this with your actual data source:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# app/lib/stock_feed.rb
module StockFeed
  STOCKS = {
    "MIN" => { name: "Meridian Mining",   price: 42.50, volatility: 0.008 },
    "FIN" => { name: "Harbour Financial", price: 18.20, volatility: 0.002 },
    "TEC" => { name: "Apex Technology",   price: 95.00, volatility: 0.005 },
    "RET" => { name: "Coastal Retail",    price:  6.80, volatility: 0.004 }
  }.freeze

  @prices       = STOCKS.transform_values { |s| s[:price] }
  @broadcasting = false

  class << self
    def tick
      STOCKS.each_key do |symbol|
        v = STOCKS[symbol][:volatility]
        @prices[symbol] = (@prices[symbol] * (1 + (rand - 0.5) * 2 * v)).round(2)
      end
      @prices.dup
    end

    def start!
      @prices       = STOCKS.transform_values { |s| s[:price] }
      @broadcasting = true
    end

    def stop!         = @broadcasting = false
    def broadcasting? = @broadcasting
    def symbols       = STOCKS.keys
    def names         = STOCKS.transform_values { |s| s[:name] }
  end
end

The broadcaster thread runs in an initializer. In production this would be a Sidekiq job or an external connection — the broadcaster pattern is the same:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# config/initializers/stock_broadcaster.rb
unless Rails.env.test?
  Thread.new do
    sleep 2

    history     = nil
    open_prices = {}

    loop do
      if StockFeed.broadcasting?
        if history.nil?
          history     = StockFeed.symbols.each_with_object({}) { |s, h| h[s] = [] }
          open_prices = {}
        end

        prices    = StockFeed.tick
        timestamp = Time.current.strftime("%H:%M:%S")

        StockFeed.symbols.each do |symbol|
          open_prices[symbol] ||= prices[symbol]
          pct = ((prices[symbol] - open_prices[symbol]) /
                  open_prices[symbol] * 100).round(3)
          history[symbol] << [timestamp, pct]
          history[symbol].shift if history[symbol].size > 60
        end

        series = StockFeed.symbols.map do |symbol|
          { name: symbol, type: "line", data: history[symbol], showSymbol: false }
        end

        ActionCable.server.broadcast("live_stocks", {
          xAxis:  {
            type:        "category",
            boundaryGap: false,
            data:        history[StockFeed.symbols.first].map(&:first)
          },
          series: series
        })

      else
        history = nil
      end

      sleep 1
    rescue => e
      Rails.logger.error "[StockBroadcaster] #{e.message}"
      sleep 1
    end
  end
end

Why percentage change? Four stocks at very different price levels ($6 to $95) on a shared Y axis would make movements of the cheaper stocks invisible. Percentage change from the opening price puts all four on a comparable scale — standard practice for multi-stock comparison charts.

Why a rolling 60-point window? The chart shows the last 60 seconds. The broadcaster maintains this buffer — history[symbol].shift removes the oldest point as each new one arrives.


8.8 — Housekeeping: Demo-Specific Behaviour

Start/stop via the channel lifecycle

In production a broadcaster runs continuously. For this demo we only want broadcasting when someone is viewing the chart — otherwise the development log fills with noise.

Update ChartChannel to start and stop the feed via the subscription lifecycle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# app/channels/chart_channel.rb
class ChartChannel < ApplicationCable::Channel
  def subscribed
    stream_from params[:stream]
    StockFeed.start! if params[:stream] == "live_stocks"
  end

  def unsubscribed
    StockFeed.stop! if params[:stream] == "live_stocks"
  end
end

When a client subscribes — visiting the live stocks page — StockFeed.start! resets prices to opening values and sets the broadcasting flag. The broadcaster thread checks this flag each second. When the client navigates away and unsubscribed fires, StockFeed.stop! clears the flag — no more broadcasts, no log noise.

Each new visit starts fresh from 0% because start! resets @prices.

Turbo cache control

Turbo caches pages as you navigate away so it can restore them instantly. For this page that causes a problem — the cached page’s chart controller does not disconnect, so navigating back does not trigger a fresh connect, and the feed does not reset.

The content_for :head block in the view (section 9.4) adds:

<meta name="turbo-cache-control" content="no-cache">

This tells Turbo not to cache this specific page. Every other page in the application caches normally. When the user navigates away and returns, Rails serves a fresh page — the controller connects, subscribed fires, start! resets the feed, and the chart begins fresh from 0%.


8.9 — Module Summary

New files:

File Purpose
app/lib/stock_feed.rb Simulated price generator (replace with real source)
config/initializers/stock_broadcaster.rb Broadcaster thread (simulation)
app/channels/chart_channel.rb Generic channel for all real-time charts
app/views/components/charts/live_stocks.rb Live chart component

To make any chart real-time:

  1. Add stream_name: prop to the component
  2. Pass it as chart_stream_name_value to the mount div
  3. Set animation: false and start with empty series data
  4. Create ChartChannel (once — reused by all live charts)
  5. Write a broadcaster that sends ECharts options JSON to the stream

Patterns introduced:

  • stream_name: prop — opt-in real-time at the component level
  • Generic ChartChannel — one channel for all real-time charts
  • Partial broadcaster payload — send only what changed, merge on client
  • animation: false — required for real-time performance
  • scale: true — Y axis auto-scale for multi-value charts
  • Percentage change from open — comparable scale for multi-stock display
  • Rolling window — bounded history buffer in the broadcaster
  • yield :head in layout — general pattern for page-specific head content
  • content_for :head — page-specific meta tag injection
  • turbo-cache-control: no-cache — clean connect/disconnect lifecycle
  • subscribed/unsubscribed — channel lifecycle for start/stop control