Skip to content

References and Application Design

zalky edited this page Apr 30, 2023 · 6 revisions

References

At the core of Reflet is the with-ref macro. The with-ref macro creates references that can be used as unique identifiers anywhere in your application. References, also referred to as "idents" in Datomic and EQL, are two element tuples containing a unique attribute and a unique value:

[:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
[:user/email "my@email.com"]

with-ref can generate references with any unique attribute out of the box. For example, if you want to generate unique references for managing focus across components, no special configuration is required:

(f/with-ref {:focus/id [id]}
  id)
;; =>
[:focus/id #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1])

However, if you want a reference to participate in graph data, then the Reflet db needs to be configured to work with the unique attribute in the reference. The Reflet db is schema-less, so the set of unique attributes is defined via a configuration map when you first initialize the Reflet db. See the Configuration document for more details.

Once a unique attribute has been configured for graph data, these references implicitly assert the existence of one and only one associated entity that also contains that same attribute-value pair:

[:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]

;; <=>

{:cmp/uuid       #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"
 :kr/name        "Name"
 :kr/description "Other data"}

Even if the entity has not actually been instantiated yet, the association is implicit. The semantics of uniqueness mean that there is no other entity in the program that can have the value #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6" for the attribute :cmp/uuid. In this way, the tuple [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6] can be used to uniquely refer to that entity anywhere in the application. This is important: it's the attribute-value pair, not the unique value alone, that makes a reference.

With an understanding of references, we can turn our attention back to with-ref.

with-ref

Consider the reflet.client.ui/player audio component in the Reflet example client:

(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 ...]))

This with-ref asserts four different references :in the component props map. The keys in the with-ref map declare the unique attributes that participate in the references, and the values specify where in the props map those references will be bound. For each symbol or keyword in a binding vector, a reference will be added to props at that attribute. The semantics are almost the inverse of Clojure map destructuring. Also, each unique attribute is associated with an extensible reference value type, the default type being cljs.core/UUID.

In this example, the with-ref asserts that the props map should have at least the following attributes:

{:player/self    [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
 :player/context [:js/uuid #uuid "3e57d7a2-9148-47b0-81b8-950ed11f74d0"]
 :player/source  [:js/uuid #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1"]
 :player/el      [:el/uuid #uuid "8f4549c8-20de-4c37-a4ec-6072b40e512f"]}

These references will be merged with any other values that are passed in as props. If the props map already contains values at a binding attribute, say :player/el, then with-ref will not generate a new reference or overwrite the existing value:

{:player/self    [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
 :player/context [:js/uuid #uuid "3e57d7a2-9148-47b0-81b8-950ed11f74d0"]
 :player/source  [:js/uuid #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1"]
 :player/el      [:other/id :i-was-passed-in-with-props]
 :so/was-i       :but-not-a-reference}

This is especially important in a React component context.

Reactive Cleanup

When used in a React component with-ref makes a distinction between two types of references:

  1. Newly created references that with-ref adds to props:

    These are considered local in scope to the with-ref reactive context, and will be cleaned up once its related component is unmounted. These references will also be marked as :transient true via metadata.

  2. External references that are passed in with props:

    These are considered external in scope to the with-ref reactive context, and will be left alone during component unmount.

Or summed up in a single statement:

Each with-ref is responsible only for the new transient references that it creates, and for cleaning them up when its associated component unmounts.

with-ref can also be used outside of a reactive context, for example in tests or in the REPL, but the refs will not be transient, and there will be no cleanup.

Cleanup behaviour is specific to the unique attribute that participates in the reference. The default built-in behaviour is to remove any associated entity from the db. There are also special behaviours predefined for :js/uuid and :el/uuid references, which is discussed in more detail in the Mutable State document. See the Advanced Usage document for how to extend your own cleanup methods for other attributes.

References that were not created by any with-ref are never cleaned up and considered persistent. For example, any references that you might receive from a remote data source will be persistent.

You can force a with-ref to create persistent refs using the :persist option:

(f/with-ref {:cmp/uuid [player/self]
             :persist  true
             :in       props}
  ...)

Even if this with-ref creates the :player/self ref, it will never be cleaned up. On the other hand, :persist true has no affect on already existing references that are passed in to the with-ref. If an external reference is transient, this with-ref cannot turn it persistent.

There is also a :meta option that can be used to attach arbitrary metadata to your newly created references. This can later be retrieved with reflet.db/ref-meta:

(f/with-ref {:cmp/uuid [player/self]
             :meta     {:provisional true}
             :in       props}
  (db/ref-meta self))

=>
{:provisional true, :transient true}
;; The :transient true would only be here in a reactive context.

Again, any external references that are passed into this with-ref cannot have their metadata changed.

Remember: each with-ref is only responsible for the new references that it creates.

Bindings

In addition to adding references to props, with-ref will expose local bindings for each of the declared references, whether they were passed in or not.

So the following:

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

Will return:

[[:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
 [:js/uuid #uuid "3e57d7a2-9148-47b0-81b8-950ed11f74d0"]
 [:js/uuid #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1"]
 [:other/id :i-was-passed-in-with-props]]

and corresponds to the values in the props map.

Alternatively, instead of adding references into an existing props map, you can generate an entirely new map if you choose a props symbol that is not already locally bound:

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

Above, new-props will be an entirely new map containing only the new references generated by the with-ref.

You can also omit the :in props binding completely, using with-ref to generate only locally bound references:

(f/with-ref {:cmp/uuid [a ::b]}
  ;; `a` and `b` are only available as local bindings
  [child-component a b])

Also note it is perfectly fine to have nested with-refs:

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :in       props}
    (f/with-ref {:cmp/uuid [player/local]}
      [:div {:on-click #(f/disp [::event local])}
       [child-component props]])))

Finally, there is an alternative syntax that allows more flexible binding of local symbols to props. Once again this corresponds closely to the semantics of Clojure map destructuring:

(f/with-ref {:cmp/uuid {self :player/self}
             :js/uuid  {ctx :player/context
                        src :player/source}
             :el/uuid  {dom :player/el}
             :in       props}
  [self ctx src dom props])

;; =>
[[:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
 [:js/uuid #uuid "3e57d7a2-9148-47b0-81b8-950ed11f74d0"]
 [:js/uuid #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1"]
 [:el/uuid #uuid "8f4549c8-20de-4c37-a4ec-6072b40e512f"]
 {:player/self    [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6"]
  :player/context [:js/uuid #uuid "3e57d7a2-9148-47b0-81b8-950ed11f74d0"]
  :player/source  [:js/uuid #uuid "1a9785dc-1ef2-4eed-8342-01fe76367bd1"]
  :player/el      [:el/uuid #uuid "8f4549c8-20de-4c37-a4ec-6072b40e512f"]}]

Application Design

With this simple way to create references, you can then use them anywhere you need an id, name or reference to something. You can reference domain data, mutable JS objects, it really doesn't matter.

For example, you can pass them to event handlers to reference component local app state:

(require '[reflet.db :as db])

(defn play-button
  [{:keys [player/self]}]
  [:button {:class    "primary"
            :on-click #(f/disp [::play self])}
   "Play"])

(f/reg-event-db ::play
  ;; This handler mixes graph and non-graph operations
  (fn [db [_ self]]
    (-> db
        (db/assoc-inn [self :player/state] ::playing)
        (assoc ::flag true))))

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :in       props}
    [:div
     [play-button props]
     ...]))

Above, the :player/self ref is used to store the current state of the audio player as a graph entity in the Re-frame db. db/assoc-inn is one of several graph mutation functions that can be used in event handlers. The multi-model db, and the full set of db mutation functions are discussed in detail here.

With this event handler defined, you can then subscribe to reactive changes in the graph data:

(f/reg-pull ::player-state
  (fn [self]
    [[:player/state
      {:player/track [:kr.track/name
                      :kr.track/uri]}]
     self]))

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :in       props}
    (let [{:player/keys [state track]} @(f/sub [::player-state self])]
      [:div
       [play-button props]
       [:div (:kr.track/name track)]])))

f/reg-pull and the graph query API are discussed in more detail here, but for now the most important thing to note is that the :player/self ref is the only thing that is passed around to complete the reactive loop. Thus references become the parameters that connect your application together. Combined with the semantics of with-ref behaviour, this encourages pluggable components and excellent APIs.

Extending Components

To see this more clearly, consider how a parent component might extend the functionality of player:

(defn extended-player
  [props]
  (f/with-ref {:cmp/uuid [player/self]             ; Parent also asserts :player/self
               :in       props}
    (let [state @(f/sub [::extended-query self])]
      [:div
       [player props]                              ; Passes props to child
       [:div {:on-click #(f/disp [::extended-event self])}
        "Extended Function"]
       ...])))

Here, the parent component also asserts the :player/self ref in props, and then passes those props along to the child component. But now, the parent with-ref is in control of the ref lifecycle, because that's where the ref was created. The child works exactly like it did before, but because the ref is passed in, when player unmounts it does not cleanup :player/self. This way the parent extended-player retains access to any referenced resource.

In essence, responsibility for the thing being referenced by :player/self is "lifted" up to the parent context, where the parent extends the functionality of player. This pattern can be repeated over and over higher up the component hierarchy, even though we really didn't design extended-player or player with this in mind. with-ref does all the work, and the child components will continue to function transparently.

Does this mean that every ref declared in a with-ref becomes part of the component's public API? Well, it's up to you. You can isolate some or all of the references by writing a with-ref with either a different :in props binding symbol, or no props binding symbol at all:

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :el/uuid  [player/el]
               :in       props}              ; Public props, part of component API
    (f/with-ref {:js/uuid [player/context player/source]
                 :in      props-private}     ; Private with different props binding symbol
      ...
      [child-component (merge props props-private)])))

Since no references are ever passed into the inner with-ref, no parent context can "lift" control of the refs in private-props.

Ultimately there are two big benefits that this seemingly simple macro brings:

  1. Resource access
  2. Transparent lifecycle management

And because they are just unique ids, they can be used almost anywhere, not just for graph data and queries.

This exact principle that works with references to app state, can be applied to references to mutable JS objects or DOM elements. In fact, most aspects of your component API will benefit from this kind of with-ref parameterization.

Reinforcing just how helpful with-ref can be for API design, is the powerful debugging paradigm that emerges almost for free with the use of with-ref. See the Debugging document for more info.

Data Attributes

When it comes to data attributes Reflet is agnostic about how you model your data. However, one recommendation is to use distinct sets of attributes between your component local state and domain data.

Reflet provides a default set of unique attributes so that all the design patterns that use with-ref work out of the box:

  • :system/uuid for domain data
  • :cmp/uuid for component local state
  • :js/uuid for JS Object references
  • :el/uuid for DOM element references

But you might end up defining your own, almost certainly for your domain data. See the Configuration document for how to define your own unique attributes.

Just keep in mind that the debugger was implemented using the default set. Therefore when loaded, the debugger will always add those attributes to any set that you define in order to support its implementation.

Another point about attributes: most of the documentation uses keywords for attributes. Of course if you're integrating with anything other than Clojure, your attributes are likely going to be strings. Reflet makes no assumptions in this regards, you can use either.

(f/reg-pull ::player-state
  (fn [self]
    [[:player/state
      {"player.track" ["kr.track.name"
                       "kr.track.uri"]}
     self]]))

Same with-let Usage and Warnings

with-ref leverages the Reagent reagent.core/with-let macro under the hood, and so many of the same usage semantics also apply. This section explains the meaning behind the following warning, and how to avoid it:

Warning: The same with-let is being used more than once in the same reactive context.

This warning only happens under a couple of specific scenarios, so you could skip this section and come back to it later if you encounter it. Just know that while this is a warning, it is not harmless! Its presence indicates that one or more with-refs or with-lets have likely produced identical references or values even thought that was not the intent.

Anyways, let's break this down:

  1. The same with-let: each with-let is uniquely identified by its lexical scope in code at compile time. Literally which line of code. This is an implementation detail of Reagent's with-let.

  2. reactive context: the React component in which the with-let is invoked at runtime. Here, the the distinction between function invocation () and component invocation [] is very important.

  3. used more than once: invoked more than once per render phase

The golden rule to avoid this warning could be re-stated as such:

Given that with-ref is uniquely identified by its lexical scope in code at compile time, each unique with-ref should be invoked only once per render of its surrounding React component.

The semantics just described apply equally to with-ref and with-let: you'll get the same warnings, and usage patterns.

Some Examples

Bad:

1 (defn component
2   [props xs]
3   [:div
4    (doall 
5     (for [[i x] (map-indexed vector xs)]
6       (f/with-ref {:el/uuid [el]}
7         ...)))])

Because of the for loop, the with-ref on line 6 of component is invoked multiple times per render of component. This will generate a warning.

In this case the warning could be resolved by wrapping the with-ref in a sub-component:

1  (defn child-component
2    [props]
3    (f/with-ref {:el/uuid [el]}
4      ...))
5 
6  (defn component
7    [props xs]
8    [:div
9     (doall 
10     (for [[i x] (map-indexed vector xs)]
11       ^{:key i} [child-component x]))])

Now the with-ref on line 3 of child-component is invoked once per render of child-component.

Also bad:

1  (defn child-maybe-component
2    [props]
3    (f/with-ref {:el/uuid [el]}
4      ...))
5 
6  (defn parent-component
7    [props]
8    [:div
9     (child-maybe-component props)
10    (child-maybe-component props)])

Here, using function invocation () on child-maybe-component means that the with-ref on line 3 of child-maybe-component is actually invoked twice within its component context: parent-component. This could be resolved by simply using component invocation on the child-maybe-components.

6  (defn parent-component
7    [props]
8    [:div
9     [child-maybe-component props]
10    [child-maybe-component props]])

Next: Multi Model DB

Home: Home