-
Notifications
You must be signed in to change notification settings - Fork 362
Quick Start (om.next)
DO NOT EDIT THIS PAGE: This page is under heavy active development.
Om Next is a uniform yet extensible approach to building networked interactive applications. By providing a structured discipline over the management of application state, Om Next narrows the scope of incidental complexity often found in user interface development. The Om Next discipline is founded upon immutable data structures, declarative data specifications, and a simple convention for routing data access and mutations.
Om Next borrows ideas liberally from Facebook’s Relay, Netflix’s Falcor, and Cognitect’s Datomic. If you are not familiar with these technologies, fear not, this tutorial makes few assumptions. You will be guided through all the core concepts of Om Next. This will prepare you for later tutorials that show custom storage integration and transparent synchronization between your UI and a remote service.
This tutorial uses Leiningen, Figwheel, and Google Chrome. You should install Leiningen and Google Chrome before proceeding. Leiningen is a standard tool for managing Clojure and ClojureScript library dependencies. Figwheel is a ClojureScript build tool and REPL that enables an expressive live programming model well suited for interactive application development. Figwheel also plays well with text editors that make traditional REPL integration more challenging.
You can of course use any web browser, but this tutorial only includes relevant instructions for Chrome to avoid tangential material.
Create a new project and switch into it:
mkdir om-tutorial
cd om-tutorial
Inside your project directory create a project.clj
:
touch project.clj
Make it look like the following:
(defproject om-tutorial "0.1.0-SNAPSHOT"
:description "My first Om program!"
:dependencies [[org.clojure/clojure "1.10.1"]
[org.clojure/clojurescript "1.10.520"]
[org.omcljs/om "1.0.0-beta4"]
[figwheel-sidecar "0.5.19" :scope "test"]])
A Leiningen project.clj
file simply allows you to declare a variety
of properties about your project. In our case the most important
is the list of :dependencies
.
Now create a file script/figwheel.clj
.
mkdir script
touch script/figwheel.clj
Change script/figwheel.clj
to look like the following:
(require '[figwheel-sidecar.repl :as r]
'[figwheel-sidecar.repl-api :as ra])
(ra/start-figwheel!
{:figwheel-options {}
:build-ids ["dev"]
:all-builds
[{:id "dev"
:figwheel true
:source-paths ["src"]
:compiler {:main 'om-tutorial.core
:asset-path "js"
:output-to "resources/public/js/main.js"
:output-dir "resources/public/js"
:verbose true}}]})
(ra/cljs-repl)
This file describes how to build your ClojureScript project and starts a REPL. If you are new to ClojureScript you may find this file a bit overwhelming. If you would like to know more, after this tutorial you may want to work through the ClojureScript Quick Start to reinforce fundamental ClojureScript concepts encountered in this tutorial.
We now need to provide some basic markup to host our ClojureScript application.
Make a file resources/public/index.html
:
mkdir -p resources/public
touch resources/public/index.html
Change the contents of this file to the following:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>Om Tutorial!</title>
</head>
<body>
<div id="app"></div>
<script src="js/main.js"></script>
</body>
</html>
Create a file src/om_tutorial/core.cljs
:
mkdir -p src/om_tutorial
touch src/om_tutorial/core.cljs
Edit its contents to look like the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(enable-console-print!)
(println "Hello world!")
Start Figwheel:
lein run -m clojure.main script/figwheel.clj
For enhanced REPL behavior it’s recommended that you install rlwrap. Under OS X this can be easily done with brew.
If you have rlwrap
installed you can then launch with:
rlwrap lein run -m clojure.main script/figwheel.clj
Point your browser at http://localhost:3449. You should see a blank page with the title “Om Tutorial!” visible on your browser tab.
Open the Chrome Developer Tools with the View > Developer >
JavaScript Console menu. In the JavaScript Console you should see
Hello, world!
printed out.
Let’s write our first component!
Edit src/om_tutorial/core.cljs
to look like the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(defui HelloWorld
Object
(render [this]
(dom/div nil "Hello, world!")))
(def hello (om/factory HelloWorld))
(js/ReactDOM.render (hello) (gdom/getElement "app"))
Try modifying the "Hello, world!"
string and saving the file. You
should see that the browser updates immediately.
This file presents a lot of new ideas, let’s break them down.
The very first thing we encounter is the ClojureScript ns
form. This
declares the current namespace (in other languages you might call this
“module”
). We require the goog.dom
, om.next
, and om.dom
libraries. Other languages might call this process “importing”.
The defui
macro gives us a succinct syntax for declaring Om
components. defui
supports many of the features of ClojureScript’s
deftype
and defrecord
with a variety of modifications better
suited to the definition of React components.
If you are familiar with Om, you will notice this is a big
departure. Om Next components are truly plain JavaScript classes. This
component only declares one JavaScript Object
method – render
.
Finally, in order to create an Om component we must first produce a
factory from the component class. The function returned by
om.next/factory
has the same signature as pure React components with
the exception that the first argument is usually an immutable
data structure.
render
should return an Om Next or React component. In our case we
return a div
. Components are usually constructed from two or more
arguments. The first argument will be props
– the properties that
will customize the component in some way. The remaining arguments will
be children
, the sub-components hosted by this node.
Note: For a more detailed list of React methods that components may implement please refer to the React documentation
Like plain React components, Om Next components take props
as their
first argument and children
as the remaining ones. Let’s modify our
file to look like the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(defui HelloWorld
Object
(render [this]
(dom/div nil (get (om/props this) :title))))
(def hello (om/factory HelloWorld))
(js/ReactDOM.render
(hello {:title "Hello, world!"})
(gdom/getElement "app"))
This is slightly more verbose than our previous example but we’ve
gained abstraction power – the HelloWorld
component no longer hard
codes a specific string.
For example we can change our code to the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(defui HelloWorld
Object
(render [this]
(dom/div nil (get (om/props this) :title))))
(def hello (om/factory HelloWorld))
(js/ReactDOM.render
;; CHANGED
(apply dom/div nil
(map #(hello {:react-key %
:title (str "Hello " %)})
(range 3)))
(gdom/getElement "app"))
We can render as many HelloWorld
components as we please and they
all receive custom data. Feel free to change (range 3)
to something
else. Figwheel will reflect these changes immediately.
We have thus far only seen stateless Om Next components. In order to do something useful you might think that we would need to introduce stateful components. This is not the case. In Om Next we introduce state into the application via a global atom.
In Om Next application state changes are managed by a reconciler. The reconciler accepts novelty, merges it into the application state, finds all affected components based on their declared queries, and schedules a re-render.
We will first examine a naive attempt to introduce application state. We will later revise this approach to something more robust.
In the following we create a global atom to hold our application state. We create a reconciler using this atom and then we add a root for the reconciler to control.
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(def app-state (atom {:count 0}))
(defui Counter
Object
(render [this]
(let [{:keys [count]} (om/props this)]
(dom/div nil
(dom/span nil (str "Count: " count))
(dom/button
#js {:onClick
(fn [e]
(swap! app-state update-in [:count] inc))}
"Click me!")))))
(def reconciler
(om/reconciler {:state app-state}))
(om/add-root! reconciler
Counter (gdom/getElement "app"))
You should now see a counter in your browser window. Clicking on the button will increase the count in the global atom. This triggers the reconciler to re-render the root.
Note:
om.next/add-root!
takes a reconciler, a root class and a DOM element. UnlikeReactDOM.render
we do not instantiate the component. The reconciler will do this on our behalf as it may need to request data from an endpoint first.
The problem with the program above is that the counter is deeply coupled to the global state atom. The counter has direct knowledge of the structure of the state atom. While this may be convenient in this trivial example, in larger applications this will be an endless supply of incidental complexity.
Previously Om attempted to mitigate deep coupling to state via the cursor abstraction. Unfortunately, cursors brought problems of their own. In Om Next, instead of introducing a new abstraction we simply embrace a time tested way of preventing such state coupling – client server architecture.
Om Next encourages a separation between components and code that actually reads and modifies global state. This design is desirable even if an Om Next application is entirely client side.
Instead of mixing control logic into components as is often encountered in React based systems, Om Next moves all state management into a router abstraction. Components declaratively request data (reads) from the router. In addition, components do not mutate application state, instead they request application state transitions (mutations) and the router will apply the state changes.
Applications designed in this way make it trivial to introduce custom stores like DataScript without touching or changing any components. In the case where there is a real remote server component, this architectural design permits seamless arbitrary partitioning of application state between local client logic and remote server logic, that is, fully transparent synchronization.
A client server architecture requires an established protocol between the client and the server. This protocol must be able to describe state transfer (reads) and state transitions (mutations).
Typical web applications already follow this pattern in the form of REST. However the unit of composition is incredibly inexpressive, a URL. Relay and Falcor have already demonstrated the benefits of moving to a richer expression of client demands.
Om Next also departs from tradition and embraces a simple data representation of client demands. This simple data representation eliminates the problematic tradeoffs present in string based routing. The data representation is a variant on s-expressions, EDN.
Because of these important differences, in Om Next we call this process “parsing” rather than routing. The rationale for this departure will become more self-evident as the tutorial progresses.
We will first study parsing in isolation.
Parsing involves handing two kinds of expressions – reads and mutations. Reads should return the requested application state, mutations should transition the application state to some new desired state and describe what changed.
A parser is created from two functions that provide semantics for reads and mutations:
(def my-parser (om.next/parser {:read read-fn :mutate mutate-fn}))
A parser takes a query expression and evaluates it using the provided read and mutate implementations.
Inspired by Datomic Pull Syntax, an Om Next query expression is a vector that enumerates the desired state reads and state mutations.
For example getting a todo list might look something like the following:
[{:todos/list [:todo/created :todo/title]}]
Updating a todo list item might look something like the following:
[(todo/update {:id 0 :todo/title "Get Orange Juice"})]
We will interactively parse some query expressions at the Figwheel REPL to build our intuition of this fundamental Om Next concept.
The signature of a read function is [env key params]
. env
is a
hash map containing any context necessary to accomplish reads. key
is the key that is being requested to be read. Finally params
is a
hash map of parameters that can be used to customize the read. In many
cases params
will be empty.
Enter the following at the Figwheel REPL:
(in-ns 'om-tutorial.core)
(defn read
[{:keys [state] :as env} key params]
(let [st @state]
(if-let [[_ v] (find st key)]
{:value v}
{:value :not-found})))
Our read function reads from a :state
property supplied by the env
parameter (we will see how :state
is supplied shortly). Our function
checks if the application state contains the key. Read functions must
return a hash map containing a :value
entry (note the :not-found
value shown here for missing keys has no special meaning in Om Next).
Let’s create a parser:
(def my-parser (om/parser {:read read}))
my-parser
is just a function. We can now read Om Next query
expressions.
(def my-state (atom {:count 0}))
(my-parser {:state my-state} [:count :title])
;; => {:count 0, :title :not-found}
Aha! We supply the env
parameter.
A query expression is always a vector. The result of parsing a query expression is always a map.
On the frontend the Om Next reconciler will invoke your parser on your
behalf and pass along the :state
parameter. When writing a backend
parser you will usually supply env
yourself.
Components will not just read data from the application state. They will want to trigger application state transitions based on user generated events like mouse clicks, keyboard events, and touch gestures. We need to supply a function that interprets these requests for application state transition.
Let’s create a simple mutate
function. The signature is identical to
read functions, however the return value is different. Copy and paste
the following into your Figwheel REPL:
(in-ns 'om-tutorial.core)
(defn mutate
[{:keys [state] :as env} key params]
(if (= 'increment key)
{:value {:keys [:count]}
:action #(swap! state update-in [:count] inc)}
{:value :not-found}))
We first check that the key is a mutation that we actually
implement. If it is we return a map containing two keys, :value
as
before and :action
which is a thunk, i.e. a function that takes no arguments. Mutations should return a
map for :value
. This map can contain two keys – :keys
and/or
:tempids
. The :keys
vector is a convenience that communicates what
read operations should follow a mutation. :tempids
will be
discussed later. Mutations can easily change multiple aspects of the
application (think Facebook “Add Friend”). Adding :value
with a
:keys
vector helps users identify stale keys which should be
re-read. The guiding principle here shares common goals with
HATEOAS.
:action
is a function that takes no arguments, that should transition the application state. You
should never run side effects in the body of a mutate function
yourself. Doing so makes it more challenging for Om Next to provide
reliable state management.
Assuming you did the previous REPL interactions now try the following:
(def my-parser (om/parser {:read read :mutate mutate}))
(my-parser {:state my-state} '[(increment)])
@my-state
;; => {:count 1}
Change src/om_tutorial/core.cljs
to the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(def app-state (atom {:count 0}))
(defn read [{:keys [state] :as env} key params]
(let [st @state]
(if-let [[_ value] (find st key)]
{:value value}
{:value :not-found})))
(defn mutate [{:keys [state] :as env} key params]
(if (= 'increment key)
{:value {:keys [:count]}
:action #(swap! state update-in [:count] inc)}
{:value :not-found}))
(defui Counter
static om/IQuery
(query [this]
[:count])
Object
(render [this]
(let [{:keys [count]} (om/props this)]
(dom/div nil
(dom/span nil (str "Count: " count))
(dom/button
#js {:onClick
(fn [e] (om/transact! this '[(increment)]))}
"Click me!")))))
(def reconciler
(om/reconciler
{:state app-state
:parser (om/parser {:read read :mutate mutate})}))
(om/add-root! reconciler
Counter (gdom/getElement "app"))
Before we dive in, confirm that the behavior is the same as before. Open the Chrome JavaScript Console and you will see that every single transaction was logged by Om Next. The object which initiated the transaction, the contents of the transaction, and a UUID identifying the state of the application before the transaction was applied.
Copy and paste one of the UUIDs and try the following at the REPL (your UUID will be different!):
(in-ns 'om-tutorial.core)
(om/from-history reconciler
#uuid "9e7160a0-89cc-4482-aba1-7b894a1c54b4")
;; => {:count 2}
Fix & Continue: Om Next automatically records the last 100 states of the application (the number of recorded states can be configured). This feature makes it trivial to take the application back to a previous state, fix a bug, re-apply a transaction, and continue development without further interruption.
The parsing bits should be familiar from the previous section. We only had to make three changes.
Om Next components always declare the data they wish to read. This is
done by implementing a simple protocol om.next/IQuery
. This method
should return a query expression. Note that we added static
before the protocol. This is required and ensures the method is
attached to the class (it will also be attached to instances).
This is so that the reconciler can determine the query required to display the application without instantiating any components at all.
The counter now calls om.next/transact!
with the desired transaction
rather than touching the application state directly. This removes the
tight coupling between components and global application state.
The reconciler now takes your custom parser. All application state
reads and mutations will go through your own custom parsing code. The
reconciler will populate the env
parameter with all the necessary
context needed to make decisions about reads and mutations including
whatever :state
parameter was provided to the reconciler.
Components can run transactions. But for development convenience it’s also possible to submit transactions directly to the reconciler.
Try the following at Figwheel REPL:
(in-ns 'om-tutorial.core)
(om.next/transact! reconciler '[(increment)])
You should see the change reflected immediately in the UI. If you have the Chrome JavaScript Console open you should also see that the transaction was logged.
While Om Next requires a little bit more work over just banging on atoms, it should now be apparent that Om Next streamlines the construction of declarative reusable components. The disciplined separation of application state reads and mutations means you can scale up your application without rapid expansion of your complexity budget.
Once you have declarative queries, it becomes quickly apparent they are an ideal way to change the behavior of the application. Like Relay, Om Next fully supports query modification. However it does so in a manner that does not compromise global time travel.
Change src/om_tutorial/core.cljs
to look like the following:
(ns om-tutorial.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(def app-state
(atom
{:app/title "Animals"
:animals/list
[[1 "Ant"] [2 "Antelope"] [3 "Bird"] [4 "Cat"] [5 "Dog"]
[6 "Lion"] [7 "Mouse"] [8 "Monkey"] [9 "Snake"] [10 "Zebra"]]}))
(defmulti read (fn [env key params] key))
(defmethod read :default
[{:keys [state] :as env} key params]
(let [st @state]
(if-let [[_ value] (find st key)]
{:value value}
{:value :not-found})))
(defmethod read :animals/list
[{:keys [state] :as env} key {:keys [start end]}]
{:value (subvec (:animals/list @state) start end)})
(defui AnimalsList
static om/IQueryParams
(params [this]
{:start 0 :end 10})
static om/IQuery
(query [this]
'[:app/title (:animals/list {:start ?start :end ?end})])
Object
(render [this]
(let [{:keys [app/title animals/list]} (om/props this)]
(dom/div nil
(dom/h2 nil title)
(apply dom/ul nil
(map
(fn [[i name]]
(dom/li nil (str i ". " name)))
list))))))
(def reconciler
(om/reconciler
{:state app-state
:parser (om/parser {:read read})}))
(om/add-root! reconciler
AnimalsList (gdom/getElement "app"))
By now, most of the bits related to parsing should be
familiar to you so we’ll focus only on the new ideas. We switched
our read
function to a multimethod – this makes it easier to add cases
in an open ended way.
We implement the :animals/list
case. We are finally using params
, and
in this case we destructure start
and end
.
This brings us to the AnimalsList
component. This component defines
om.next/IQueryParams
along with om.next/IQuery
. The params
method should return a map of bindings. These will be used to replace
any occurrences of ?some-var
in the actual query.
At the Figwheel REPL try the following (you haven’t seen
om.next/class->any
yet, we’ll explain it in a moment):
(in-ns 'om-tutorial.core)
(om/get-query (om/class->any reconciler AnimalsList))
;; => [:app/title (:animals/list {:start 0, :end 10})]
It should be clear now that the params have been bound to the query.
Let’s change the query by modifying the parameters. This can be done
with om.next/set-query!
.
(om/set-query!
(om/class->any reconciler AnimalsList)
{:params {:start 0 :end 5}})
You should see the UI change immediately. You should also see Om Next log an event in the Chrome JavaScript Console. Query modifications mutate the state of program and thus are also recorded into the application state history log.
Grab one of the UUIDs and you’ll see that query state is maintained when you time travel (again your UUID will not be the one below!):
(reset! app-state
(om/from-history reconciler
#uuid "e0a07c41-413a-430c-8c91-976a155241c3"))
Om Next supports a first class notion of identity. While mounted React components do provide a kind of identity, Om Next provides a stronger model that can cut across the mounted component tree. In addition this model delivers powerful debugging and reasoning facilities over those found in React itself.
Every reconciler has an indexer. The indexer keeps indexes that
maintain a variety of useful mappings. For example a class to all
mounted components of that class, or prop name and all components that
use that prop name. For example we already say om.next/class->any
which is incredibly useful when testing things out at a REPL.
The details of the indexer are not fully ironed out yet, but suffice to say it is a critical component that both simplifies reconciliation and enhances interactive development.
This wraps up the Om Next fundamentals. Take a breather and dive into the second tutorial when you’re ready. The following tutorial shows how Om Next provides a powerful but simple tool for cutting across the component tree – identity:
Also, it may not be fully apparent how the Om Next architecture permits seamless integration of custom stores or remote services. The following links provide more details: