-
Notifications
You must be signed in to change notification settings - Fork 2
Om Next Overview
WARNING: THIS DOCUMENT HAS NOT BEEN REVIEWED/APPROVED BY DAVID. IT MAY CONTAIN INCORRECT OR MISLEADING INFORMATION. THIS DOCUMENT IS BASED ON OM-1.0-ALPHA22.
It is a good idea to start with Quick Start on the main Om Wiki.
This overview is intended to help you understand how Om Next works, how you do the basic UI creation and composition, and how to write the various components you supply to build your application.
There will be examples that you can try, and they should work just fine via the setup described in the Quick Start.
Om Next is very much based on the innovations of Relay and Falcor. I highly recommend you read at least the base Relay documentation (Thinking in Relay and Guides -> Mutation, in particular).
As a convenience to the reader, I will summarize some of the most important bits here.
Building some number of UIs that are intended to run on various devices presents some real problems. Mobile apps, for example, typically want to ask for smaller data sets than desktop versions. Traditional frameworks encourage you to use REST APIs, and the resulting hard requirements of applications result in all sorts of task-specific API endpoints.
UIs need to grab arbitrary data from some database. This might be queries against normalized data, aggregations...really anything. This leads to the API explosion (different UIs -> narrowed queries), but it also leads to the whole MVC architecture. The Model rarely matches the UI, so Controllers are written to interpret the queries into some localized UI tree (and interpret user interactions in reverse). The view ends up being an artifact with blurry lines.
One of the problems happens when more than one UI component relies on the same underlying data (e.g. a table and a graph). Updating the data should update both UI representations. This problem is exacerbated when you represent application UI state as a tree, since such a representation is really a graph (two siblings want to refer to the same data).
Another problem happens as soon as you have two kinds of client UIs: mobile vs desktop. Now you have components that want less detail for a smaller screen, and you end up generating alternate code paths to handle that difference.
The basic overall problem can be summarized as: the UI developer is constantly wanting "new queries" on centralized data that result in a structure and content that matches the desired UI. This mismatch leads to a lot of incidental complexity and boilerplate code.
When composing a random smattering of UI components in traditional systems there is an initial problem: How do we get the data into the UI, decide what to render, and render it. It is not uncommon for a webapp to spin off a whole slew of async data retrievals when there is any attempt to compose disparate components. Making these things "work together" in the UI can be troublesome.
Being able to place something on the UI while waiting for a server response is a practical desire of most developers. Showing a "please wait" (or worse just hanging the UI) while you wait to see if something is "OK" gives a less-than-optimal user experience when the network is slow, or you wish those network interactions to be frequent.
The REST API explosion problem is rooted around the idea that the "server team" has to write new APIs any time the "UI team" needs a different query. One of the big innovations of Relay and Falcor is the idea that a client should be able to build a targeted query, and the server should be responsible for adding a security/interpretation layer for those queries. This goes beyond systems like OData (where you can select fields and such) because the query itself is a graph-compatible thing.
By using a graph query language instead of REST the server-side problem is just one of parsing a graph query and validating security. Graph-capable databases (especially Datomic) make writing such server-side APIs much simpler, though the problem general boils down to the well-understood theory of parsing. The necessary grammar for these queries (rooted in Datomic Pull syntax) is very small and minimizes the kinds of one-off code needed by REST.
The next innovation is to co-locate the queries with the UI component that needs data. The query language is recursive in nature, allowing you to structure the query as a graph that matches your current UI tree (which itself can dynamically evolve over runtime).
The combination of dynamic query composition and customization means the UI automatically adapts the data interations to exactly what is needed and eliminates a lot of incidental complexity found in typical middle layers.
The colocated queries come with some cost. In particular, you should recognize:
- A query on a component is a composable fragment of the overall query of the application.
- The overall query must compose to root.
- The root must be unambiguous or you have no idea what to actually read.
However, the final benefits include:
- Components that compose in terms of UI and state.
- Local reasoning (critical for reusable components)
- Global composition of the initial application query (the initial query composes into a single conceptual startup query to run against the server)
In this data-driven model, one defines mutations as named operations. An abstraction just like a function. When one runs a mutation there are a lot of things that can happen. For example, if I add a "sale" to a database, all sorts of real and derived data change.
- The line items on an associated invoice change
- Reports about revenue are now different
- The week-to-week report of profit changes
etc.
However, any given UI may or (more likely) may not care about all of those changes. But the fact remains that those bits of real and derived data have changed, and some part of the UI that ran the mutation might depend on them.
So, we would like the author of the UI to be able to indicate which of these they have an interest in.
Relay addresses this with the concept of the "Fat Query". Here's how it works: Any mutation declares a query that includes all of the different things that are affected by the mutation. This is documentation to the user of the mutation. The server side of the story must already support reading anything a UI might need, so it is possible to write mutations that include these details.
When a UI writer calls such a mutation they include a sub-set of this "fat query" as an indication that the UI (in general) needs to be sent updates about those facts.
This allows a UI component to do a mutation while being confident that any other component that is currently displayed in the UI will get up-to-date data as a side-effect of the mutation (without needing to write the code to explicitly update it).
Note: This is a subtree concern. A UI cannot possibly indicate what things it depends on at some "leaf" of the UI and hope to remain composable, since that leaf component should care only about its local state. Thus, mutations that can affect many things can only be reasoned about at some node in the UI tree that "owns" all of the things that can be affected.
An example should help.
Say you have a UI that shows the current friend count in one corner, and has a list of "people you might know". Each person has a "Friend" button.
Friends: 23
+-------------------------+
| Joe Friend |
+-------------------------+
| Sally Friend |
+-------------------------+
The "Person" component in the table is something you want to re-use. It queries for the person's name, and possibly if you are currently their friend.
How do we "hook up" the "Friend" Button??? If we do the mutation in the Person component, then we cannot possibly make it a reusable component, because we'd need to write the mutation request in such a way as to say:
"Add Sally to my friend list, then re-read Sally...oh, and I also need to re-read my friend count".
It's that latter bit that is the problem. If I'm writing "re-read my friend count" in the Person renderer, then I'm breaking encapsulation.
Instead, I should write a Person component that supports an "onFriend" handler, such that I can just call it, and expect some parent to supply the action:
;; PSEUDO-Sample call to render one of the people in the list...missing query data
(person { :onFriend (fn [] ...) })
Now my top-level component (the one that renders the friend count and "people you might know") can generate a function that can do the proper mutation, and that top-level component also has to know that both the friend count and friend list are involved (because it is explicitly rendering them).
Om supports the "Fat Query" notation when writing mutations. Such fat queries are documentation to the caller about what things a mutation changes so that it is possible to correctly (and simultaneously) re-query all relevant data after a mutation to make sure all components in the UI are correct.
Your application state can take any form you want. It can be in a browser database, a cljs map held by an atom, etc. The representation of your state needs to be something you can reason about and manipulate.
It is also required that this state be normalized (which Om Next can automatically do, but for this document, we'll use something we've normalized ourselves).
Part of the trouble with comprehending Om Next from the start is the fact that your application state can take any shape you want. The grammar of the component queries is unrelated (though if you want to structure your app state to make it easier to join the two together, that's nice too).
You're trying to make it possible to do two things:
- Write arbitrary functions that can retrieve data when given a query in the Om Query Grammar.
- Write arbitrary functions that can update your app state given abstract mutations. These mutations include a "fat query" that indicates what related data is affected, such that the UI in question can decide what to re-query when making that change.
Let's start with the function that retrieve data and the Grammar they must follow.
NOTE: You will often see quoting used in queries. Many of the examples in this document do not use quoting, because the data in question is just data. You should make sure you understand quoting, syntax quote, and unquote (in Clojure(script)). Some of the grammar uses parens, which is what leads to the quoting in examples.
The basic read functions provide a "router/parser" in concept. The base requirement is that they understand the Query Grammar, and return data in a form that matches the expected query response.
All queries are vectors (except for Union queries, which are maps whose values are vectors). If the vector contains multiple things, then you are asking for multiple seperate results.
NOTE: The grammar is not yet complete, but I'll cover the bits that are generally stable at the time of this writing (Oct 2015).
For reference, here are the defined grammar elements:
[:some/key] ;;prop
[(:some/key {:arg :foo})] ;;prop + params
[{:some/key [:sub/key]}] ;;join + sub-select
[({:some/key [:sub/key]} {:arg :foo})] ;;join + params
[[:foo/by-id 0]] ;;reference
[(fire-missiles!)] ;;mutation
[(fire-missiles! {:target :foo})] ;;mutation + params
{ :photo [...subquery...]
:video [...subquery...]
:comment [...subquery...] } ;;union
RECOMMENDATION: Even if you do not plan to use Datomic, I highly recommend going through the Datomic Pull Tutorial. It will really help you with Om Next queries.
In order to get started, we'll just consider the two most common bits of query notation: keywords (props) and joins. The params are simply that: parameters you want to pass to the query. These can be static data, but can also be manipulated via Query Parameters (a mechanism of Om). But more on that later.
The simplest item in the vector is just a keyword. More than one indicates you want a result for each:
[:user/name :user/address]
which means "I'd like to query for the properties :user/name and :user/address".
The result of a parse of such a query must always be a map. In this case, a map keyed by the requested properties:
{ :user/name "Joe" :user/address "111 Nowhere St." }
Basic form:
{ :keyword SELECTOR }
where SELECTOR is a vector of further things (e.g. keywords or joins).
So a join entry for your query is written as a (possibly recursive) map:
{ :property/name [:key1 :key2] }
The intended interpretation is that of a graph walk. In other words, "this
thing I'm querying will have something it calls :property/name, which will
reference one or more things. I'd like to get :key1
and :key2
on each of them".
So, a full query might look like this:
[
{:people [:person/name :person/address]}
{:places [:place/name]}
]
which is asking for two things (which themselves will contain zero or more sub-things).
Remember that the output of a parse of a query is always a map, and in this case the query result should take this form:
{ :people [ {:person/name "Joe" :person/address "..." }
{:person/name "Sally" :person/address "..." }
{:person/name "Marge" :person/address "..." } ]
:places [ {:place/name "Washington D.C." } ]
}
IMPORTANT NOTE: Om Next has no magic here. It cannot magically create this output result...you have to play along (the shape of the result must match the shape indicated by the query). The parser, however, is written to make it pretty easy. So, let's look at how we do that.
When building your application you must build a read function such that it can pull data that the parser needs to fill in the result of a query parse.
The Om Next parser understands the grammar, but you know your data. At the start of your application the parse invokes read for each top-level element in the query. So:
[:kw {:j [:v]}]
would result in a call to your read function on :kw and {:j [:v]}. Two calls. No automatic recursion. Done. The output value of the parser will be a map (that parse creates) which contains the keys (from the query, copied over by the parser) and values (obtained from your read):
{ :kw value-from-read-for-kw :j value-from-read-for-j }
Note that if your read accidentally returns a scalar for :j
then you've not
done the right thing...a join like { :j [:k] }
expects a result that is a
vector of (zero or more) things or a singleton object that contains key
:k
.
{ :kw 21 :j { :k 42 } } ; OR
{ :kw 21 :j [{ :k 42 } {:k 43}] }
Dealing with recursive queries is a natural fit for a recursive algorithm, and it
is perfectly fine to invoke the parser
function to descend the query. In fact,
the parser
is passed as part of your environment.
So, the read function you write:
- Will receive three arguments:
- An environment containing:
-
:parser
: The query parser -
:state
: The application state (atom) -
:query
: if the query had one E.g.{:people [:user/name]}
has:query
[:user/name]
-
- A key whose form may vary based on the grammar form used (e.g.
:user/name
). - Parameters (which are nil if not supplied in the query)
- An environment containing:
- Must return a value that has the shape implied by the grammar element being read.
The parse will create the output map.
If the parser encounters a keyword :kw
, your function will be called with:
(your-read
{ :state app-state :parser (fn ...) } ;; the environment. App state, parser, etc.
:kw ;; the keyword
nil) ;; no parameters
in this case, your read function should return some value that makes sense for that spot in the grammar. There are no real restrictions on what that data value has to be in this case. There is no further shape implied by the grammar. It could be a string, number, Entity Object, JS Date, nil, etc.
Due to additional features of the parser, your return value must be wrapped in a
map with the key :value
. Thus, a very simple read for props (keywords) could be:
(defn read [env key params] { :value 42 })
and you have a read function that returns the meaning of life the universe and everything in a single line! Of course we're ignoring the meaning of the question, but we can now read any query that contains only keywords. Try it out via the REPL:
(def state (atom {}))
(defn read [env key params] { :value 42 })
(def my-parser (om/parser {:read read}))
(my-parser {:state state} '[:a :b :c])
and you should see the following output:
{:a 42, :b 42, :c 42}
As advertised! Every property in your system now has the value 42.
If your app state is just a flat set of scalar values with unique keyword identities, then a real read is similarly trivial:
(def app-state (atom {:a 1 :b 2 :c 99}))
(defn read [{:keys [state]} key params] { :value (get @state key) })
(def my-parser (om/parser {:read read}))
(my-parser {:state app-state} '[:a :b :c])
and you should see the following output:
{:a 1, :b 2, :c 99}
Of course, your app state probably has some more structure to it.
Joins are naturally recursive, and if we revert back to
our old parser that thinks 42
is right for all questions:
(def app-state (atom {:a 1 :b 2 :c 99}))
(defn read [env key params] { :value 42 })
(def my-parser (om/parser {:read read}))
(my-parser {:state app-state} '[:a {:user [:user/name]} :c])
then the read of the join is:
{:a 42, :user 42, :c 42}
No recursion happened! If you put a println
in the read, you'll see it is only
called three times.
Those that are accustomed to writing parsers probably already see the solution.
Let's clarify what the read function will receive in this case. When parsing:
{ :j [:a :b :c] }
your read function will be called with:
(your-read { :state state :parser (fn ...) :query [:a :b :c] } ; NOTE: query is set
:j ; keyword as expected
nil)
So, we can get a basic recursive parse using just a bit more flat data:
(def app-state (atom {:a 1 :user/name "Sam" :c 99}))
(defn read [{:keys [state parser query] :as env} key params]
(if (= :user key)
{:value (parser env query)} ; query is now [:user/name]
{:value (get @state key)})) ; gets called for :user/name :a and :c
(def my-parser (om/parser {:read read}))
(my-parser {:state app-state} '[:a {:user [:user/name]} :c])
The important bit is the then
part of the if
. Return a value that is
the recursive parse of the query. Otherwise, we just look up the keyword
in the state (which is a very flat map).
The return value now has the correct structure of the desired response:
{:a 1, :user {:user/name "Sam"}, :c 99}
The first (possibly surprising thing) is that your result includes a nested object, and you didn't even need to create it (the internals of the parser did that).
Next you should remember that join implies there could be one OR many results. The singleton case is fine (e.g. putting a single map there). If there are multiple results it should be a vector.
In this case, we're just showing that you can use the parser to parse something
you already know how to parse, and that in turn will call your read function.
In a real application, you will almost certainly not call parser
in quite this
way (since you need to actually do something to run your join!).
So, let's put a little better state in our application, and write a more realistic parser.
Let's start with the following hand-normalized application state. Note that I'm not using the query grammar for object references (which take the form [:kw id]). Writing a more complex parser will benefit from doing so, but it's our data and we can do what we want to!
(def app-state (atom {
:window/size [1920 1200]
:friends #{1 3} ; these are people IDs...see map below for the objects themselves
:people/by-id {
1 { :id 1 :name "Sally" :age 22 :married false }
2 { :id 2 :name "Joe" :age 22 :married false }
3 { :id 3 :name "Paul" :age 22 :married true :married-to 2}
4 { :id 4 :name "Mary" :age 22 :married false } }
}))
now we want to be able to write the following query:
(def query [:window/size {:friends [:name :married]}])
Here is where multi-methods start to come in handy. Let's use one:
(defmulti rread om/dispatch) ; dispatch by key
(defmethod rread :default [{:keys [state]} key params] nil)
The om/dispatch
literally means dispatch by the key
parameter.
We also define a default method, so that if we fail we'll get
an error message in our console, but the parse will continue
(returning nil from a read elides that key in the result).
Now we can do the easy case: If we see something ask for window size:
(defmethod rread :window/size [{:keys [state]} key params] {:value (get @state :window/size)})
Bingo! We've got part of our parser. Try it out:
(def my-parser (om/parser {:read rread}))
(my-parser {:state app-state} query)
and you should see:
{:window/size [1920 1200]}
The join result (friends
) is elided because our default rread
got called and
returned nil
(no results). OK, let's fix that:
(defmethod rread :friends [{:keys [state query parser path]} key params]
(let [friend-ids (get @state :friends)
get-friend (fn [id] (get-in @state [:people/by-id id]))
friends (mapv get-friend friend-ids)]
{:value friends}
)
)
when you run the query now, you should see:
{:window/size [1920 1200],
:friends [{:id 1, :name "Sally", :age 22, :married false}
{:id 3, :name "Paul", :age 22, :married true, :married-to 2}]}
Looks mostly right...but we only asked for :name
and :married
. Your
read function is responsible for the value, and we ignored the query!
This is pretty easy to remedy with the standard select-keys
function. Change
the get-friend embedded function to:
get-friend (fn [id] (select-keys (get-in @state [:people/by-id id]) query))
and now you've satisfied the query:
{:window/size [1920 1200],
:friends [{:name "Sally", :married false}
{:name "Paul", :married true}]}
Those of you paying close attention will notice that we have yet to need recursion. We've also done something a bit naive: select-keys assumes that query contains only keys! What if our query were instead:
(def query [:window/size
{:friends [:name :married {:married-to [:name]} ]}])
Now things get interesting, and I'm sure more than one reader will have an opinion on how to proceed. My aim is to show that the parser can be called recursively to handle these things, not to find the perfect structure for the parser in general, so I'm going to do something simple.
The primary trick I'm going to exploit is the fact that env
is just a map, and
that we can add stuff to it. When we are in the context of a person, we'll add
:person
to the environment, and pass that to parser
. This makes parsing a query
like [:name :age]
as trivial as:
(defmethod rread :name [{:keys [person]} key _] {:value (get person key)})
(defmethod rread :age [{:keys [person]} key _] {:value (get person key)})
(defmethod rread :married [{:keys [person]} key _] {:value (get person key)})
(defmethod rread :friends [{:keys [state query parser path] :as env} key params]
(let [friend-ids (get @state :friends)
get-person (fn [id]
(let [raw-person (get-in @state [:people/by-id id])
env' (dissoc env :query) ; clear the parent query
env-with-person (assoc env' :person raw-person)]
(parser env-with-person query)
))
friends (mapv get-person friend-ids)]
{:value friends}
)
)
The three important bits:
- We need to remove the :query from the environment, otherwise our nested
read function will get the old query on plain keywords, making it
impossible to tell if the parser saw
[:married-to]
vs.{ :married-to [...] }
. - For convenience, we add
:person
to the environment. - The
rread
for plain scalars (like:name
) are now trivial...just look on the person in the environment!
The final piece is hopefully pretty transparent at this point. For
:married-to
, we have two possibilities: it is queried as a raw value
[:married-to]
or it is joined { :married-to [:attrs] }
. By clearing the
query
in the :friends
rread
, we can tell the difference (since parser
will add back a query if it parses a join).
So, our final bit of this parser could be:
(defmethod rread :married-to
[{:keys [state person parser query] :as env} key params]
(let [partner-id (:married-to person)]
(cond
(and query partner-id) { :value [(select-keys (get-in @state [:people/by-id partner-id]) query)]}
:else {:value partner-id}
)))
If further recursion is to be supported on this query, then rinse and repeat.
For those who read to the end first, here is an overall runnable segment of code for this parser:
(def app-state (atom {
:window/size [1920 1200]
:friends #{1 3} ; these are people IDs...see map below for the objects themselves
:people/by-id {
1 {:id 1 :name "Sally" :age 22 :married false}
2 {:id 2 :name "Joe" :age 22 :married false}
3 {:id 3 :name "Paul" :age 22 :married true :married-to 2}
4 {:id 4 :name "Mary" :age 22 :married false}}
}))
(def query-props [:window/size {:friends [:name :married :married-to]}])
(def query-joined [:window/size {:friends [:name :married {:married-to [:name]}]}])
(defmulti rread om/dispatch)
(defmethod rread :default [{:keys [state]} key params] (println "YOU MISSED " key) nil)
(defmethod rread :window/size [{:keys [state]} key params] {:value (get @state :window/size)})
(defmethod rread :name [{:keys [person query]} key params] {:value (get person key)})
(defmethod rread :age [{:keys [person query]} key params] {:value (get person key)})
(defmethod rread :married [{:keys [person query]} key params] {:value (get person key)})
(defmethod rread :married-to
;; person is placed in env by rread :friends
[{:keys [state person parser query] :as env} key params]
(let [partner-id (:married-to person)]
(cond
(and query partner-id) {:value [(select-keys (get-in @state [:people/by-id partner-id]) query)]}
:else {:value partner-id}
)))
(defmethod rread :friends [{:keys [state query parser path] :as env} key params]
(let [friend-ids (get @state :friends)
keywords (filter keyword? query)
joins (filter map? query)
get-person (fn [id]
(let [raw-person (get-in @state [:people/by-id id])
env' (dissoc env :query)
env-with-person (assoc env' :person raw-person)]
;; recursively call parser w/modified env
(parser env-with-person query)
))
friends (mapv get-person friend-ids)]
{:value friends}
)
)
(def my-parser (om/parser {:read rread}))
;; remember to add a require for cljs.pprint to your namespace
(cljs.pprint/pprint (my-parser {:state app-state} query-props))
(cljs.pprint/pprint (my-parser {:state app-state} query-joined))
In the query grammar most kinds of rules accept parameters. These are intended to be combined with dynamic queries that will allow your UI to have some control over what you want to read from the application state (think filtering, pagination, and such).
Remember that Om Next has a story for integrating with server communications, and these remote queries are meant to be transparent (from the UI perspective). If the UI needs less data parameters and query details can fine-tune what gets transferred over the wire.
As you might expect, the parameters are just passed into your read function as the third argument. You are responsible for both defining and interpreting them. They have no rules other than they are maps:
[(:load/start-time {:locale "es-MX" })] ;;prop + params
invokes read with:
(your-read env :load/start-time { :locale "es-MX" })
the implication is clear. The code is up to you.
The query grammar includes a form for an entity reference:
[:people/by-id id]
The convention is that the keyword be namespaced with the type of thing, and the name indicates how it is indexed. This is a convention, but since it is self-documenting it is wise to follow it.
Also, remember the queries are enclosed in vectors, so asking for something by reference means you end up with a double-nested vector:
[ [:people/by-id 42] ]
means look up a person whose id is 42. The response will be:
{ [:people/by-id 42] ...whatever your read returns... }
Again, by convention, your read probably returns a map, but there is nothing to say that this couldn't be a more complex object (e.g. a Datascript Entity).
In the earlier example of writing read/parser, the app state was using raw numbers
as IDs. For demonstration purposes this was simpler (in that it required no
further explanation), but you'll find it much easier to reason about your code
if you store your references in the query grammar format above. It makes the
data more obvious, and it also makes it trivial to write your read methods since
the incoming key can just go straight to a get-in
. Below is an example of
our previous app state refactored to use reference notation:
(def app-state (atom {
:window/size [1920 1200]
:friends #{[:person/by-id 1] [:person/by-id 3]}
:person/by-id {
1 { :id 1 :name "Sally" :age 22 :married false }
2 { :id 2 :name "Joe" :age 22 :married false }
3 {:id 3 :name "Paul" :age 22 :married true :married-to [:person/by-id 2]}
4 { :id 4 :name "Mary" :age 22 :married false } }
}))
If you now go back and work through the parser code, you'll see that a lot of it gets a bit shorter. Not a bad exercise for the reader. Those who have read Components, Identity & Normalization will recognize that the built-in normalization support can use your UI code to build normalized tables that look a lot like what you see above.
OK, the mutation side of the grammar should now be easier to understand. Some of the details are the same: The parser understands the basic structure of the syntax, but you're responsible for the grunt work.
Running mutations is done with transact!
. For the moment we'll ignore the UI
side of the equation and just concentrate on the data. It's important to
understand that there are two concerns: changing the app state and updating
the UI to reflect the changes. Your mutation functions don't have to worry
(too much) about the UI. You can definitely get some mutation working (i.e.
changing app state) without thinking about the UI at all.
So, we'll start with understanding how to write our mutations, and we'll verify that our app state changes.
Mutations go in a vector, just like reads. The element grammar looks just like a clojure function call (albeit a function of arity one that optionally takes a map).
(fire-missiles! {:target :foo})
So, a transaction is just a vector of these:
[ (make-friend { :person/by-id 42 })
(send-business-card { :person/by-id 33 }) ]
Now, this makes a lot of sense to those that are well-versed in Clojure(script),
but for those that are starting out I want to point out that a transaction
(the vector above) needs to be data. If we type it into our code as shown, the
compiler will try to evaluate the plain lists (e.g. (make-friend)
) and
will crash with "symbol not found". Those "symbols" are not meant to be
functions in our code, they are meant to be instructions to our transact!
that are in turn interpreted by your parser.
Thus you'll often see them quoted:
'[ (f) (g) (h) ]
Unfortunately, plain quoting is kind of a pain when you want to embed values
from variables, since everything is literal. If we use a different quote, the
syntax quote, then we also have the ability to unquote with ~
:
(let [p 42]
`[ (f { :person ~p }) ]
)
The ~
is an unquote, which says "stop quoting for this next form". Alas, this
still isn't quite right, because syntax quoting tries to "help you" (these are
used in macros, where they do actually help) by putting namespaces on all of the
symbols! Thus, f
in the above example ends up with the current namespace
prepended (e.g. om-tutorial.core/f
). You can prevent this by namespacing your
symbols manually. The symbols are all make-believe, so just make them work for
your understanding:
(let [p 42]
`[ (people/make-friend { :person/by-id ~p } ]
)
Read up on quote, syntax quote, and unquote. If you're working in Om Next, it's time you understood how to quote.
Another strategy is to use the list
function to build the list:
[ (list 'make-friend {:p p} ) ]
This is both better and worse. p
is properly expanded, we can use plain
quoting on the symbol make-friend
, namespacing isn't an issue, and there
are no squiggles...but now we have the word list
in there.
One might suggest
(def invoke-mutation list)
so you can write:
[(invoke-mutation 'make-friend {:p p})]
I'm sure others will write macros. Whatever.
So, the summary of facts we covered so far:
- Mutation grammar is just data. It looks like function calls, but should be
passed to
transact!
as data (fortransact!
to run theparser
on, not the cljs compiler) - The grammar uses lists, which the compiler will be sorely tempted to interpret into function calls.
- Use quoting (or whatever mechanism you want) to make sure
transact!
receives the grammar unmolested.
There are several bits that fit together to make mutation work (and of course those pieces are also ultimately interested in your UI being up-to-date).
The pieces are:
- Reconciler : This is the bit that tries to merge novelty into the state. It
sits between your UI and the mutation functions you write. It is
(mostly indirectly) invoked when you run
transact!
. - Parser : This is the same parser you were using for reads. When it sees
something that conforms to a
mutate
grammar, it tries to run mutate instead of calling read. The reconciler uses the parser to understand the grammar of thetransact!
statements. - Mutation functions: Code you write that understands how to actually change the underlying application (and/or server) state. The parser ends up invoking these when it sees a mutation grammar form.
- Indexer : This one keeps track of things. It knows which components on the screen share the same Ident (and therefore should update when that underlying data changes), what classes got with what on-screen components, etc.
Just like read
, the mutate
operation is just a function you write
that is called with the signature [env key params]
. In the case
of mutate, the key
will be the symbol you "called" and params
will
be the map you passed (as arguments to the call). env
is as before (includes
app state).
Since you invented the symbol name, you invent the operations, but there is one catch: you don't do it during the call itself. This is because the parser needs to be side-effect free (and in fact can run multiple times).
So, you instead return a lambda to do your action when the reconciler decides it is time:
(defn mutate [env key params]
(cond
(= 'make-friend key) { :action (fn [] ...) }
(= 'send-business-card key) { :action (fn [] ...) }
))
Of course, as before, you probably want to use a multimethod that dispatches on key instead of some monolith.
NOTE: We're just talking state changes at the moment. There is more to say about how this works with the UI...there are more requirements, such as the inclusion of a query in the return value.
The Quick Start (om.next) has already covered a mutation example, but for completeness, we'll include another sample here to build it up from pieces:
(def app-state (atom {:count 0}))
(defmulti mutate om/dispatch)
(defmethod mutate 'counter/add [{:keys [state]} key params]
(let [n (or (:n params) 1)]
{:action (fn [] (swap! state update :count (partial + n)))}
))
(def my-parser (om/parser {:mutate mutate}))
(def reconciler (om/reconciler {:state app-state :parser my-parser}))
Now every time you run a transact!
like this:
(om/transact! reconciler '[(counter/add {:n 3})])
the contents of @app-state
will go up by amount n
.
NOTE: You should not, in general, call transact!
directly on the
reconciler. Such a call is legal, but Om Next uses the current UI tree-path
of the component to help it figure out what in the UI should update. If you
call it on the reconciler directly you lose that context. If you are not in
the context of the UI, then the API is NOT stable on how you should push in the
change. The function merge!
may be where "remote control" lands.
If you've done the Normalization tutorial, you know that Om will be happy to
take a tree of data and normalize it by replacing objects with their Ident
while creating tables of those objects.
The CRUD operations you do to your app state will need to deal with this fact.
The methods for doing this are as follows:
- Create: You need to add the created object to the generated tables, and put the ref (e.g. [:people/by-id 4]) into the UI. If a remote is involved, then the story needs to include tempid resolution.
- Read: Covered under queries
- Update: If the object has an Ident, update the value in the generated table. If the object does not, just update the singleton object in place.
- Delete: Remove the item from the UI. If you are sure the UI no longer needs the item, then it is also safe to remove it from the generated tables.
In cases where you're dealing with Om-normalized application state, you may
also use db->tree
to convert the current state back into a UI tree, do your
update there, then use tree->db
to convert it back.
When you start doing remote mutations, then what you most likely will do is ask for a re-read of the particular bits from the server, and that will overwrite the state locally. You'll likely have to help a bit with the state merge, and we'll cover that in the coverage of remotes.
Om Next needs a little help from you during mutation. Sure, it is simple for you to go about writing new random bits of data into your app state, but Om Next can't read your mind (or your code, even though the latter might be possible...it is Clojurescript, after all).
However, what it can read certainly includes something you explicitly tell it when you do a mutation. The primary thing you want to tell it (in addition to the mutation itself) is the local component instance that is asking for the change.
When you do that, you automatically enable it to read:
- The declared "identity" of the data being used to render the component (via
Ident
) - The "query path" of the data being used in the render. I'll explain this shortly.
- The React Class of the component.
- The Query of the component.
- The Query parameters in use on the component instance.
- Other bits and pieces, like component local state.
The indexer tracks all of the UI classes and component instances by various keys (including many of the above), so this plethora of information can be used to quickly locate UI components that are affected by mutations.
Even then, it is possible (even likely) that the component on which you want to
trigger a change (e.g. some button-like thing) may not actually be at a
location in the UI tree that has much to do with the state being changed. If you
run transact!
there, you lose all of the context that might be used for Om
Next to decide what to re-render. If that component has no query (stateless)
then it is forbidden to run transact!
from that context. (you will get an
error).
So, at the time of this writing there are some general rules:
- Run
transact!
from the body of a stateful component (one with a query) that is at the root of any UI elements the mutation might affect. This is a hard rule to state clearly, but consider that some action in a child that deletes, say, an item in a collection should calltransact!
from the component that asked for the collection. - Include a query in the result of your mutate that helps Om Next understand what the mutation is changing.
Let's look at these in detail.
The signature is simple enough:
(om/transact! component-instance-or-reconciler tx)
where tx is a vector containing mutations and queries. For example,
(om/transact! this `[(friends/unfriend! {:who 42})])
but as usual, the devil is in the details.
To go any futher, we are going to actually have to build some UI (or at least UI-attached queries).
So, at this point you understand most of the basic state management code you'll need to write. We'll clarify the mutation story a bit more as we talk about real UI, since the remaining bits of mutation have to do with our biggest UI concern: keeping the UI up-to-date with respect to the application state.
Once you've understood the read function, parser, and query grammar then you should be capable of writing a UI component that gets something on the screen. Hopefully you've already done that in the Quick Start (om.next).
In this section I want to make sure you understand:
- How to compose the UI (and queries)
- The difference between stateful and stateless components
- A bit about how Om Next tracks queries
- Proper use of
transact!
- How to force additional UI updates when using
transact!
- A bit more on Ident
When you create component classes (defui
) you specify queries on them. These queries
can include sub-component queries (since they are statically available). Thus,
your UI will have a "UI Rendering Tree" structure (A renders B renders C) and a separate but
related "UI Query Tree" (A asks for stuff that C wants):
(defui C
static om/IQuery
(query [this] [:attr])
Object
(render [this]
(dom/li nil "Hi" (-> this (om/props) :attr))))
(def c (om/factory C {:keyfn :attr}))
(defui B
Object
(render [this] (c (om/props this))))
(def b (om/factory B))
(defui A
static om/IQuery
(query [this] [:stuff {:c-stuff (om/get-query C)}])
Object
(render [this]
(let [c-items (-> this om/props :c-stuff)]
(dom/ul nil (mapv b c-items)))))
If you render this UI, then any number of B/C components might render (because the top-level query is a join).
Let's say your query result is:
{:stuff 42 :c-stuff [ {:attr 1} {:attr 2} ]}
then you can imagine that the query result tree looks like:
{:stuff 42 :c-stuff [ \ ] }
|\
| \
{ :attr 1 } { :attr 2 }
The path to the top-level query result (as you might use in get-in
) is [], the
path to the vector is [:c-stuff]
, and the path to { :attr 2 }
is [:c-stuff 1]
.
Here's the cool part: when the parser has finished parsing the query (and has the result), it recursively annotates the specific bits of the result with metadata that indicates the path for each bit. Try this:
(def app-state (atom {:c-stuff [{:attr 1} {:attr 2}]}))
(defmulti read om/dispatch)
(defmethod read :stuff [env key params] { :value 42 })
(defmethod read :c-stuff [env key params] { :value (get @(:state env) key) })
(def parser (om/parser {:read read}))
(meta (-> (parser {:state app-state} (om/get-query A)) :c-stuff (get 0)))
;; output is: { :om-path [:c-stuff 0] }
In other words the tree shown above (graphically) on the query result is annotated at each node with the path to that node. Therefore when you pass the data through the UI tree the props can see the (extra) side information in the metadata of the properties. More importantly, the plumbing of Om Next can see this information.
Thus, each component instance (on the screen) knows what part of the query it
uses, and how it relates to the parents' queries. If you examine the this
parameter in render
of component C
you'll see it has a nested property called
omcljs$path
. If you render a list of them, you'll see each has the correct
query path even though it is nested at some arbitrary depth in the UI tree.
Of course, this works because you are explicitly passing the bits of the parse result down through the UI tree, and each bit of the parser result knows (via metadata) it's query/result path from the query/result root.
IMPORTANT: You should not, in general, muck with properties that represent a
query result (since that result is exactly what the component explicitly asked
for). If you need to pass "additional" data to a component, use computed
and
get-computed
to add that extra data.
All of this is critical when it comes to keeping your UI in sync with actual app state.
Now that you see what kinds of thing are being recorded it is time for you to glance at the indexer. This is the thing that keeps track of things related to what's in the UI tree, and what query bits are related to those UI bits. In the sample UI from the UI Structure section above, running a render with the following state:
[:stuff 42 :c-stuff [ {:attr 1} {:attr 2} ]]
results in the following indexer content:
{:class->components ; index of the mounted components, by class
{om-tutorial.core/A #{#object[om-tutorial.core.A]},
om-tutorial.core/B #{#object[om-tutorial.core.B] #object[om-tutorial.core.B]},
om-tutorial.core/C #{#object[om-tutorial.core.C] #object[om-tutorial.core.C]}},
:ref->components {}, ; index of the mounted component, by Ident
:prop->classes ; index of the property names to the classes that query them
{:stuff #{om-tutorial.core/A},
:c-stuff #{om-tutorial.core/A},
:attr #{om-tutorial.core/C}},
:class-path->query ; index of queries based on static class path (zipper)
{[om-tutorial.core/A] #{[[:stuff {:c-stuff [:attr]}] nil]},
[om-tutorial.core/A om-tutorial.core/C]
#{[[:attr]
{:l [:c-stuff],
:pnodes
[[{:c-stuff [:attr]}] {:c-stuff [:attr]} [:c-stuff [:attr]]],
:ppath
{:l [],
:pnodes [[{:c-stuff [:attr]}] {:c-stuff [:attr]}],
:ppath
{:l [], :pnodes [[{:c-stuff [:attr]}]], :ppath nil, :r nil},
:r nil},
:r nil}]}}}
THIS WILL EVOLVE: David has said that the indexer is incomplete, but I think it is important for you to see what's in here right now so you can understand how the mutation story works with the UI.
So, you now have enough information to understand how to compose a basic (although somewhat statically structured) UI:
- A top-level query is required. Om Next runs the query and works in the path information from the UI root. If you drop the query from your root UI node, none will run.
- The top-level query is composed from children (who themselves may compose child queries).
- Interstitial stateless components must pass data through the tree in a way that preserves the result structure (and metadata).
- The data that you return in a query is under your control, but must have structure that matches the query's structure or the whole thing falls apart.
NOTE: The material below is outdate...revisions coming soon...
Now we have enough of the picture to talk about mutation in general. If we step back and think about the problem we see that there are a number of things that we're concerned about when we change application state:
- We don't want to actually re-render the entire DOM on each app state transition.
- We don't even want React to have to do unnecessary VDOM diffs. We want our UI to be as fast as possible.
Hm. Negative assertions....we also don't want our updates to melt our chair with lasers. The general positive assertion about what we want to do is:
We want our UI to do the minimum number of updates that a state change requires.
If you re-read the early sections of this document on mutation, the general solution to this problem is to include queries with any call to a mutation.
Mutations themselves should include a "Fat Query" as their :value
which
indicates any of the things you might want to request after a mutation.
If you consider the kinds of things UIs show:
- Data that comes directly from a uniquely identifiable (entity-like) thing.
- Data that is derived (aggregates, e.g. how many friends do I have right now?)
- Data that is a collection of other data.
You'll also see that Om will do some automatic things for you.
This case is the easiest. If we give our UI component an Ident function:
(defui Person
static om/Ident
(ident [this props] [:people/by-id (:db/id props)])
...)
then the Om Next indexer will remember everywhere that particular person is being rendered in the UI:
Root
/ \
friends family
/ \
Joe [:people/by-id 4] Joe [:people/by-id 4]
So, if you change any of the attributes in Person via transact!
, the
underlying system can look up all the places that "Joe" is on the screen
and force updates.
For example:
(defui Person
static om/Ident
(ident [this props] [:people/by-id (:db/id props)])
static om/Query
(query [this] [:db/id :name :age])
Object
(render [this]
(let [{:keys [db/id name age]} (om/props this)
make-older #(om/transact! this `[(person/bump-age { :id id })])]
(dom/ul nil (dom/button { :onClick make-older } "Age this person")))))
clicking on the "Age this person" button sends off an abstract operation
that should (you write it, remember) increment the age of the person
with that ID in the state. You've given transact!
the component instance (on
screen) which has a specific Ident ([:people/by-id 4]
for example). Om Next
looks in the indexer, finds all of the components that have that specific
data "mounted" on the UI (ref->components
), and makes sure they re-render.
This is probably the most interesting case, and it is quite common. The only general and reliable way to solve this case is to do the following:
- Declare (in your "fat query" of the mutation) what things can change due to the mutation
- Declare (during your UI call to the mutation) the things your UI needs to re-read as a result of the mutation.
(defmethod mutate 'person/add-friend [{:keys [state] :as env}
key
{:keys [person-ref new-friend-ref] :as params}]
(let [[_ new-friend-id] new-friend-ref
[_ my-id] person-ref]
{:action #(add-friend state my-id new-friend-id) ; make it happen in state
; value is like Relay's FAT QUERY. (DOCUMENTATION TO UI WRITER)
:value [person-ref new-friend-ref :friend-count :friends]}
))
Now the caller of mutate (via transact) can include the items that need to be re-read by the invoking UI:
(transact! this '[(add-friend ...) [:people/by-id 33] [:people/by-id 99] :friend-count ])
Thus the UI author can trigger exactly the updates to state and UI that are needed.
Collections have some tricky bits. You might mutate the collection (create new items, delete items), sort it, filter it, and all manner of other fun things. Collections themselves are recursive in nature, since the data in a collection can be another collection.
Additionally, collections can be derived (find all of the people who are female) and can contain derived data. An interesting observation is that any parent stateful nodes that have stateful children are, by definition, collections.
There will likely be some interesting developments in the area of collection
re-rendering, but for the moment these share the same basic story as
the Derived Data case: you indicate when a collection has changed by
including the attribute name under which it was queried as add-on
queries in your call to transact!
Hopefully understanding the design (early sections of this documentation) along
with the details of the APIs now makes it simpler for you to reason about
the where of calls to transact!
.
You should place them at the place in the UI that "most knows" about the overall data needs of the current UI related to that mutation.
If you're "deleting an item from a list", then it makes sense to locate the transact on the component rendering the list (not on the subcomponent that has the "delete" button). Using callbacks makes it trivial to connect the two. If your UI has some additional dependencies on that "delete", then you probably want to lift that transaction up to the level that knows about those other dependencies.
In all cases, your calls to transact!
should indicate what reads are required
to run after a mutation.
You'll note that since your re-reads and re-renders are triggered based on the names of attributes (or via refs) it is important to make sure your keywords are specific.
In general, the convention established by Datomic seems like a nice start: Use a namespace for keywords that best describes an "entity" type. Then associate attributes that belong (mostly) to that type.
Some examples are :user/name, :address/city, etc. Datomic allows you to put any attribute on any entity (for example, :permission/read might make sense on many different kinds of things), and the same can easily apply in your application state.
Providing unique keywords that have semantic meaning will make it easy to trigger precise updates in the app-state and UI from your UI transactions.