Skip to content

References and Application Design

zalky edited this page Feb 7, 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 an 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        "Entity"
 :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 value [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6] can be used to uniquely identify or refer to that entity anywhere in the application.

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 reactive component context, where with-ref makes a distinction between two types of references:

  1. External references that are passed in with props
  2. Newly created references that with-ref adds to props

Any newly created references 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 be marked as :transient true via metadata.

Any external references that are passed in via props 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, in tests or the REPL for example, but the refs will not be transient, and there will be no cleanup.

Cleanup behaviour is extensible, and specific to the unique attribute that participates in the reference. The default built-in behaviour is to remove any associated entity in the multi-model 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. Also, see the Advanced Usage document for how to extend your own cleanup methods.

References that were not created by any with-ref are never cleaned up and considered persistent. You can force with-ref to create only persistent refs using the :persist option:

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

This with-ref will never clean up the :player/self ref, even if it was the one that created it. On the other hand, if the reference was created by some other with-ref, and passed into this one, then the :persist true here has no affect on that reference. If that external reference was transient, this with-ref could not 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 will not have their metadata changed.

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

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 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.

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. In the child component nothing changes, 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 until the parent is unmounted.

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. The player component however, will transparently work exactly as it did before, because it doesn't really care about where the :player/self ref came from. As long as it has one, the implementation will work.

This way of extending components can be repeated over and over in contexts 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 function transparently exactly as they did before.

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 no :in props binding, or a different :in props binding symbol:

(defn player
  [props]
  (f/with-ref {:cmp/uuid [player/self]
               :el/uuid  [player/el]}        ; Private since no props binding
    (f/with-ref {:js/uuid [player/context player/source]
                 :in      props-private}     ; Private with different props binding
      [:div ... (merge props props-private)])))

As defined above, both with-refs will always generate their own references no matter what is passed in via the component props, and no parent context can "lift" control of those refs.

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

  1. Resource access
  2. Transparent lifecycle management

And because they are simply 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. Obviously there is still a place for regular inline parameters, like setting a feature flag on a component, or a declarative spec. But lifecycle management and resource access (immutable or mutable) are typically what drive API complexity.

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.

Attributes

When it comes to regular and unique 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. In fact you almost certainly will for your domain data. See the Configuration for how to do this.

Just keep in mind that the debugger was implemented using the default set of unique attributes. Therefore when loaded, the debugger will always add the default set 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 uses the Reagent reagent.core/with-let macro under the hood, and 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 it is not a harmless warning. Its presence indicates that one or more with-refs/with-lets have likely produced identical values even thought that was likely 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