- 1. Project setup
- 2. Basic UI Components
- 3. Feeding the Data Tree
- 4. Passing Callbacks and Other Parent-computed Data
- 5. Updating the Data Tree
- 6. Normalizing the Database
- 7. Review So Far
- 8. Going Remote!
- 8.1. Setting up a Server
- 8.2. Setup for Playing with Loads
- 8.3. Loading Data
- 8.4. Handling Mutations on The Server
- 8.5. Using a legacy REST API
- 9. The Complete Final App
Your project will need a few basic directories and files:
# mkdir -p script resources/public/js src/main/app src/dev/cljs
Untangled requires that you include a few things in your dependency list.
-
Om Next (1.0.0-beta1 or above)
-
Clojure (1.9.0-alpha16 or above)
-
Clojurescript (1.9.542 or above)
-
Untangled (this library)
A typical project will also include figwheel, devtools, and at least one development build configuration.
This goes in project.clj
:
(defproject my-project "0.0.1"
:description "My Project"
:dependencies [[org.clojure/clojure "1.9.0-alpha16"]
[org.clojure/clojurescript "1.9.562"]
[org.omcljs/om "1.0.0-beta1"]
[awkay/untangled "1.0.0-SNAPSHOT"]]
:source-paths ["src/main"]
:resource-paths ["resources"]
:clean-targets ^{:protect false} ["resources/public/js" "target" "out"]
:plugins [[lein-cljsbuild "1.1.6"]]
:cljsbuild {:builds
[{:id "dev"
:source-paths ["src/main" "src/dev"]
:figwheel {:on-jsload "cljs.user/refresh"}
:compiler {:main cljs.user
:output-to "resources/public/js/app.js"
:output-dir "resources/public/js/app"
:preloads [devtools.preload]
:asset-path "js/app"
:optimizations :none}}]}
:profiles {:dev {:source-paths ["src/dev" "src/main"]
:dependencies [[binaryage/devtools "0.9.2"]
[figwheel-sidecar "0.5.9"]]}})
Figwheel is a hot-reload development tool. We recommend using figwheel sidecar so you can easily start the project from the command line or use it from the REPL support built into IntelliJ. This requires a couple of CLJ source files:
Put this in script/figwheel.clj
:
(require '[user :refer [start-figwheel]])
(start-figwheel)
and this in src/dev/user.clj
:
(ns user
(:require
[figwheel-sidecar.system :as fig]
[com.stuartsierra.component :as component]))
(def figwheel (atom nil))
(defn start-figwheel
"Start Figwheel on the given builds, or defaults to build-ids in `figwheel-config`."
([]
(let [figwheel-config (fig/fetch-config)
props (System/getProperties)
all-builds (->> figwheel-config :data :all-builds (mapv :id))]
(start-figwheel (keys (select-keys props all-builds)))))
([build-ids]
(let [figwheel-config (fig/fetch-config)
default-build-ids (-> figwheel-config :data :build-ids)
build-ids (if (empty? build-ids) default-build-ids build-ids)
preferred-config (assoc-in figwheel-config [:data :build-ids] build-ids)]
(reset! figwheel (component/system-map
:figwheel-system (fig/figwheel-system preferred-config)
:css-watcher (fig/css-watcher {:watch-paths ["resources/public/css"]})))
(println "STARTING FIGWHEEL ON BUILDS: " build-ids)
(swap! figwheel component/start)
(fig/cljs-repl (:figwheel-system @figwheel)))))
In order to get the thing building, we need two more files with some application code in them.
Place this in src/main/app/basic_ui.cljs
:
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
[om.next :as om :refer [defui]]))
; Create an application
(defonce app-1 (atom (uc/new-untangled-client)))
; Create a simple UI
(defui Root
Object
(render [this]
(dom/div nil "Hello World.")))
and this in src/dev/cljs/user.cljs
(NOTE THIS IS DIFFERENT FROM src/dev/user.clj
!!!)
(ns cljs.user
(:require
[app.basic-ui :refer [app-1 Root]]
[untangled.client.core :as uc]))
; so figwheel can call it on reloads. Remounting just forces a UI refresh.
(defn refresh [] (swap! app-1 uc/mount Root "app-1"))
(refresh) ; for initial mount
A single basic HTML file will be needed, and it must have an element on which to mount your application.
Put this in resources/public/index.html
:
<!DOCTYPE html>
<html>
<body>
<div id="app-1"></div>
<script src="js/app.js" type="text/javascript"></script>
</body>
</html>
You can now run this project in various ways.
From the command line:
# lein run -m clojure.main script/figwheel.clj
Within IntelliJ:
-
Run → Edit Configurations…
-
Press the '+' button, and choose Clojure REPL → Local
-
Give it a name (like
dev
) -
Choose "Use clojure.main in normal JVM process" (important: it defaults to nREPL which won’t work right)
-
In
Parameters
addscript/figwheel.clj
-
Now you should be able to start it from the Run menu.
You should see the application printing "Hello World" at: http://localhost:3449
Now that you have a basic project working, let’s understand how to add some content!
Important
|
When developing it is a good idea to: Use Chrome (the devtools only work there), have the developer’s console open, and in the developer console settings: "Network, Disable cache (while DevTools is open)", and "Console, Enable custom formatters". |
One of the most maddening things that can happen during development is mystery around build errors. Nothing is more frustrating than not understanding what is wrong.
As you work on your code your compiler errors and warnings will show in the browser. DO NOT RELOAD THE PAGE! If you reload the page you’ll lose the warning or error, and that makes it harder to figure out what is wrong!
Instead, edit your code and re-save.
If you are having problems and you’ve lost your way, it is sometimes useful to ask figwheel to clean and recompile everything:
cljs.user=> (reset-autobuild)
will typically get you back on track.
Sometimes stuff just fails for reasons we fail to understand. There are times when
you may want to completely kill your REPL, clean the project with lein clean
, and start again. Make sure all
of the generated Javascript is removed when you clean, or things might not clear up.
It is also true that problems in your project configuration may cause problems that are very difficult to
understand. If this happens to you (especially if you’ve never run a project with the current project setup) then
it is good to look at things like dependency problems with lein deps :tree
and fix those.
In general, if you see a conflict on versions it will work to place the newest version of the conflicted dependency into your own dependency list. This can cause problems as well, but is less likely to fail than using an older version of a library that doesn’t have some needed feature of bug fix.
Untangled uses Om’s defui
to build React components. This macro emits React components that work as 100% raw React
components (i.e. once you compile them to Javascript they could be used from other native React code).
Om also supplies factory functions for generating all standard HTML5 DOM elements in React in the om.dom
namespace.
The basic code to build a simple component has the following form:
(defui ComponentName
Object
; object lifecycle and render methods
(render [this]
(dom/div #js {:className "a"}
(dom/p nil "Hello"))))
for our purposes we won’t be saying much about the React lifecycle methods, though they can be added. The basic
intention of this macro’s syntax is to declare a component, and then extend various interfaces (in the above case,
Object
(extend the basic javascript object to have a render method that takes one parameter: this
).
Technically, you can add whatever other native methods you might want to this object:
(defui ComponentName
Object
(my-method [this]
(js/console.log "Hi!"))
(render [this]
(.my-method this) ; call my-method on this
(dom/div #js {:className "a"}
(dom/p nil "Hello"))))
You can convince yourself that you get a plain javascript object by going to the developer’s console in Chrome:
> new app.basic_ui.Root().my_method();
Hi!
though you do have to understand how the names might get munged (e.g. hyphens become underscores).
The render
method can do whatever work you need, but it should return a react element
(see React Components, Elements, and Instances).
Luckily, there are factory methods for all of HTML5 in om.dom
. These functions generally take a Javascript map
as their first argument (for things like classname and event handlers) and any children. There are two ways to
generate the Javascript map: with the reader tag #js
or with clj→js
. Thus the following two are functionally
equivalent:
(dom/div #js {:className "a"} "Hi")
(dom/div (clj->js {:className "a"}) "Hi")
However, the former happens in the reader (before compile) and generates more efficient runtime code, but the latter is useful when you’ve computed attributes in regular clj data structures and need to convert it at runtime.
React components receive their data through props and state. In Untangled we generally recommend using props. This
ensures that various other features work well. The data passed to a component can be accessed (as a cljs map) by
calling om/props
on this
.
So, let’s define a Person
component to display details about
a person. We’ll assume that we’re going to pass in name and age as properties:
(defui Person
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)]
(dom/div nil
(dom/p nil "Name: " name)
(dom/p nil "Age: " age)))))
Now, in order to use this component we need an element factory. An element factory lets
us use the component within our React UI tree. Name confusion can become an
issue (Person the component vs. person the factory?) we recommend prefixing the factory with ui-
:
(def ui-person (om/factory Person))
(defui Root
Object
(render [this]
(ui-person {:person/name "Joe" :person/age 22})))
If you reload your browser page, you should see the updated UI.
Part of our quick development story is getting hot code reload to update the UI whenever we change the source. At the moment this is broken in your app (you’re having to reload the page to see changes). Actually, hot code reload is working, but the UI refresh isn’t.
There are two steps to make this work.
-
Make sure the definition of the UI components is marked with
:once
metadata: -
Force React to re-render the entire UI (Om optimizes away refresh when the app state hasn’t changed). The trick here is to change the React key on the root element (which forces React to throw away the prior tree and generate a whole new one). Untangled helps by sending your root component a property named
:ui/react-key
that only changes on (re)mount and forced refresh.
So, changing your current application to this:
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
[om.next :as om :refer [defui]]))
(defonce app-1 (atom (uc/new-untangled-client)))
(defui ^:once Person
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)]
(dom/div nil
(dom/p nil "Name: " name)
(dom/p nil "Age: " age)))))
(def ui-person (om/factory Person))
(defui ^:once Root
Object
(render [this]
(let [{:keys [ui/react-key]} (om/props this)]
(dom/div #js {:key react-key}
(ui-person {:person/name "Joe" :person/age 22})))))
and reloading your page (just once more, to clear out the old stuff) should now cause changes you make
to the code to appear in the UI without having to reload the page. Try editing the UI of Person
and save.
You should already be getting the picture that your UI is going to be a tree composed from a root element. The way data is passed (via props) should also be giving you the picture that supplying data to your UI (through root) means you need to supply an equivalently structured tree of data. This is true of basic React, and since we’ve only seen basic React stuff at this point, it is a true statement in general. However, just to drive the point home let’s make a slightly more complex UI and see it in detail:
Replace your basic_ui.cljs
content with this:
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
[om.next :as om :refer [defui]]))
(defonce app-1 (atom (uc/new-untangled-client)))
(defui ^:once Person
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)]
(dom/li nil
(dom/h5 nil name (str "(age: " age ")"))))))
(def ui-person (om/factory Person {:keyfn :person/name}))
(defui ^:once PersonList
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)]
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map ui-person people))))))
(def ui-person-list (om/factory PersonList))
(defui ^:once Root
Object
(render [this]
(let [{:keys [ui/react-key]} (om/props this)
ui-data {:friends {:person-list/label "Friends" :person-list/people
[{:person/name "Sally" :person/age 32}
{:person/name "Joe" :person/age 22}]}
:enemies {:person-list/label "Enemies" :person-list/people
[{:person/name "Fred" :person/age 11}
{:person/name "Bobby" :person/age 55}]}}]
(dom/div #js {:key react-key}
(ui-person-list (:friends ui-data))
(ui-person-list (:enemies ui-data))))))
So that the UI graph looks like this:
+--------+ | Root | ++-----+-+ | | +-----+--+ ++-------+ | List | | List | +---+----+ +----+---+ | | +---+----+ +----+---+ | Person | | Person | |--------| |--------| | Person | | Person | +--------+ +--------+
and the data graph matches the same structure, with map keys acting as the graph "edges":
{ LIST-1-KEY { PEOPLE-KEY [PERSON PERSON]
LIST-2-KEY { PEOPLE-KEY [PERSON PERSON] }
+--------+ | Root | ++-----+-+ enemies| |friends +-----+--+ ++-------+ | List | | List | +---+----+ +----+---+ |people |people +---+----+ +----+---+ | Person | | Person | 0 |--------| |--------| | Person | | Person | 1 +--------+ +--------+
Obviously it isn’t going to be desirable to hand-manage such a hairy beast in this manner for anything but the most trivial application. At best it does give us a persistent data structure that represents the current "view" of the application (which has many benefits), but at worst it requires us to "think globally" about our application. We want local reasoning. We also want to be able to easily re-compose our UI as needed, and a static data graph like this would have to be updated every time we made a change! Almost equally as bad: if two different parts of our UI want to show the same data, then we’d have to find and update a bunch of copies spread all over the data tree.
So, how do we solve this?
This is certainly a possibility; however, it leads to other complications. What is the data model? How do you interact with remotes to fill your data needs? Om Next has a very nice cohesive story for these questions, while systems like Re-frame end up with complications like event handler middleware, coeffect accretion, and signal graphs…not to mention that the sideband solution says nothing definitive about server interactions with said data model.
In Untangled, there is a way to construct the initial tree of data in a way that allows for local reasoning: co-locate the initial desired part of the tree with the component that uses it. This allows you to compose the state tree in exactly the same way as the UI tree.
Untangled defines a protocol InitialAppState
with a single method named initial-state
. The defui macro
will allow us to add that implementation to the generated component class by adding static
in front of
the protocol name we want to implement.
It looks like this:
(defui ^:once Person
static uc/InitialAppState
(initial-state [comp-class {:keys [name age] :as params}] {:person/name name :person/age age})
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)]
(dom/li nil
(dom/h5 nil name (str "(age: " age ")"))))))
(def ui-person (om/factory Person {:keyfn :person/name}))
(defui ^:once PersonList
static uc/InitialAppState
(initial-state [comp-class {:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(uc/get-initial-state Person {:name "Sally" :age 32})
(uc/get-initial-state Person {:name "Joe" :age 22})]
[(uc/get-initial-state Person {:name "Fred" :age 11})
(uc/get-initial-state Person {:name "Bobby" :age 55})])})
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)]
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map ui-person people))))))
(def ui-person-list (om/factory PersonList))
(defui ^:once Root
static
uc/InitialAppState
(initial-state [c params] {:friends (uc/get-initial-state PersonList {:label "Friends"})
:enemies (uc/get-initial-state PersonList {:label "Enemies"})})
Object
(render [this]
(let [{:keys [ui/react-key]} (om/props this)
{:keys [friends enemies]} (uc/get-initial-state Root {})]
(dom/div #js {:key react-key}
(ui-person-list friends)
(ui-person-list enemies)))))
Now this is just for demonstration purposes. Data like this would almost certainly come from a server, but
it serves to illustrate that we can localize the initial data needs of a component to the component, and then
compose that into the parent in an abstract way (by calling get-initial-state
on that child).
There are several benefits of this so far:
-
It generates the exact tree of data needed to feed the UI
-
It restores local reasoning (and easy refactoring). Moving a component just means local reasoning about the component being moved and the component it is being moved from/to.
In fact, at the figwheel REPL you can see the tree by running:
dev:cljs.user=> (untangled.client.core/get-initial-state app.basic-ui/Root {})
{:friends
{:person-list/label "Friends",
:person-list/people
[{:person/name "Sally", :person/age 32}
{:person/name "Joe", :person/age 22}]},
:enemies
{:person-list/label "Enemies",
:person-list/people
[{:person/name "Fred", :person/age 11}
{:person/name "Bobby", :person/age 55}]}}
Note
|
Technically, in cljs you can call untangled.client.core/initial-state directly, but this doesn’t work right when
doing server-side rendering, so it is good to get in the habit of calling static protocol methods with the helper
function.
|
Behind the scenes Untangled has detected this initial state and actually automatically used it to initialize your application state, but at the moment we’re accessing it directly, but you can check out the application’s current state (which is held in an atom) with:
dev:cljs.user=> @(om.next/app-state (get @app.basic-ui/app-1 :reconciler)
Let’s see how we program our UI to access the data in the application state!
Om Next unifies the data access story using a co-located query on each component. This sets up data access for both the client and server, and also continues our story of local reasoning and composition.
Queries go on a component in the same way as initial state: as static
implementations of a protocol.
The query notation is relatively light, and we’ll just concentrate on two bits of query syntax: props and joins.
Queries form a tree just like the UI and data. Obtaining a value at the current node in the tree traversal is done using the keyword for that value. Walking down the graph (a join) is represented as a map with a single entry whose key is the keyword for that nested bit of state.
So, a data tree like this:
{:friends
{:person-list/label "Friends",
:person-list/people
[{:person/name "Sally", :person/age 32}
{:person/name "Joe", :person/age 22}]},
:enemies
{:person-list/label "Enemies",
:person-list/people
[{:person/name "Fred", :person/age 11}
{:person/name "Bobby", :person/age 55}]}}
would have a query that looks like this:
[{:friends
[ :person-list/label
{:person-list/people
[:person/name :person/age]}]}]
This query reads "At the root you’ll find :friends
, which joins to a nested entity that has a label and people,
which in turn has nested properties name and age.
-
A vector always means "get this stuff at the current node"
-
:friends
is a key in a map, so at the root of the application state the query engine would expect to find that key, and would expect the value to be nested state (because maps mean joins on the tree) -
The value in the
:friends
join must be a vector, because we have to indicate what we want out of the nested data.
Joins are automatically to-one
if the data found in the state is a map, and to-many
if the data found is a
vector.
The namespacing of keywords in your data (and therefore your query) is highly encouraged, as it makes it clear to the reader what kind of entity you’re working against (it also ensures that over-rendering doesn’t happen on refreshes later).
You can try this query stuff out in your REPL. Let’s say you just want the friends list label. The Om function
db→tree
can take an application database (which we can generate from initial state) and run a query
against it:
dev:cljs.user=> (om.next/db->tree [{:friends [:person-list/label]}] (untangled.client.core/get-initial-state app.basic-ui/Root {}) {})
{:friends {:person-list/label "Friends"}}
So, we want our queries to have the same nice local-reasoning as our initial data tree. The get-query
function
works just like the get-initial-state
function, and can pull the query from a component. In this case, you
should not ever call query
directly. The get-query
function augments the subqueries with metadata that is
important at a later stage.
So, the Person
component queries for just the properties it needs:
(defui ^:once Person
static om/IQuery
(query [this] [:person/name :person/age])
static uc/InitialAppState
(initial-state [comp-class {:keys [name age] :as params}] {:person/name name :person/age age})
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)]
(dom/li nil
(dom/h5 nil name (str "(age: " age ")"))))))
Notice that the entire rest of the component did not change.
Next up the chain, we compose the Person
query into PersonList
:
(defui ^:once PersonList
static om/IQuery
(query [this] [:person-list/label {:person-list/people (om/get-query Person)}])
static uc/InitialAppState
(initial-state [comp-class {:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(uc/get-initial-state Person {:name "Sally" :age 32})
(uc/get-initial-state Person {:name "Joe" :age 22})]
[(uc/get-initial-state Person {:name "Fred" :age 11})
(uc/get-initial-state Person {:name "Bobby" :age 55})])})
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)]
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map ui-person people))))))
again, nothing else changes.
Finally, we compose to Root
:
(defui ^:once Root
static om/IQuery
(query [this] [:ui/react-key ; IMPORTANT: have to ask for react-key from the database
{:friends (om/get-query PersonList)}
{:enemies (om/get-query PersonList)}])
static
uc/InitialAppState
(initial-state [c params] {:friends (uc/get-initial-state PersonList {:label "Friends"})
:enemies (uc/get-initial-state PersonList {:label "Enemies"})})
Object
(render [this]
; NOTE: the data now comes in through props!!!
(let [{:keys [ui/react-key friends enemies]} (om/props this)]
(dom/div #js {:key react-key}
(ui-person-list friends)
(ui-person-list enemies)))))
and now the magic happens! Notice that the render method of root will now receive the entire query result
in props (our prior example was generating the data from initial-state
itself), and
it will pick the bits out it knows about (:friends and :enemies) and pass those to the children associated
with rendering them.
Notice that everything you think about when looking at any one of those components is the data it needs to render itself, or (in the abstract) its direct children. Re-arranging the UI is similarly done in a way the preserves this reasoning.
Also, you now have application state that can evolve (the query is running against the active application database stored in an atom)!
Important
|
You should always think of the query as "running from root". You’ll
notice that Root still expects to receive the entire data tree for the UI (even though it doesn’t have to
know much about what is in it, other than the names of direct children), and it still picks out those sub-trees
of data and passes them on. In this way an arbitrary component in the UI tree is not querying
for it’s data directly in a side-band sort of way, but is instead being composed in from parent to parent all the
way to the root. Later, we’ll learn how Om can optimize this and pull the data from the database for
a specific component, but the reasoning will remain the same.
|
The queries on component describe what data the component wants from the database; however, you’re not allowed to put code in the database, and sometimes a parent might compute something it needs to pass to a child.
Om can optimize away the refresh of components if their data has not changed, meaning that it can use a component’s query to directly re-supply its render method for refresh. Doing so skips the rendering call from the parent, and would lead to losing these "extra" bits of data passed from the parent.
Let’s say we want to render a delete button on our individual people in our UI. This button will mean "remove the person from this list"…but the person itself has no idea which list it is in. Thus, the parent will need to pass in a function that the child can call to affect the delete properly:
(defui ^:once Person
static om/IQuery
(query [this] [:person/name :person/age])
static uc/InitialAppState
(initial-state [comp-class {:keys [name age] :as params}] {:person/name name :person/age age})
Object
(render [this]
(let [{:keys [person/name person/age onDelete]} (om/props this)] ; (3)
(dom/li nil
(dom/h5 nil name (str "(age: " age ")") (dom/button #js {:onClick #(onDelete name)} "X")))))) ; (4)
(def ui-person (om/factory Person {:keyfn :person/name}))
(defui ^:once PersonList
static om/IQuery
(query [this] [:person-list/label {:person-list/people (om/get-query Person)}])
static uc/InitialAppState
(initial-state [comp-class {:keys [label]}]
{:person-list/label label
:person-list/people (if (= label "Friends")
[(uc/get-initial-state Person {:name "Sally" :age 32})
(uc/get-initial-state Person {:name "Joe" :age 22})]
[(uc/get-initial-state Person {:name "Fred" :age 11})
(uc/get-initial-state Person {:name "Bobby" :age 55})])})
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)
delete-person (fn [name] (js/console.log label "asked to delete" name))] ; (1)
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map (fn [person] (ui-person (assoc person :onDelete delete-person))) people)))))) ; (2)
-
A function acting in as a stand-in for our real delete
-
Adding the callback into the props (WRONG)
-
Pulling the onDelete from the passed props (WRONG)
-
Invoking the callback when delete is pressed.
This method of passing a callback will work, but not consistently. The problem is that Om can optimize away a re-render of a parent when it can figure out how to pull just the data of the child on a refresh, and in that case the callback will get lost because only the database data will get supplied to the child! Your delete button will work on the initial render (from root), but may stop working at a later time after a UI refresh.
There is a special helper function that can record the computed data like callbacks onto the child that receives them such that an optimized refresh will still know them.
The change is so small it is easy to miss:
(defui ^:once Person
...
Object
(render [this]
(let [{:keys [person/name person/age]} (om/props this)
onDelete (om/get-computed this :onDelete)] ; (2)
(dom/li nil
(dom/h5 nil name (str "(age: " age ")") (dom/button #js {:onClick #(onDelete name)} "X"))))))
(def ui-person (om/factory Person {:keyfn :person/name}))
(defui ^:once PersonList
...
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)
delete-person (fn [name] (js/console.log label "asked to delete" name))]
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map (fn [person] (ui-person (om/computed person {:onDelete delete-person}))) people)))))) ; (1)
-
The
om/computed
function is used to add the computed data to the props being passed. -
The child pulls the computed data via
om/get-computed
.
Now you can be sure that your callbacks (or other parent-computed data) won’t be lost to render optimizations.
Now the real fun begins: Making things dynamic.
In general you don’t have to think about how the UI updates, because most changes are run within the context that needs refreshed. But for general knowledge UI Refresh is triggered in two ways:
-
Running a data modification transaction on a component (which will re-render the subtree of that component), and refresh only the DOM for those bits that had actual changes.
-
Telling Om that some specific data changed (e.g. :person/name).
The former is most common, but the latter is often needed when a change executed in one part of the application modifies data that some UI component elsewhere in the tree needs to respond to.
So, if we run the code that affects changes from the component that will need to refresh (a very common case) we’re covered. If a child needs to make a change that will affect a parent (as in our earlier example), then the modification should run from the parent via a callback so that refresh will not require further interaction.
Every change to the application database must go through a transaction processing system. This has two goals:
-
Abstract the operation (like a function)
-
Treat the operation like data (which allows us to generalize to the remote interactions)
The operations are written as quoted data structures. Specifically as a vector of mutation invocations. The entire transaction is just data. It is not something run in the UI, but instead passed into the underlying system for processing.
You essentially just "make up" names for the operations you’d like to do to your database, just like function names.
(om/transact! this `[(ops/delete-person {:list-name "Friends" :person "Fred"})])
is asking the underlying system to run the mutation ops/delete-person
(where ops can be an alias established
in the ns
). Of course, you’ll typically use unquote to embed data from local variables:
(om/transact! this `[(ops/delete-person {:list-name ~name :person ~person})])
When a transaction runs in Untangled, it passes things off to a multimethod. This multi-method is described in more
detail in the Om documentation and the Untangled Developer’s Guide, but Untangled provides a macro that makes
building (and using) them easier: defmutation
.
Let’s create a new namespace called app.operations
in src/app/operations.cljs
A mutation looks a bit like a method. It can have a docstring, and the argument list will always receive a single argument (params) that will be a map (which then allows destructuring).
The body of the mutation looks like the layout of a protocol implementation, with one or more methods. The one
we’re interested in at the moment is action
, which is what to do locally. The action
method will be
passed the application database’s app-state atom, and it should change the data in that atom to reflect
the new "state of the world" indicated by the mutation.
For example, delete-person
must find the list of people on the list in question, and filter out the one
that we’re deleting:
(ns app.operations
(:require [untangled.client.mutations :as m :refer [defmutation]]))
(defmutation delete-person
"Mutation: Delete the person with name from the list with list-name"
[{:keys [list-name name]}] ; (1)
(action [{:keys [state]}] ; (2)
(let [path (if (= "Friends" list-name)
[:friends :person-list/people]
[:enemies :person-list/people])
old-list (get-in @state path)
new-list (vec (filter #(not= (:person/name %) name) old-list))]
(swap! state assoc-in path new-list))))
-
The argument list for the mutation itself
-
The thing to do, which receives the app-state atom as an argument.
Then all that remains is to change basic-ui
in the following ways:
-
Add a require and alias for app.operations to the ns
-
Change the callback to run the transaction
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
; ADD THIS:
[app.operations :as ops] ; (1)
[om.next :as om :refer [defui]]))
...
(defui ^:once PersonList
...
Object
(render [this]
(let [{:keys [person-list/label person-list/people]} (om/props this)
delete-person (fn [name]
(js/console.log label "asked to delete" name)
; AND THIS
(om/transact! this `[(ops/delete-person {:list-name ~label :name ~name})]))] ; (2)
-
The require ensures that the mutations are loaded, and also gives us an alias to the namespace of the mutation’s symbol.
-
Running the transaction in the callback.
Note that our mutation’s symbol is actually app.operations/delete-person
, but the syntax quoting will fix it.
Also realize that the mutation is not running in the UI, it is instead being handled "behind the scenes". This
allows a snapshot of the state history to be kept, and also a more seamless integration to full-stack operation
over a network to a server (in fact, the UI code here is already full-stack capable without any changes!).
This is where the power starts to show: all of the minutiae above is leading us to some grand unifications when it comes to writing full-stack applications.
But first, we should address a problem that many of you may have already noticed: The mutation code is tied to the shape of the UI tree!!!
This breaks our lovely model in several ways:
-
We can’t refactor our UI without also rewriting the mutations (since the data tree would change shape)
-
We can’t locally reason about any data. Our mutations have to understand things globally!
-
Our mutations could get rather large and ugly as our UI gets big
-
If a fact appears in more than one place in the UI and data tree, then we’ll have to update all of them in order for things to be correct. Data duplication is never your friend.
Fortunately, we have a very good solution to this problem, and it is one that has been around for decades: database normalization!
Here’s what we’re going to do:
Each UI component represents some conceptual entity with data (assuming it has state and a query). In a fully normalized database, each such concept would have its own table, and related things would refer to it through some kind of foreign key. In SQL land this looks like:
+-------------------------------------+ | | PersonList | Person | +---------------------------+ | +----------------------------+ | | ID | Label | | |ID | Name | List ID | | |---------------------------| | |----------------------------| | | 1 | Friends |<---+ |1 | Joe | 1 |--+ +---------------------------+ |----------------------------| | |2 | Sally | 1 |--+ +----------------------------+
In a graph database (like Datomic) a reference can have a to-many arity, so the direction can be more natural:
PersonList Person +---------------------------+ +------------------+ | ID | Label | People | |ID | Name | |---------------------------| |------------------| | 1 | Friends | #{1,2} |----+---->|1 | Joe | +---------------------------+ | |------------------| +---->|2 | Sally | +------------------+
Since we’re storing things in a map, we can represent "tables" as an entry in the map where the key is the table name, and the value is a map from ID to entity value. So, the last diagram could be represented as:
{ :PersonList { 1 { :label "Friends"
:people #{1, 2} }}
:Person { 1 {:id 1 :name "Joe" }
2 {:id 2 :name "Sally"}}}
This is close, but not quite good enough. The set in :person-list/people
is a problem. There is no schema, so there is no
way to know what kind of thing "1" and "2" are!
The solution is rather easy: make a foreign reference include the name of the table to look in (to-many relations are stored in a vector as well, which results in the doubly-nested vector):
{ :PersonList { 1 { :label "Friends"
:people [ [:Person 1] [:Person 2] ] }}
:Person { 1 {:id 1 :name "Joe" }
2 {:id 2 :name "Sally"}}}
A foreign key as a vector pair of [TABLE ID]
is known as an Ident
.
So, now that we have the concept and implementation, let’s talk about conventions:
-
Properties are usually namespaced (as shown in earlier examples)
-
Table names are usually namespaced with the entity type, and given a name that indicates how it is indexed. For example:
:person/by-id
,:person-list/by-name
, etc.
Fortunately, you don’t have to hand-normalize your data. The components have almost everything they need to
do it for you, other than the actual value of the Ident
. So, we’ll add one more (static) method to your components
(and we’ll add IDs to the data at this point, for easier implementation):
...
(defui ^:once Person
static om/Ident ; (1)
(ident [this props] [:person/by-id (:db/id props)])
static om/IQuery
(query [this] [:db/id :person/name :person/age]) ; (2)
static uc/InitialAppState
(initial-state [comp-class {:keys [id name age] :as params}] {:db/id id :person/name name :person/age age}) ; (3)
Object
(render [this]
(let [{:keys [db/id person/name person/age]} (om/props this)
onDelete (om/get-computed this :onDelete)]
(dom/li nil
(dom/h5 nil name (str "(age: " age ")") (dom/button #js {:onClick #(onDelete id)} "X")))))) ; (4)
(def ui-person (om/factory Person {:keyfn :person/name}))
(defui ^:once PersonList
static om/Ident
(ident [this props] [:person-list/by-id (:db/id props)]) ; (5)
static om/IQuery
(query [this] [:db/id :person-list/label {:person-list/people (om/get-query Person)}]) ; (5)
static uc/InitialAppState
(initial-state [comp-class {:keys [id label]}]
{:db/id id ; (5)
:person-list/label label
:person-list/people (if (= id :friends)
[(uc/get-initial-state Person {:id 1 :name "Sally" :age 32}) ; (3)
(uc/get-initial-state Person {:id 2 :name "Joe" :age 22})]
[(uc/get-initial-state Person {:id 3 :name "Fred" :age 11})
(uc/get-initial-state Person {:id 4 :name "Bobby" :age 55})])})
Object
(render [this]
(let [{:keys [db/id person-list/label person-list/people]} (om/props this)
delete-person (fn [person-id]
(js/console.log label "asked to delete" name)
(om/transact! this `[(ops/delete-person {:list-id ~id :person-id ~person-id})]))] (4)
(dom/div nil
(dom/h4 nil label)
(dom/ul nil
(map (fn [person] (ui-person (om/computed person {:onDelete delete-person}))) people))))))
(def ui-person-list (om/factory PersonList))
(defui ^:once Root
static om/IQuery
(query [this] [:ui/react-key
{:friends (om/get-query PersonList)}
{:enemies (om/get-query PersonList)}])
static
uc/InitialAppState
(initial-state [c params] {:friends (uc/get-initial-state PersonList {:id :friends :label "Friends"}) ; (5)
:enemies (uc/get-initial-state PersonList {:id :enemies :label "Enemies"})})
Object
(render [this]
; NOTE: the data now comes in through props!!!
(let [{:keys [ui/react-key friends enemies]} (om/props this)]
(dom/div #js {:key react-key}
(ui-person-list friends)
(ui-person-list enemies)))))
-
Adding an ident function allows Untangled to know how to build a FK reference to a person (given its props)
-
We will be using IDs now, so we need to add
:db/id
to the query. This is just a convention for the ID attribute -
The state of the entity will also need the ID
-
The callback can now delete people by their ID, which is more reliable.
-
The list will have an ID, and an Ident as well
If you reload the web page (needed to reinitialize the database state), then you can look at the newly normalized database at the REPL:
dev:cljs.user=> @(om.next/app-state (-> app.basic-ui/app-1 deref :reconciler))
{:friends [:person-list/by-id :friends], ; The TOP-LEVEL data keys, pointing to table entries now
:enemies [:person-list/by-id :enemies],
:ui/locale "en-US",
:person/by-id ; The PERSON table
{1 {:db/id 1, :person/name "Sally", :person/age 32},
2 {:db/id 2, :person/name "Joe", :person/age 22},
3 {:db/id 3, :person/name "Fred", :person/age 11},
4 {:db/id 4, :person/name "Bobby", :person/age 55}},
:person-list/by-id ; The PERSON LIST Table
{:friends
{:db/id :friends,
:person-list/label "Friends",
:person-list/people [[:person/by-id 1] [:person/by-id 2]]}, ; FKs to the PERSON table
:enemies
{:db/id :enemies,
:person-list/label "Enemies",
:person-list/people [[:person/by-id 3] [:person/by-id 4]]}}}
Note that db→tree
understands (prefers) this normalized form, and can still convert it (via a query)
to the proper data tree (note the repetition of the app state is necessary now). At the REPL, try this:
dev:cljs.user=> (def current-db @(om.next/app-state (-> app.basic-ui/app-1 deref :reconciler)))
#'cljs.user/current-db
dev:cljs.user=> (om.next/db->tree (om.next/get-query app.basic-ui/Root) current-db current-db)
{:friends
{:db/id :friends,
:person-list/label "Friends",
:person-list/people
[{:db/id 1, :person/name "Sally", :person/age 32}
{:db/id 2, :person/name "Joe", :person/age 22}]},
:enemies
{:db/id :enemies,
:person-list/label "Enemies",
:person-list/people
[{:db/id 3, :person/name "Fred", :person/age 11}
{:db/id 4, :person/name "Bobby", :person/age 55}]}}
We have now made it possible to fix the problems with our mutation. Now, instead of removing a person from a tree, we can remove a FK from a TABLE entry!
This is not only much easier to code, but it is complete independent of the shape of the UI tree:
(ns app.operations
(:require [untangled.client.mutations :as m :refer [defmutation]]))
(defmutation delete-person
"Mutation: Delete the person with name from the list with list-name"
[{:keys [list-id person-id]}]
(action [{:keys [state]}]
(let [ident-to-remove [:person/by-id person-id] ; (1)
strip-fk (fn [old-fks]
(vec (filter #(not= ident-to-remove %) old-fks)))] ; (2)
(swap! state update-in [:person-list/by-id list-id :person-list/people] strip-fk)))) ; (3)
-
References are always idents, meaning we know the value to remove from the FK list
-
By defining a function that can filter the ident from (1), we can use update-in on the person list table’s people.
-
This is a very typical operation in a mutation: swap on the application state, and update a particular thing in a table (in this case the people to-many ref in a specific person list).
If we were to now wrap the person list in any amount of addition UI (e.g. a nav bar, sub-pane, modal dialog, etc) this mutation will still work perfectly, since the list itself will only have one place it ever lives in the database.
It is good to know how an arbitrary tree of data (the one in InitialAppState) can be converted to the normalized form. Understanding how this is accomplished can help you avoid some mistakes later.
When you compose your query (via om/get-query
), the get-query
function adds metadata to the query fragment that
names which component that query fragment came from.
For example, try this at the REPL:
dev:cljs.user=> (meta (om.next/get-query app.basic-ui/PersonList))
{:component app.basic-ui/PersonList}
The get-query
function adds the component itself to the metadata for that query fragment. We already know that
we can call the static methods on a component (in this case we’re interested in ident
).
So, Om includes a function called tree→db
that can simultaneously walk a data tree (in this case initial-state) and a
component-annotated query. When it reaches a data node whose query metadata names a component with an Ident
, it
places that data into the approprite table (by calling your ident
function on it to obtain the table/id), and
replaces the data in the tree with its FK ident.
Once you realize that the query and the ident work together to do normalization, you can more easily
figure out what mistakes you might make that could cause auto-normalization to fail (e.g. stealing a query from
one component and placing it on another, writing the query of a sub-component by-hand instead of pulling it
with get-query
, etc.).
-
An Initial app state sets up a tree of data for startup to match the UI tree
-
Component query and ident are used to normalize this initial data into the database
-
The query is used to pull data from the normalized db into the props of the active Root UI
-
Transactions invoke abstract mutations
-
Mutations modify the (normalized) db
-
The transaction’s subtree of components re-renders
-
Believe it or not, there’s not much to add/change on the client to get it talking to a server, and there is also a relatively painless way to get a server up and running.
There are two server namespaces in Untangled: untangled.server
and
untangled.easy-server
. The former has composable bits for making a server that
has a lot of your own extensions, while the latter is a pre-baked server that covers
many of the common bases and is less work to get started with. You can always get started with the easy one, and upgrade
to a more enhanced one later.
To add a server to our project just requires a few small additions:
-
The server itself
-
Some tweaks to allow us to rapidly restart the server with code refresh for quick development.
In dev/user.clj
, we’ll add the following for development use:
(ns user
(:require
[figwheel-sidecar.system :as fig]
app.server
[clojure.tools.namespace.repl :as tools-ns :refer [set-refresh-dirs]]
[com.stuartsierra.component :as component]))
; start-figwheel as before...
; Set what clojure code paths are refreshed.
; The resources directory is on the classpath, and the cljs compiler copies code there, so we have to be careful
; that these extras don't get re-scanned when refreshing the server.
(set-refresh-dirs "src/dev" "src/main")
(def system (atom nil))
(declare reset)
(defn refresh
"Refresh the live code. Use this if the server is stopped. Otherwise, use `reset`."
[& args]
(if @system
(println "The server is running. Use `reset` instead.")
(apply tools-ns/refresh args)))
(defn stop
"Stop the currently running server."
[]
(when @system
(swap! system component/stop))
(reset! system nil))
(defn go
"Start the server. Optionally supply a path to your desired config. Relative paths will scan classpath. Absolute
paths will come from the filesystem. The default is config/dev.edn."
([] (go :dev))
([path]
(if @system
(println "The server is already running. Use reset to stop, refresh, and start.")
(letfn [(start []
(swap! system component/start))
(init [path]
(when-let [new-system (app.server/make-system "config/dev.edn")]
(reset! system new-system)))]
(init path)
(start)))))
(defn reset
"Stop the server, refresh the code, and restart the server."
[]
(stop)
(refresh :after 'user/go))
These functions will be used at the clj REPL for managing your running server.
The server itself requires very little code. In src/main/app/server.clj
:
(ns app.server
(:require [untangled.easy-server :as easy]
[untangled.server :as server ]
[taoensso.timbre :as timbre]))
(defn make-system [config-path]
(easy/make-untangled-server
:config-path config-path
:parser (server/untangled-parser)))
The make-untangled-server
function needs to know where to find the server config file, and what to use to process
the incoming client requests (the parser). Untangled comes with a parser that you can use to get going. You may also
supply your own Om parser here.
Finally, you need two configuration files. Place these in resources/config
:
defaults.edn
:
{:port 4050}
dev.edn
:
{}
The first file is always looked for by the server, and should contain all of the default settings you think you want independent of where the server is started.
The server (for safety reasons in production) will not start if there isn’t a user-specified file containing potential overrides.
Basically, it will deep-merge the two and have the latter override things in the former. This makes mistakes in
production harder to make. If you read the source of the go
function in the user.clj
file you’ll see that
we supply this development config file as an argument. In production systems you’ll typically want this file to be
on the filesystem when an admin can tweak it.
If you now start a local Clojure REPL (with no special options), you should be in the user
namespace to start.
user=> (go)
should start the server. The console should tell you the URL, and if you browse there you should see your index.html
file.
When you add/change code on the server you will want to see those changes in the live server without having to restart your REPL.
user=> (reset)
will do this.
If there are compiler errors, then the user
namespace might not reload properly. In that case, you should be able
to recover using:
user=> (tools-ns/refresh)
user=> (go)
Warning
|
Don’t call refresh while the server is running. It will refresh the code, but it will lose the reference to the running server, meaning you won’t be able to stop it and free up the network port. If you do this, you’ll have to restart your REPL. |
Figwheel comes with a server that we’ve been using to serve our client. When you want to build a full-stack app you must serve your client from your own server. Thus, if you load your page with the figwheel server (which is still available on an alternate port) you’ll see your app, but the server interactions won’t succeed.
One might ask: "If I don’t use figwheel’s server, do I lose hot code reload on the client?"
The answer is no. When figwheel compiles your application it embeds it’s own websocket code in your application for hot code reload. When you load that compiled code (in any way) it will try to connect to the figwheel websocket.
So your network topology was:
+----------+ | Browser | +-------------------+ | app +-----+ | | | | | | port 3449 | +----------+ | http load | +-------------+ | +----------->| | Figwheel | | | | | | | +----------->| | | | ws hot code | +-------------+ | +-------------------+
where both the HTML/CSS/JS resources and the hot code were coming from different connections to the same server.
The networking picture during full-stack development just splits these like this:
localhost +-------------------+ | | | port 4050 | app requests | +-------------+ | +----------+ +-------->| |Your Server | | | Browser | | | +-------------+ | | app +-----+ | | | | | | port 3449 | +----------+ | | +-------------+ | +-------->| | Figwheel | | ws hot code | +-------------+ | | | +-------------------+
Untangled’s client will automatically route requests to the /api
URI of the source URL that was used to load the page,
and Untangled’s server is built to watch for communications at this endpoint.
It is very handy to be able to look at your applications state to see what might be wrong. We’ve been manually
dumping application state at the REPL using a rather long expression. Let’s simplify that. In user.cljs
(make sure it is the
CLJS file!) add:
(defn dump
[& keys]
(let [state-map @(om.next/app-state (-> app-1 deref :reconciler))
data-of-interest (if (seq keys)
(get-in state-map keys)
state-map)]
data-of-interest))
now you should be able to examine the entire app state or a particular key-path with:
dev:cljs.user=> (dump)
dev:cljs.user=> (dump :person/by-id 1)
Now we will start to see more of the payoff of our UI co-located queries and auto-normalization. Our application so far is quite unrealistic: the people we’re showing should be coming from a server-side database, they should not be embedded in the code of the client. Let’s remedy that.
Untangled provides a few mechanisms for loading data, but every possible load scenario can be done using
the untangled.client.data-fetch/load
function.
It is very important to remember that our application database is completely normalized, so anything we’d want to put in that application state will at most be 3 levels deep (the table name, the ID of the thing in the table, and the field within that thing).
Thus, there really are not very many scenarios!
-
Load something into the root of the application state
-
Load something into a particular field of an existing thing
-
Load some pile of data, and shape it into the database (e.g. load all of the people, and then separate them into a list of friends and enemies).
Let’s try out these different scenarios with our application.
First, let’s correct our application’s initial state so that no people are there:
(defui ^:once PersonList
...
static uc/InitialAppState
(initial-state [comp-class {:keys [id label]}]
{:db/id id
:person-list/label label
:person-list/people []}) ; REMOVE the initial people
...
If you now reload your page you should see two empty lists.
When you load something you will use a query from something on your UI (it is rare to load something you don’t want to show). Since those components (should) have a query and ident, the result of a load can be sent from the server as a tree, and the client can auto-normalize that tree just like it did for our initial state!
This case is less common, but it is a simple starting point. It is typically used to obtain something that you’d want
to access globally (e.g. the user info about the current session). Let’s assume that our Person component represents
the same kind of data as the "logged in" user. Let’s write a load that can ask the server for the "current user" and
store that in the root of our database under the key :current-user
.
Loads, of course, can be triggered at any time (startup, event, timeout). Loading is just a function call.
For this example, let’s trigger the load just after the application has started.
To do this, we can add an option to our client. In app.basic-ui
change app-1
:
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
[app.operations :as ops]
[om.next :as om :refer [defui]]
[untangled.client.data-fetch :as df] ; (1)
[untangled.client.mutations :as m]))
...
(defonce app-1 (atom (uc/new-untangled-client
:started-callback (fn [app]
(df/load app :current-user Person))))) ; (2)
-
Require the
data-fetch
namespace -
Issue the load in the application’s
started-callback
Of course hot code reload does not restart the app (if just hot patches the code), so to see this load trigger we must reload the browser page.
If you do that at the moment, you should see an error in the developer console related to the load.
Important
|
Make sure your application is running from your server (port 4050) and not the figwheel one! |
Technically, load
is just writing a query for you (in this case [{:current-user (om/get-query Person)}]
) and sending it to the
server. The server will receive exactly that query as a CLJ data structure.
In vanilla Om Next you would now be tasked with converting the raw CLJ query into a response. You can read more about that in the developer’s guide; however, remember that we’re using Untangled’s built-in request parser. This makes our job much easier.
Create a new namespace in src/main/operations.clj
(NOT the cljs file…that was for the client operations):
(ns app.operations
(:require
[untangled.server :as server :refer [defquery-root defquery-entity defmutation]]
[taoensso.timbre :as timbre]))
(def people-db (atom {1 {:db/id 1 :person/name "Bert" :person/age 55 :person/relation :friend}
2 {:db/id 2 :person/name "Sally" :person/age 22 :person/relation :friend}
3 {:db/id 3 :person/name "Allie" :person/age 76 :person/relation :enemy}
4 {:db/id 4 :person/name "Zoe" :person/age 32 :person/relation :friend}
99 {:db/id 99 :person/name "Me" :person/role "admin"}}))
Since we’re on the server and we’re going to be supplying and manipulating people, we’ll just make a single atom-based in-memory database. This could easily be stored in a database of any kind.
To handle the incoming "current user" request, we can use a macro to write the handler:
(defquery-root :current-user
"Queries for the current user and returns it to the client"
(value [env params]
(get @people-db 99)))
This actually augments a multimethod, which means we need to make sure this namespace is loaded by our server.
So, be sure to edit user.clj
and add this to the requires:
(ns user
(:require
[figwheel-sidecar.system :as fig]
app.server
app.operations ; Add this so your operations get loaded into the multimethod request handler
[clojure.tools.namespace.repl :as tools-ns :refer [set-refresh-dirs]]
[com.stuartsierra.component :as component]))
...
You should now refresh the server at the SERVER REPL:
user=> (reset)
If you’ve done everything correctly, then reloading your application should successfully load your current user. You can verify this by examining the network data, but it will be even more convincing if you look at your client database:
dev:cljs.user=> (dump)
{:current-user [:person/by-id 99],
:person/by-id {99 {:db/id 99, :person/name "Me", :person/role "admin"}},
...
}
Notice that the top-level key is a normalized FK reference to the person, which has been placed into the correct database table.
Of course, the question is now "how do I use that in some arbitrary component?" We won’t completely
explore that right now, but the answer is easy: The query syntax has a notation for "query something at the root". It looks like this:
[ {[:current-user '_] (om/get-query Person)} ]
. You should recognize this as a query join, but on something that
looks like an ident without an ID (implying there is only one, at root).
We’ll just use it on the Root UI node, where we don’t need to "jump to the top":
(defui ^:once Root
static om/IQuery
(query [this] [:ui/react-key
:ui/person-id
{:current-user (om/get-query Person)} ; (1)
{:friends (om/get-query PersonList)}
{:enemies (om/get-query PersonList)}])
static
uc/InitialAppState
(initial-state [c params] {:friends (uc/get-initial-state PersonList {:id :friends :label "Friends"})
:enemies (uc/get-initial-state PersonList {:id :enemies :label "Enemies"})})
Object
(render [this]
; NOTE: the data now comes in through props!!!
(let [{:keys [ui/react-key current-user friends enemies]} (om/props this)] ; (2)
(dom/div #js {:key react-key}
(dom/h4 nil (str "Current User: " (:person/name current-user))) ; (3)
(ui-person-list friends)
(ui-person-list enemies)))))
-
Add the current user to the query
-
Pull of from the props
-
Show something about it in the UI
Now reload the page to re-execute the load and it should fill in correctly.
The next common scenario is loading something into some other existing entity in your database. Remember that since the database is normalized this will cover all of the other loading cases (except for the one where you want to convert what the server tells you into a different shape (e.g. paginate, sort, etc.)).
Untangled’s load method accomplishes this by loading the data into the root of the database, normalizing it, then (optionally) allowing you to re-target the top-level FK to a different location in the database.
The load looks very much like what we just did, but with one addition:
source
(df/load app :my-friends Person {:target [:person-list/by-id :friends :person-list/people]})
The :target
option indicates that once the data is loaded and normalized (which will leave the FK reference
at the root as we saw in the last section) this top-level reference will be moved into the key-path provided. Since
our database is normalized, this means a 3-tuple (table, id, target field).
Warning
|
It is important to choose a keyword for this load that won’t stomp on real data in your database’s root.
We already have the top-level keys :friends and :enemies as part of our UI graph from root. So, we’re making up
:my-friends as the load key. One could also namespace the keyword with something like :server/friends .
|
Since friend and enemies are the same kind of query, let’s add both into the started callback:
(defonce app-1 (atom (uc/new-untangled-client
:started-callback (fn [app]
...
(df/load app :my-enemies Person {:target [:person-list/by-id :enemies :person-list/people]})
(df/load app :my-friends Person {:target [:person-list/by-id :friends :person-list/people]})))))
The server query processing is what you would expect from the last example (in operations.clj
):
(def people-db ...) ; as before
(defn get-people [kind keys]
(->> @people-db
vals
(filter #(= kind (:person/relation %)))
vec))
(defquery-root :my-friends
"Queries for friends and returns them to the client"
(value [{:keys [query]} params]
(get-people :friend query)))
(defquery-root :my-enemies
"Queries for enemies and returns them to the client"
(value [{:keys [query]} params]
(get-people :enemy query)))
A refresh of the server and reload of the page should now populate your lists from the server!
user=> (reset)
It is somewhat common for a server to return data that isn’t quite what we want in our UI. So far we’ve just been placing the data returned from the server directly in our UI. Untangled’s load mechanism allows a post mutation of the loaded data once it arrives, allowing you to re-shape it into whatever form you might desire.
For example, you may want the people in your lists to be sorted by name. You’ve already seen how to write client
mutations that modify the database, and that is really all you need. The client mutation for sorting the people
in the friends list could be (in operations.cljs
):
(defn sort-friends-by*
"Sort the idents in the friends person list by the indicated field. Returns the new app-state."
[state-map field]
(let [friend-idents (get-in state-map [:person-list/by-id :friends :person-list/people] [])
friends (map (fn [friend-ident] (get-in state-map friend-ident)) friend-idents)
sorted-friends (sort-by field friends)
new-idents (mapv (fn [friend] [:person/by-id (:db/id friend)]) sorted-friends)]
(assoc-in state-map [:person-list/by-id :friends :person-list/people] new-idents)))
(defmutation sort-friends [no-params]
(action [{:keys [state]}]
(swap! state sort-friends-by* :person/name)))
Of course this mutation could be triggered anywhere you could run a transact!
, but since we’re interested in morphing
just-loaded data, we’ll add it there (in basic-ui
):
(df/load app :my-friends Person {:target [:person-list/by-id :friends :person-list/people]
:post-mutation `ops/sort-friends})
Notice the syntax quoting. The post mutation has to be the symbol of the mutation. Remember that
our require has app.operations
aliased to ops
, and syntax quoting will expand that for us.
If you reload your UI you should now see the people sorted by name. Hopefully you can see how easy it is to change this sort order to something like "by age". Try it!
Once things are loaded from the server they are immediately growing stale (unless you’re pushing updates with websockets). It is very common to want to re-load a particular thing in your database. Of course, you can trigger a load just like we’ve been doing, but in that case we reloading a whole bunch of things. What if we just wanted to refresh a particular person (e.g. in preparation for editing it).
The load
function can be used for that as well. Just replace the keyword with an ident, and you’re there!
Load can take the app
or any component’s this
as the first argument, so from within the UI we can trigger a load
using this
:
(df/load this [:person/by-id 3] Person)
Let’s embed that into our UI at the root:
(defui ^:once Root
...
Object
(render [this]
(let [{:keys [ui/react-key current-user friends enemies]} (om/props this)]
(dom/div #js {:key react-key}
(dom/h4 nil (str "Current User: " (:person/name current-user)))
; Add a button:
(dom/button #js {:onClick (fn [] (df/load this [:person/by-id 3] Person))} "Refresh Person with ID 3")
...
The incoming query will have a slightly different form, so there is an alternate macro for making a handler for entity
loading. Let’s add this in operations.clj
:
(defquery-entity :person/by-id
"Server query for allowing the client to pull an individual person from the database"
(value [env id params]
(timbre/info "Query for person" id)
; the update is just so we can see it change in the UI
(update (get @people-db id) :person/name str " (refreshed)")))
The defquery-entity
takes the "table name" as the dispatch key. The value
method of the query handler will receive
the server environment, the ID of the entity to load, and any parameters passed with the query (see the :params
option
of load
).
In the implementation above we’re augmenting the person’s name with "(refreshed)" so that you can see it happen in the UI.
Remember to (reset)
your server to load this code.
Your UI should now have a button, and when you press it you should see one person update!
There is a special case that is somewhat common: you want to trigger a refresh from an event on the item that needs
the refresh. The code for that is identical to what we’ve just presented (a load with an ident and component); however,
the data-fetch
namespace includes a convenience function for it.
So, say we wanted a refresh button on each person. We could leverage df/refresh
for that:
(defui ^:once Person
... as before
(render [this]
(let [{:keys [db/id person/name person/age]} (om/props this)]
(dom/li nil
(dom/h5 nil name (str "(age: " age ")")
(dom/button #js {:onClick #(df/refresh! this)} "Refresh"))))))
This should already work with your server, so once the browser hot code reload has happened this button should just work!
Untangled’s load system covers a number of additional bases that bring the story to completion. There are load markers (so you can show network activity), UI refresh add-ons (when you modify data that isn’t auto-detected, e.g. through a post mutation), server query parameters, and error handling. See the developers guide, doc strings, or source for more details.
Mutations are handled on the server using the server’s defmutation
macro (if you’re using Untangled’s request parser).
This has the identical syntax to the client version!
Important
|
You want to place your mutations in the same namespace on the client and server since the defmutation
macros namespace the symbol into the current namespace.
|
So, this is really why we duplicated the namespace name in Clojure earlier and created an operations.clj
file right
next to our operations.cljs
.
So, we can now add an implementation for our server-side delete-person
:
(defmutation delete-person
"Server Mutation: Handles deleting a person on the server"
[{:keys [person-id]}]
(action [{:keys [state]}]
(timbre/info "Server deleting person" person-id)
(swap! people-db dissoc person-id)))
Refresh the code on your server with (reset)
at the REPL.
Mutations are simply optimistic local updates by default. To make them full-stack, you need to add a method-looking
section to your defmutation
handler:
(defmutation delete-person
"Mutation: Delete the person with person-id from the list with list-id"
[{:keys [list-id person-id]}]
(action [{:keys [state]}]
(let [ident-to-remove [:person/by-id person-id]
strip-fk (fn [old-fks]
(vec (filter #(not= ident-to-remove %) old-fks)))]
(swap! state update-in [:person-list/by-id list-id :person-list/people] strip-fk)))
(remote [env] true)) ; This one line is it!!!
The syntax for the addition is:
(remote-name [env] boolean-or-ast)
where remote
is the value of the default remote-name
. You can have any number of network remotes. The default one talks to the
page origin at /api
. What is this AST we speak of? It is the abstract syntax tree of the mutation itself (as data).
Using a boolean true means "send it just as the client specified". If you wish you can pull the AST from the env
,
augment it (or completely change it) and return that instead. See the developers guide for more details.
Now that you’ve got the UI in place, try deleting a person. It should disappear from the UI as it did before; however, now if you’re watching the network you’ll see a request to the server. If you server is working right, it will handle the delete.
Try reloading your page from the server. That person should still be missing, indicating that it really was removed from the server.
Working with legacy REST APIs is a simple, though tedious, task. Basically you need to add an additional remote to the Untangled Client that knows how to talk via JSON instead of EDN.
The basic steps are:
-
Implement
UntangledNetwork
. See theuntangled.client.network
namespace for the protocol and built-in implementation.-
Your
send
method will be passed the query/mutations the client wants to do. You must translate them to a REST call and translate the REST response into the desired tree of client data, which you then pass to theok
callback thatsend
is given.
-
-
Install your network handler on the client (using the
:networking
option) -
Add the
:remote
option to your loads, or use your remote name as the remote side of a mutation
For this example we’re going to use the following public REST API endpoint: http://jsonplaceholder.typicode.com/posts
which returns a list of posts (try it to make sure it is working).
It should return an array of JSON maps, with strings as keys.
Basically, when you run a transaction (read or
write) the raw transaction that is intended to go remote is passed into the send
method of a networking protocol.
The networking can send that unchanged, or it can choose to modify it in some way. Since REST servers don’t understand
our Untangled requests, we have to add a layer at the network to convert one to the other, and back (for the response).
First, let’s talk about the UI code for dealing with these posts, since the UI defines the queries. Here is a very simple UI we can add to our program:
(defui Post ; (1)
static om/Ident
(ident [this props] [:posts/by-id (:db/id props)])
static om/IQuery
(query [this] [:db/id :post/user-id :post/body :post/title])
Object
(render [this]
(let [{:keys [post/title post/body]} (om/props this)]
(dom/div nil
(dom/h4 nil title)
(dom/p nil body)))))
(def ui-post (om/factory Post {:keyfn :db/id}))
(defui Posts ; (2)
static uc/InitialAppState
(initial-state [c params] {:posts []})
static om/Ident
(ident [this props] [:post-list/by-id :the-one])
static om/IQuery
(query [this] [{:posts (om/get-query Post)}])
Object
(render [this]
(let [{:keys [posts]} (om/props this)]
(dom/ul nil
(map ui-post posts)))))
(def ui-posts (om/factory Posts))
(defui ^:once Root
static om/IQuery
(query [this] [:ui/react-key
:ui/person-id
{:current-user (om/get-query Person)}
{:blog-posts (om/get-query Posts)} ; (3)
{:friends (om/get-query PersonList)}
{:enemies (om/get-query PersonList)}])
static
uc/InitialAppState
(initial-state [c params] {:blog-posts (uc/get-initial-state Posts {}) ; (4)
:friends (uc/get-initial-state PersonList {:id :friends :label "Friends"})
:enemies (uc/get-initial-state PersonList {:id :enemies :label "Enemies"})})
Object
(render [this]
; NOTE: the data now comes in through props!!!
(let [{:keys [ui/react-key blog-posts current-user friends enemies]} (om/props this)] ; (5)
(dom/div #js {:key react-key}
(dom/h4 nil (str "Current User: " (:person/name current-user)))
(dom/button #js {:onClick (fn [] (df/load this [:person/by-id 3] Person))} "Refresh User with ID 3")
(ui-person-list friends)
(ui-person-list enemies)
(dom/h4 nil "Blog Posts") ; (6)
(ui-posts blog-posts)))))
-
A component to represent the post itself
-
A component to represent the list of the posts
-
Composing the Posts UI into root query
-
Composing the Posts UI into root initial data
-
Pull the resulting app db data from props
-
Render the list
Of course, there are no posts yet, so all you’ll see is the heading. Notice that there is nothing new here. The UI is completely network agnostic, as it should be.
Now for the networking code. This bit is a little longer, but most of it is the details around network communcation
itself, rather than the work you have to do. Create a new namespace src/main/app/rest.cljs
:
(ns app.rest
(:refer-clojure :exclude [send])
(:require [untangled.client.logging :as log]
[untangled.client.network :as net]
[cognitect.transit :as ct]
[goog.events :as events]
[om.transit :as t]
[clojure.string :as str]
[clojure.set :as set]
[om.next :as om])
(:import [goog.net XhrIo EventType]))
(defn make-xhrio [] (XhrIo.))
(defrecord Network [url request-transform global-error-callback complete-app transit-handlers]
net/NetworkBehavior
(serialize-requests? [this] true)
net/IXhrIOCallbacks
(response-ok [this xhr-io valid-data-callback]
;; Implies: everything went well and we have a good response
;; (i.e., got a 200).
(try
(let [read-handlers (:read transit-handlers)
; STEP 3: Convert the JSON response into a proper tree structure to match the query
response (.getResponseJson xhr-io)
edn (js->clj response) ; convert it to clojure
; Rename the keys from strings to the desired UI keywords
posts (mapv #(set/rename-keys % {"id" :db/id
"title" :post/title
"userId" :post/user-id
"body" :post/body})
edn)
; IMPORTANT: structure of the final data we send to the callback must match the nesting structure of the query
; [{:posts [...]}] or it won't merge correctly:
fixed-response {:posts posts}]
(js/console.log :converted-response fixed-response)
; STEP 4; Send the fixed up response back to the client DB
(when (and response valid-data-callback) (valid-data-callback fixed-response)))
(finally (.dispose xhr-io))))
(response-error [this xhr-io error-callback]
;; Implies: request was sent.
;; *Always* called if completed (even in the face of network errors).
;; Used to detect errors.
(try
(let [status (.getStatus xhr-io)
log-and-dispatch-error (fn [str error]
;; note that impl.application/initialize will partially apply the
;; app-state as the first arg to global-error-callback
(log/error str)
(error-callback error)
(when @global-error-callback
(@global-error-callback status error)))]
(if (zero? status)
(log-and-dispatch-error
(str "UNTANGLED NETWORK ERROR: No connection established.")
{:type :network})
(log-and-dispatch-error (str "SERVER ERROR CODE: " status) {})))
(finally (.dispose xhr-io))))
net/UntangledNetwork
(send [this edn ok error]
(let [xhrio (make-xhrio)
; STEP 1: Convert the request(s) from Om query notation to REST...
; some logic to morph the incoming request into REST (assume you'd factor this out to handle numerous kinds)
request-ast (-> (om/query->ast edn) :children first)
uri (str "/" (name (:key request-ast))) ; in this case, posts
url (str "http://jsonplaceholder.typicode.com" uri)]
(js/console.log :REQUEST request-ast :URI uri)
; STEP 2: Send the request
(.send xhrio url "GET")
; STEP 3 (see response-ok above)
(events/listen xhrio (.-SUCCESS EventType) #(net/response-ok this xhrio ok))
(events/listen xhrio (.-ERROR EventType) #(net/response-error this xhrio error))))
(start [this app]
(assoc this :complete-app app)))
(defn make-rest-network [] (map->Network {}))
The steps you need to customize are annotated in the comments of the code. There are just a few basic steps:
-
Om comes with a handy function that can convert a query into an AST, which is easier to process. We don’t really care too much about the whole query, we just want to detect what is being asked for (we’re going to ask for
:posts
). -
Once we’ve understood what is wanted, we create a REST URL and GET the data from the REST server.
-
When we get a successful response we need to convert the JSON into the proper EDN that the client expects. In this case we’re looking for
{ :posts [ {:db/id 1 :post/body "…" :post/title "…" ] … }
. -
Once we have the properly structure tree of data to match the query, we simply pass it to the ok callback that our send was given.
In a more complete program, you’d put hooks at steps (2) and (3) to handle all of the different REST requests, so that the majority of this code would be a one-time thing.
Untangled lets you set up networking yourself. We’d still like to talk to our server, but now we also want to be able
to talk to the REST server. The modification is done in our basic-ui
namespace where we create the client:
(ns app.basic-ui
(:require [untangled.client.core :as uc]
[om.dom :as dom]
[app.operations :as ops]
[om.next :as om :refer [defui]]
[app.rest :as rest] ; <-------ADD THIS
[untangled.client.data-fetch :as df]
[untangled.client.mutations :as m]
[untangled.client.network :as net]))
...
(defonce app-1 (atom (uc/new-untangled-client
; Set up two networking handlers (:remote is an explicit creation of the "default" that we still want)
:networking {:remote (net/make-untangled-network "/api" :global-error-callback (constantly nil))
:rest (rest/make-rest-network)}
:started-callback ...)))
All the hard stuff is done. Loading is now triggered just like you would have before, except with a :remote
option
to specify which network to talk over:
(defonce app-1 (atom (uc/new-untangled-client
...
:started-callback (fn [app]
(df/load app :posts Post {:remote :rest :target [:post-list/by-id :the-one :posts]})
... as before ...
The same technique is used. Everything you’ve read is accurate for mutations as well (you’ll see the mutation come
into the send
function). To trigger a mutation, just add another section to your client mutation (a mutation can
be sent to any number of remotes, in fact):
(defmutation delete-post
[{:keys [id]}]
(action [env] ...stuff to affect local db...)
; you could also include this: (remote [env] true)
(rest [env] true)) ; tell the :rest networking to send this mutation
So, action
names the local (optimistic) effect. Each other method name must match a remote’s name as configured
in the :networking
of the client. If you return true (or an AST) from one of these "remote" sections, it will trigger
the mutation to be sent to that network handler.
Just for reference the complete project for this guide is on Github at https://github.com/awkay/untangled-getting-started