Skip to content

Mutable State

zalky edited this page Feb 7, 2023 · 2 revisions

The same design principles that were described in the [[References and Application Design]] document when applied to immutable app state, can be applied to mutable JS objects, or DOM elements.

JS Objects

Consider the traditional inner/outer component pattern for working with mutable JS state.

For the player component in the example client, it might look something like:

(require '[reflet.core :as f])

(defn player-inner
  [_]
  (let [js-obj (r/atom nil)]              ; <- reference to mutable JS object
    (r/create-class
     {:component-did-update
      (f/props-did-update-handler
       (fn [old-props new-props]
         (interop/update-obj old-props
                             new-props
                             js-obj)))    ; <- access reference in update handler

      :component-will-unmount
      (fn [_]
        (when-let [^js obj @js-obj]       ; <- and cleanup
          (.close obj)))

      :reagent-render
      (fn [props] ...)})))

(defn player
  [props]
  (let [data @(f/sub [::imp/data-sub props])]  ; <- immutable data dependencies
    [player-inner (merge props data)]
    ...))

Here, update-obj is both an idempotent constructor and updater. Such details, or whether you need a :component-did-mount are fairly context specific, so instead lets focus in on how the mutable state is managed.

The atom that stores the mutable JS object lives in a closure over the React lifecycle methods.

While this effectively encapsulates the mutable state in one place, this makes the player component difficult to extend. Often a parent component will require access to the mutable JS object to extend some piece of functionality.

You could accept an optional r/atom as an argument to the inner component:

(defn player-inner
  [{js-obj* :js-obj
    :as     props}]
  (let [js-obj (or js-obj* (r/atom nil))]
    (r/create-class
     ...)))

This way the parent context can pass in its own atom, gaining access to the JS object. But what about the unmount lifecycle method? If player-inner calls (.close obj) before the parent context is done with the JS object, you will get an error.

You could optionally pass in the :component-will-unmount function as an argument along with the JS object atom, but now your API has become much more complex.

with-ref gives us a better way.

Consider the outer player component again:

(require '[reflet.core :as f])

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :js/uuid  [player/context player/source]
               :el/uuid  [player/el]
               :in       props}
    [:div ...]))

It has a with-ref that creates two :js/uuid references in props:

  • :player/context
  • :player/source

The inner component is then modified so that it is no longer responsible for storing the JS object:

(require '[reagent.core :as r]
         '[reflet.core :as f])

(defn player-inner
  [_]
  (r/create-class
   {:component-did-update
    (f/props-did-update-handler interop/update-context)

    :reagent-render
    (fn [props] ...)}))

Here, the f/props-did-update-handler helper passes along the props to interop/update-context.

interop/update-context is still an idempotent constructor and updater. But now instead of passing around an atom, the :player/context and :player/source immutable refs are used to register two JS objects with the Reflet mutable object registry. It does this using reflet.interop/reg:

(require '[reflet.interop :as i])

(defn create-context
  "Idempotent constructor."
  [{el-ref      :player/el
    context-ref :player/context
    source-ref  :player/source}]
  (when-not (i/grab context-ref)                                ; <- We'll talk about this later
    (let [context (js/AudioContext.)
          el      (i/grab el-ref)
          source  (.createMediaElementSource context el)]
      (.connect source (.-destination context))
      (i/reg context-ref context {:destroy #(.close context)})  ; <- Register JS Object here!
      (i/reg source-ref source))))                              ; <- and here!

(defn update-context
  "Updates mutable JS Object, creating it if necessary."
  [old-props new-props]
  ...
  (create-context new-props)
  (when some-condition
    (update ...)))

During registry, the context object optionally declares a :destroy method. Any destroy method registered this way will be called by the with-ref that created the :js/uuid reference, when the associated component unmounts and the ref is cleaned up. In this case, the with-ref of the outer player component is in charge of cleanup.

Side note: Calling :destroy is a cleanup behaviour, and cleanup behaviours are properties of the ref's unique attribute (as explained in the References and Application Design document). In this case the unique attribute is :js/uuid. With a different attribute, say :cmp/uuid, you could still register the object and retrieve it, but a different cleanup method would be called. When it comes to cleanup, make sure to use the right attribute for the given context.

Now consider how the parent context might extend the player component. Let's create an extended-player with an inner/outer component pattern:

(require '[reflet.interop :as i]
         '[reagent.core :as r])

(defn extended-player-inner
  [_]
  (r/create-class
   {:component-did-update
    (f/props-did-update-handler
     (fn [old-props new-props]
       (... (i/grab (:player/context new-props)))))  ; Grab JS object here

    :reagent-render
    (fn [props]
      [:div ...])}))

(defn extended-player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :js/uuid  [player/context]            ; Assert :player/context
               :in       props}
    (let [data (f/sub [::extended-data self])]
      [:div
       [player props]                                ; Props passed to child component
       [:div {:on-click #(f/disp [::extended-event self])}]
       [extended-player-inner (merge props @data)]])))

The parent context with-ref also asserts a :player/context ref, just like the child. Also, the reflet.interop/grab method is used to retrieve the JS object from the Reflet mutable object registry, given the :player/context reference.

Now, not only does the parent context have easy access to the JS object via the :player/context reference, but the lifecycle of the referenced object has been "lifted" to the parent context.

Remember: every with-ref is in charge of the lifecycle of the transient refs that it creates. The with-ref in extended-player component created the :player/context ref, therefore the with-ref in extended-player decides when the referenced object is no longer needed. extended-player can be sure that the JS object will not be prematurely destroyed by the player child component. And yet nothing changed in the player implementation, it works exactly as it did before.

Also notice that the only thing that gets passed around in this entire example are immutable references.

DOM Elements

DOM element access works almost exactly the same way, except there is a different registry function.

First, recall the canonical way of gaining access to the underlying DOM element using React Refs (if you're hazy on the details, this is a nice and short description). Notice that just like with mutable JS objects, the canonical approach requires the component to create a closure over an atom that stores the thing we need inside the component.

However, just like with the mutable JS object, if some parent requires access to the DOM node to extend functionality, you have to re-write your code to start passing around the atom.

Enter with-ref.

Recall that the player with-ref also created a :player/el reference:

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :js/uuid  [player/context player/source]
               :el/uuid  [player/el]
               :in       props}
    ...
    [:div
     [player-inner (merge props data)]
     ...]))

In the player-inner component, we can then pass this reference to the reflet.interop/el! function to generate a :ref callback in the render function.

(require '[reflet.interop :as i])

(defn player-inner
  [_]
  (r/create-class
   {...
    :reagent-render
    (fn [{el-ref :player/el}]
      [:audio {:ref (i/el! el-ref)  ; Register DOM element using :player/el ref
               ...}])}))

Once the element is mounted, el! will register the underlying DOM element with the Reflet mutable object registry.

Then in the extended-player component, we could also assert :player/el, and use reflet.interop/grab to retrieve the DOM element in our update handlers:

(defn extended-player-inner
  [_]
  (r/create-class
   {:component-did-update
    (f/props-did-update-handler
     (fn [old-props new-props]
       (... (i/grab (:player/el new-props)))))   ; Grab DOM element here

    ...}))

(defn extended-player
  [props]
  (f/with-ref {:el/uuid [player/el]              ; Assert :player/el
               :in      props}
    ...
    [:div
     [player props]                              ; Pass props to child component
     [extended-player-inner (merge props data)]
     ...]))

Like before, the player child component works transparently without modifications.

Reflet also provides a subscription equivalent of reflet.interop/grab for use during the render phase:

(f/sub [::i/grab (:player/el props)])

You will probably use this less often than the i/grab function, because in general you should avoid performing mutations on your DOM elements during the render phase.

However the ::i/grab subscription is useful when you want to read computed styles from one DOM element to generate inline styles for another. In this case, you might provide (f/sub [::i/grab ref]) as an input to a layer-3 subscription to compute the inline style map.

A quick sketch: at some place in your component tree you would use a with-ref reference to generate a React Ref:

(defn component
  [prop]
  (f/with-ref {:cmp/uuid [component/self]
               :el/uuid  [component/el]
               :in       props}
    [:div {:ref (i/el! el)}         ; <- ref passed here, :component/el registered on mount
     ...])) 

Then elsewhere in your subscription signal graph you would pass that :component/el reference to the ::i/grab subscription:

(f/reg-sub ::style-map
  (fn [[_ el-ref]]
    (f/sub [::i/grab el-ref]))      ; <- this is the :component/el ref
  (fn [^js el _]                    ; <- this is the DOM element
    (when el
      ;; Generate style map
      {:padding (get-padding el)
       :border  (get-border el)
       ...})))

You could then subscribe to the style map in your view code:

[:div {:style @(f/sub [::style-map el-ref])}]

The Reflet debugger implementation does this in one place to get nicer visual effects when interacting with panels.

Notice that just like with the JS example, the only thing that ever gets passed around is the immutable DOM reference.

el! Lifecycle Callbacks

You can pass a :mount function to i/el!, which will be run only one time, after the underlying DOM element has been mounted into the DOM tree. This :mount function accepts a single argument, the DOM element in question, and is run just before the :component-did-mount lifecycle method:

(defn component
  [prop]
  (f/with-ref {:cmp/uuid [component/self]
               :el/uuid  [component/el]
               :in       props}
    [:div {:ref (i/el! el :mount #(f/disp [::some-init %]))}  ; <- called JUST before did-mount, % is the DOM element
     ...]))

Importantly, :mount is not a cleanup behaviour, so it does not depend on the unique attribute of the ref. It will work with any ref.

On that note, just like how i/reg allows you to register an optional :destroy method for JS objects, i/el! allows you to provide an optional :unmount function with no arguments that is run when the with-ref :el/uuid reference used to register the DOM element is cleaned up:

(defn component
  [prop]
  (f/with-ref {:cmp/uuid [component/self]
               :el/uuid  [component/el]
               :in       props}
    [:div {:ref (i/el! el :unmount #(do cleanup... ))}  ; <- called when :component/el is cleaned up
     ...]))

Like with any cleanup behaviour, calling :unmount is a property of the unique attribute, in this case :el/uuid.


Next: Debugging

Home: Home