Reagent + Re-Frame + ChartJs

6 minute read

Charts in Reagent

I wanted to include some charts in my ClojureScript project, and having worked with the Chart.js library I decided to use it. My stack up to that point included shadow-cljs, reagent and re-frame. I had to piece together the solution by reading to the documentation. I think I landed on a good solution.

Including Chart.js

First we need to bring the chart.js dependancy to our project. To do that we first consult the documentation on installing npm packages. We just need to run npm install chart.js and we can that use chart.js in our project by requireing it (require '["chart.js/auto" :as chartjs]).

Reagent and Re-frame

Our chart component is stateful which clashes re-frame immutable structure. Many JS libraries are like this so the re-frame team decided on a way to use these components is our application. The strategy is outlined in their documentation. Basically we need to create an outer component which sources the data, and an inner component which manages the actual state.

Inner component

Lets start with first writing the inner component. This component is represented as a reagent class which is like a react class. You can read more about them here. We need decide on four things:

  1. function that gets called on mount
  2. function that gets called on update
  3. display name
  4. render function

The general outline of our component would be

1(defn chart-component-inner []
2  (reagent/create-class
3    {:component-did-mount ...
4     :component-did-update ...
5     :display-name ...
6     :reagent-render ...}))

Render function and display name

Our render function is actually just some Hiccup markup. In this case for chart.js to function we need to create a canvas where out chart will reside. We also need to identify this chart with an id. We can just give it some id like chart-canvas and this would work if we want to create only one chart. If we need more charts the ids would clash, so we need to make this id dynamically and keep track of it in our code. For this purpose we can generate a random string and pass it around. We write this random string function and incorporate it into our function using let.

For the display name I decided to use chart-component-{chart-id}.

Now out component looks like this

 1(defn rand-str [len]
 2  (apply str (take len (repeatedly #(char (+ (rand 26) 65))))))
 3
 4(defn chart-component-inner []
 5  (let [chart-id (rand-str 10)]
 6   (r/create-class 
 7    {:component-did-mount ...
 8     :component-did-update ...
 9     :display-name (str "chart-component-" chart-id)
10     :reagent-render (fn []
11                      [:canvas {:id chart-id}])})))

Mount function

Our mount function need to initialize the component using chart.js lifecycle functions and supply it with the initial data. To initialize a chart.js component we need a canvas and its id. We took care of that in the previous step so our function is now easy to write. Our first try might look something like this:

 1(defn mount-chart [chart-id]
 2  (let [context (.getContext (.getElementById js/document chart-id) "2d")
 3        data {:type "line"
 4              :data {
 5                     :labels []
 6                     :datasets [{
 7                                 :data []
 8                                 :borderColor "rgb(75, 192, 192)"
 9                                 }]
10                     }
11              :options {
12                        }}
13        chart (chartjs/Chart. context (clj->js data))]
14    ))

We just use javascript functions to do the job and convert our data structure from clojure to javascript using clj->js function. This approach is fine but has two major problems that present themselves later. First, we can’t supply the initial data so we would need to create a chart and then update it immediately. Second, we can’t even update this chart because for that we need the reference to the chart we created which we currently have no way of getting from this function.

To solve the first problem we can return a function that does these computations insted of doing them. By doing this we get access to the this variable which we can use to get data passed to the component. To get that data we can use reagent/props this. This will get us all the arguments passed to this component. For example if we created our component like [chart-component-inner [1 2 3]], by running reagent/props this we would get the vector [1 2 3]. We can leverage this plus Clojure destructuring to create a function that can take initial data but can also be created without it. Now our function looks like this:

 1(defn mount-chart [chart-id]
 2  (fn [this] 
 3    (let [{:keys [labels data] :or {labels [] data []}} (r/props this)
 4          context (.getContext (.getElementById js/document chart-id) "2d")
 5          data {:type "line"
 6                :data {
 7                       :labels labels
 8                       :datasets [{
 9                                   :data data
10                                   :borderColor "rgb(75, 192, 192)"
11                                   }]
12                       }
13                :options {
14                          }}
15          chart (chartjs/Chart. context (clj->js data))]
16    )))

To solve the second problem we need to pass our chart structure back to the caller. We can’t do that directly so we can use reagent atoms which work in the same way as clojure atoms. In our create-inner-chart function we create this atom and pass it to our mount-chart function which uses reset! to change it’s value.
Our functions now:

 1(defn chart-component-inner []
 2  (let [chart-atom (r/atom nil)
 3        chart-id (rand-str 10)]
 4   (r/create-class 
 5    {:component-did-mount (mount-chart chart-atom chart-id)
 6     :component-did-update ...
 7     :display-name (str "chart-component-" chart-id)
 8     :reagent-render (fn []
 9                      [:canvas {:id chart-id}])})))
10
11(defn mount-chart [chart-atom chart-id]
12  (fn [this] 
13    (let [{:keys [labels data] :or {labels [] data []}} (r/props this)
14          context (.getContext (.getElementById js/document chart-id) "2d")
15          data {:type "line"
16                :data {
17                       :labels labels
18                       :datasets [{
19                                   :data data
20                                   :borderColor "rgb(75, 192, 192)"
21                                   }]
22                       }
23                :options {
24                          }}
25          chart (chartjs/Chart. context (clj->js data))]
26    (reset! chart-atom chart))))

Update function

To update our chart we need to take data passed to it which we do using props and change our state using javascript functions. I decided to pass all data to chart instead of passing it as it arrives. This allows us to easily revert the state and not have to deal with more javascript.

1(defn update-chart [chart-atom]
2  (fn [this]
3    (let [{:keys [labels data]} (r/props this)
4          labels                (clj->js labels)
5          data                  (clj->js data)]
6      (set! (.. @chart-atom -data -labels) labels)
7      (set! (.. @chart-atom -data -datasets (at 0) -data) data)
8      (.update @chart-atom))))

Final

Our final chart-component-inner function looks like:

 1(defn mount-chart [chart-atom chart-id]
 2  (fn [this] 
 3    (let [{:keys [labels data] :or {labels [] data []}} (r/props this)
 4          context (.getContext (.getElementById js/document chart-id) "2d")
 5          data {:type "line"
 6                :data {
 7                       :labels labels
 8                       :datasets [{
 9                                   :data data
10                                   :borderColor "rgb(75, 192, 192)"
11                                   }]
12                       }
13                :options {
14                          }}
15          chart (chartjs/Chart. context (clj->js data))]
16    (reset! chart-atom chart))))
17
18(defn update-chart [chart-atom]
19  (fn [this]
20    (let [{:keys [labels data]} (r/props this)
21          labels                (clj->js labels)
22          data                  (clj->js data)]
23      (set! (.. @chart-atom -data -labels) labels)
24      (set! (.. @chart-atom -data -datasets (at 0) -data) data)
25      (.update @chart-atom))))
26
27(defn chart-component-inner []
28  (let [chart-atom (r/atom nil)
29        chart-id (rand-str 10)]
30   (r/create-class 
31    {:component-did-mount (mount-chart chart-atom chart-id)
32     :component-did-update (update-chart chart-atom)
33     :display-name (str "chart-component-" chart-id)
34     :reagent-render (fn []
35                      [:canvas {:id chart-id}])})))

Outer component

For the outer component we can write to subscribe to re-frame our just as a fucntion that takes our initial data and passed it to our inner component.

1(defn chart-component-outer [initial-data]
2  [chart-component-inner initial-data])