-
Notifications
You must be signed in to change notification settings - Fork 2
References and Application Design
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 tuple [:cmp/uuid #uuid "bf8cc02d-271d-4779-843c-e3829f800cb6]
can be used to uniquely
identify or 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
.
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:
- External references that are passed in with props
- 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 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. 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. For example, any references that you
receive from a remote data source will be persistent.
You can force a 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-ref
s:
(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"]}]
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.
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-ref
s 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:
- Resource access
- 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.
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 document 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]]))
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-ref
s or with-let
s have likely
produced identical references or values even thought that was
probably not the intent.
Anyways, let's break this down:
-
The same with-let
: eachwith-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'swith-let
. -
reactive context
: the React component in which thewith-let
is invoked at runtime. Here, the the distinction between function invocation()
and component invocation[]
is very important. -
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 uniquewith-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.
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-component
s.
6 (defn parent-component
7 [props]
8 [:div
9 [child-maybe-component props]
10 [child-maybe-component props]])
Next: Multi Model DB
Home: Home