-
Notifications
You must be signed in to change notification settings - Fork 2
Mutable State
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.
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 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.
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