Skip to content

Module 01 — Foundation: ECharts, Stimulus, and the Phlex Bridge

What We’re Building

This module establishes the architectural foundation the rest of the series builds on. By the end you will have:

  • ECharts 5.x pinned via importmap
  • A chart_controller.js Stimulus controller that owns the full ECharts instance lifecycle
  • A minimal Components::Chart Phlex base class that renders a correctly-structured container
  • A working proof-of-concept: a hard-coded chart rendered in a Rails view

No abstraction yet. This module deliberately exposes every seam before we cover it.


1.1 — Pinning ECharts via Importmap (Background)

If you’ve followed the Initial Setup, then you will already have a working echarts setup The following provides some general setup instructions for echarts if you’re working from scratch.

ECharts ships a full build and several partial builds. The full build is ~1MB minified — fine for an internal tool, too large for a public-facing app. We will use the full build now and revisit tree-shaking in Module 11.

ECharts 5.x is available on jsDelivr as an ESM bundle:

1
bin/importmap pin echarts --from jsdelivr

This will resolve to something like:

1
2
# config/importmap.rb
pin "echarts", to: "https://cdn.jsdelivr.net/npm/echarts@5.6.0/dist/echarts.esm.min.js"

Version note: Pin to a specific version, not @latest. ECharts has introduced breaking option changes between minor versions. Check the ECharts changelog before upgrading.

Verify the pin resolves correctly:

1
bin/importmap audit

1.2 — The chart_controller.js Stimulus Controller

Create the controller at app/javascript/controllers/chart_controller.js.

Before writing any code, let’s be clear about what this controller is responsible for:

  • Initialising the ECharts instance when the element connects
  • Disposing the instance when the element disconnects (Turbo navigation away, element removal)
  • Resizing the instance when the container dimensions change
  • Responding to option changes when the optionsValue Stimulus value changes
  • Surviving Turbo navigation — specifically, not leaving orphaned ECharts instances
 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
51
52
53
54
55
// app/javascript/controllers/chart_controller.js
import { Controller } from "@hotwired/stimulus"
import * as echarts from "echarts"

export default class extends Controller {
  static values = {
    options: { type: Object, default: {} }
  }

  connect() {
    this.#initChart()
  }

  disconnect() {
    this.#disposeChart()
  }

  optionsValueChanged(options) {
    if (!this.chart) return
    this.chart.setOption(options, { notMerge: true })
  }

  // — Private ————————————————————————————————————————

  #initChart() {
    this.chart = echarts.init(this.element, null, {
      renderer: "svg"   // SVG renderer: accessible, printable, no pixel ratio issues
    })

    this.chart.setOption(this.optionsValue, { notMerge: true })

    this.#startResizeObserver()
  }

  #disposeChart() {
    this.#stopResizeObserver()

    if (this.chart) {
      this.chart.dispose()
      this.chart = null
    }
  }

  #startResizeObserver() {
    this.resizeObserver = new ResizeObserver(() => {
      this.chart?.resize()
    })
    this.resizeObserver.observe(this.element)
  }

  #stopResizeObserver() {
    this.resizeObserver?.disconnect()
    this.resizeObserver = null
  }
}

Register the controller in app/javascript/controllers/index.js — if you are using the eagerLoadControllersFrom pattern from the main series, this happens automatically.

Why SVG renderer?

ECharts supports both Canvas and SVG rendering. Canvas is the default and performs better with very large datasets (tens of thousands of points). SVG is preferable for most use cases because:

  • It is accessible — screen readers can traverse SVG elements
  • It prints correctly without pixel ratio issues
  • It exports cleanly via CSS print stylesheets
  • It scales correctly on high-DPI displays without configuration

We will revisit Canvas rendering in Module 11 when we cover large dataset performance.

Why { notMerge: true }?

ECharts’ setOption merges by default — new options are merged into the existing option state. This is useful for incremental updates (Module 09) but dangerous for full replacements: old series, axes, and visual maps can bleed through into the new configuration. Passing notMerge: true ensures a full replacement. We will override this deliberately in Module 09 for real-time updates.

The ResizeObserver

Do not use a window resize event listener. It fires for any window resize, not just resizes that affect your chart container. A ResizeObserver on the element itself fires only when the observed element’s dimensions change — which is exactly what you need when charts are in sidebars, tabs, or collapsible panels.

The observer is started in #initChart and stopped in #disposeChart. There is no memory leak.


1.3 — The Components::Chart Phlex Base Class

The Phlex component has one job: render a container element with the correct data attributes. It does not know what chart type it is rendering. It does not transform data. It renders a div.

Make sure your Components::Base class has the extend Literal::Properties so that we can use the prop calls in our components.

A minimum Components::Base would be:

1
2
3
class Components::Base < Phlex::HTML
  extend Literal::Properties
end

Now we can build our first Chart component.

 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
# app/views/components/chart.rb
module Components
  class Chart < Components::Base
    prop :options, Hash,   default: -> { {} }
    prop :height,  String, default: -> { "400px" }
    prop :html,    Hash,   default: -> { {} }

    def view_template
      div(class: "p-4 rounded-lg bg-red-50/50 border border-neutral-200", **@html) do
        div(
          data: {
            controller: "chart",
            chart_options_value: chart_options.to_json
          },
          style: "height: #{@height}; width: 100%;"
        )
      end
    end

    private

    def chart_options
      { animation: false }.merge(@options)
    end
  end
end

A few things worth noting:

options.to_json — The Stimulus value system expects a JSON string for Object-typed values. We serialise here in Ruby; chart_controller.js receives a parsed object automatically via the Stimulus value API.

height as an inline style — ECharts requires the container element to have explicit dimensions at initialisation time. It reads offsetWidth and offsetHeight from the DOM. If those are zero, ECharts initialises with a zero-size canvas and subsequent resize() calls may not recover correctly. An inline style guarantees dimensions are present regardless of CSS load order.

html prop — A pass-through for arbitrary HTML attributes (class, id, aria-label, etc.), following the same pattern as other components in the main series.


1.4 — Proof of Concept: A Hard-Coded Chart

Let’s verify the entire stack works before adding any abstraction.

Add a route and controller action:

1
2
# config/routes.rb
get "charts/demo", to: "charts#demo"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/controllers/charts_controller.rb
class ChartsController < ApplicationController
  def demo
    @chart_options = {
      title: { text: "Australia GDP Growth Rate", subtext: "Source: ABS National Accounts" },
      tooltip: { trigger: "axis" },
      xAxis: { type: "category", data: ["2019", "2020", "2021", "2022", "2023", "2024"] },
      yAxis: { type: "value", axisLabel: { formatter: "{value}%" } },
      series: [
        {
          name: "GDP Growth",
          type: "line",
          data: [1.9, -2.1, 5.2, 3.8, 2.0, 1.5],
          smooth: true,
          markLine: { data: [{ type: "average", name: "Average" }] }
        }
      ]
    }
  end
end

In the view, construct a raw ECharts option hash and pass it directly to the component:

1
2
3
4
5
6
7
<%# app/views/charts/demo.html.erb %>
<h1 class="text-2xl font-bold mb-6">ECharts Demo</h1>

<%= render Components::Chart.new(
  options: @chart_options,
  height: "400px"
) %>

Start the server and visit /charts/demo. You should see a rendered line chart.

If the chart container is visible but empty: Open the browser console. The most common causes are (1) ECharts failed to import — check the importmap pin, (2) the container has zero height — check that the inline style is present in the rendered HTML, or (3) a JavaScript error in chart_controller.js — check for a missing controllers/index.js registration.


1.5 — Turbo Navigation Correctness

The connect/disconnect lifecycle in Stimulus handles Turbo navigation automatically — when Turbo replaces the page body, Stimulus disconnects controllers on the outgoing page (triggering disconnect()#disposeChart()) and connects controllers on the incoming page (triggering connect()#initChart()).

However, there is one case to be aware of: data-turbo-permanent. If you mark the chart container as permanent, Stimulus will not call disconnect and connect on navigation. The ECharts instance will persist across pages, which is usually not what you want for a chart (the container dimensions or options may be stale).

As established in the main series, data-turbo-permanent belongs on the <html> element for theme persistence. Do not apply it to chart containers.

Verify correct disposal by navigating away from /charts/demo and back. Each navigation should produce a fresh ECharts instance. You can confirm this in the browser console by temporarily adding a console.log to #initChart and #disposeChart.


1.6 — What We Have Not Done Yet

This module has deliberately omitted:

  • Data transformation — the option hash is built inline in the view. Module 03 introduces Chart::Dataset presenter objects.
  • The Ruby DSL — we are using raw Ruby hashes. Module 02 introduces typed builder objects.
  • Multiple chart types — the Components::Chart base class is chart-type-agnostic. Subclasses arrive in Modules 03–08.
  • Real data — the demo uses hard-coded values. The ABS seed data is introduced in Module 03.
  • Lookbook previews — deferred to Module 03 once the component hierarchy has enough shape to preview meaningfully.

The architecture is intentionally thin. The goal of this module is to establish one thing with confidence: the contract between Ruby and JavaScript is a JSON object delivered via a Stimulus value, and the JavaScript side owns the ECharts instance completely.


1.7 — Module Summary

Concern Where it lives
ECharts instance chart_controller.js — created in connect, disposed in disconnect
Container dimensions Inline style on the component’s root div
Resize handling ResizeObserver in chart_controller.js
Option delivery data-chart-options-value JSON attribute
Turbo compatibility Stimulus connect/disconnect lifecycle — no extra work required
Chart configuration Ruby hash in the view (for now)

What’s Next

Module 02 confronts the question this module deliberately avoided: what should the Ruby side of the option hash look like? Raw hashes work, but they offer no type safety, no reuse, and no testability. Module 02 designs the DSL — and makes a deliberate decision about how much of ECharts to wrap.

Next: Module 02 — The Ruby DSL: Building ECharts Options in Ruby