image of me

Wrapping the HTML Canvas for re-frame

2020-10-22

When working all high up in the ivory tower of PURE STATELESS DECLARACTIVE REACTIVE RE-FRAME, one can forgot that one sometimes has to dip their fingers into the dirty pool of the DOM. For example, if you want to draw stuff on an HTML canvas, you've got to manipulate it directly. In this case you've got to write a more explicit recipe to be able to re-create the same component from the same inputs.

Lets start with the required imports:

(ns simple.core
  (:require [reagent.core :as reagent]
            [reagent.dom :as rdom]
            [re-frame.db :as db]
            [re-frame.core :as rf]))

We're requiring reagent, clojurescript's wrapper for react, and re-frame, a library using reagent which gives you a central place to store state and makes sure your data is flowing in one direction.

In re-frame, the data flows like: database → subscriptions → view → events → database. The events step is special - it's only there where we can take in values from the environment, or modify them (eg. loading data from a website, or printing a cool picture). This is the way that re-frame lets you shepherd your program's state.

Events and Effects

We'll start with an event that initialises the state of our little app. We're gonna make a canvas which has a black box that jumps about, and the canvas can change size. This gives us two values to keep track of: the position of the box, and the canvas size. We'll make an event which returns the initial database:

(rf/reg-event-db
  :initialize
  (fn [_ _]
    {:pos [10 10]
     :canvas-size [200 200]}))
nil

We'll also need some other events: one to move the box to a random position, and one to change the canvas size. We could be cheeky and just do the random number generation inside the event functions, but re-frame gives us a really nice feature to keep the event handlers pure (same inputs → same outputs): interceptors (or effects and coeffects). We'll register some coeffects which will do the impure randomness generation and then input these to our pure event handler.

(rf/reg-cofx
 :random-pos
 (fn [cofx]
   (let [max-vals (get-in cofx [:db :canvas-size])]
     (assoc cofx :random-pos
            (mapv #(-> %1 (* %2) js/Math.floor)
                  [(js/Math.random) (js/Math.random)]
                  (map #(- % 10) max-vals))))))

(rf/reg-cofx
 :random-canvas
 (fn [cofx]
   (assoc cofx :random-canvas
          (mapv #(-> %1 (* %2) js/Math.floor)
                [(js/Math.random) (js/Math.random)]
                [300 300]))))

nil

This gives us two coeffects: :random-pos which takes the current canvas size from the database, and generates a random position within the canvas, and :random-canvas which changes the canvas size to a random number inside [300 300].

Now we want to create the events. Again, we'll need one to change the position, and one to change the canvas size:

(rf/reg-event-fx
 :change-position
 [(rf/inject-cofx :random-pos)]
 (fn [{:keys [db random-pos]} _]
   {:db (assoc db :pos random-pos)}))

(rf/reg-event-fx
 :change-size
 [(rf/inject-cofx :random-canvas)]
 (fn [{:keys [db random-canvas]}]
   {:db (assoc db :canvas-size random-canvas)}))

nil

We've injected the coeffects with rf/inject-cofx :cofx-name, and they just appear as arguments to our lovely pure event functions. One nice benefit of reducing stateful interactions to effects and coeffects is that you know exactly where interaction with the environment is happening, so bugs become easier to track down.

Now we've hooked up the internal logic of our beautiful canvas, how do we feed the database data to the view?

Subscriptions

Subscriptions are a set of functions which watch the parts of the database we need, extract the data when it changes, run calculations on the data, and cache it. The caching means that if multiple components need the same bit of derived data, we won't need to run the calculation multiple times. Subscriptions make our UI reactive, meaning it only changes when something in the database has changed. Our subscriptions will be super simple, we just need to extract the position and the canvas size from the database:

(rf/reg-sub
 :pos
 (fn [db] (:pos db)))

(rf/reg-sub
 :canvas-size
 (fn [db] (:canvas-size db)))

nil

This gives us all the data we need to create our amazing interface. All we have to do now is create the view.

The View

Our canvas will have an inner and outer part. The inner part will contain the react component, and will deal with the state. The outer part will take in the data from our subscriptions, and feed it to the inner part.

(defn canvas-inner [[w h]]
  (let [update (fn [canvas] (let [cv (rdom/dom-node canvas)
                                  ctx (.getContext cv "2d")
                                  [x y] (get (reagent/props canvas) :pos)]
                              (doto ctx
                                (.clearRect 0 0 (.-width cv) (.-height cv))
                                (.fillRect x y 10 10))))]
    (reagent/create-class
     {:reagent-render (fn []
                        [:canvas {:width w :height h}])
      :component-did-mount update
      :component-did-update update
      :display-name "canvas-inner"})))

(defn canvas-outer []
  (let [pos (rf/subscribe [:pos])
        canvas-size (rf/subscribe [:canvas-size])]
    [(canvas-inner @canvas-size) {:pos @pos}]))

In canvas-outer we subscribe to our subscriptions, and then use them to create our canvas-inner component. The canvas size is used as an argument to canvas-inner, since the canvas will need to be destroyed and re-created if this is changed. The position is passed in a map to the properties of the inner canvas. This allows it to be used in the update function in component-did-mount and component-did-update. canvas-inner is made as a react class, with a render function which just sets up the canvas, and an update function which gets the canvas context, clears it and draws a 10x10 rectangle at the given position.

Now we'll put it into a div with some buttons which fire our events (using rf/dispatch), and mount it to the dom on this page:

(defn ui
  []
  [:div
   [:div
    [:button {:onClick #(rf/dispatch [:change-position])} "change position"]
    [:button {:onClick #(rf/dispatch [:change-size])} "change size"]]
   [:div [:label (str "pos: " @(rf/subscribe [:pos])
                      "| canvas-size: " @(rf/subscribe [:canvas-size]))]]
   [canvas-outer]])

(defn ^:export run
  []
  (rf/dispatch-sync [:initialize]) 
  (rdom/render [ui]            
                  js/klipse-container))

(run)

@db/app-db

The end.