-
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