This tutorial will try to explain how to make the simplest client/server app using om-next and a simple backend.
We’ll start the tutorial with building a simple login page. The user is prompted for a username and a password, and when they supply the correct one, as verified by the backend database, the login form is replaced by a success message.
We’ll break down each step of this simple om-next app as it proceeds.
The code is contained in two git repositories:
The client code: https://github.com/ftravers/omn1
omn1
stands for: “Om Next, 1st tutorial”
The server code: https://github.com/ftravers/omn1be
omn1be
stands for: “Om Next Back End, 1st tutorial”
Throughout the tutorial we’ll reference branches in these repositories. Check that branch out to follow along.
Git Repository: omn1
Git Branch: in-the-beginning
Here is a super simple om-next web-app. All it does is print “Hello World”.
(ns omn1.webpage
(:require
[om.next :as om :refer-macros [defui]]
[om.dom :as dom :refer [div]]
[goog.dom :as gdom]))
(defui SimpleUI (ref:ss-comp)
Object
(render
[this]
(div nil "Hello World"))) (ref:ss-hard-code)
(om/add-root!
(om/reconciler {}) (ref:ss-recon)
SimpleUI
(gdom/getElement "app"))
Line (ss-recon): is a bit redundant in this example, but its a required argument to the
add-root!
function, so we include it. It doesn’t do anything at
this point, but later on we’ll see what it does.
Line (ss-comp): we refer to the user interface created by the defui
macro as a
Component.
Line (ss-hard-code): we’ve hard-coded “Hello World” into the UI component, which is bad form, lets extract it now.
Git Branch: remove-state
Now we move the data from being hard coded inside the component to an external light weight database. Parts that are redundant from previous examples are elided.
;; ...
(defui SimpleUI
Object
(render
[this]
(div nil (:greeting (om/props this))))) (ref:rs-access)
(def app-state (atom {:greeting "Hello World"})) (ref:rs-state)
(def reconciler
(om/reconciler
{:state app-state})) (ref:rs-wire-state)
;; ...
Line (rs-state): here we create a state atom that holds the state for the application.
Line (rs-wire-state): here we add the state atom into the
application by wiring it into the reconciler
.
Line (rs-access): finally, we access the state in the root
component.
Git Branch: add-reader-query-parser
;; ...
(defui SimpleUI
static om/IQuery
(query [_] [:greeting])
;; ...
)
;; ...
(defn my-reader
[env kee parms]
(.log js/console (:target env)) (ref:rqp-logging)
{:value "abc"})
(def parser
(om/parser {:read my-reader}))
(def reconciler
(om/reconciler
{:state app-state
:parser parser}))
;; ...
Now the application looks quite a bit more complicated. We’ve added a query to the component, a reader function and a parser.
Run the program and inspect the console. The code:
Line (rqp-logging): causes the following output:
null
:remote
Om-next will run the reader function once for a local query, and once
for any remotes that are defined. We haven’t define any remote end
points, but om-next out of the box provides one remote called:
:remote
. A remote is a mechanism to wire in calls to a backend
server.
Our reader function my-reader
, has the function parameter kee
, set
to the keyword :greeting
. Then the reader result is a map with a
key :value
set to the string abc
.
Reader functions should always return a map with a :value
key, that
is set to whatever the value for the passed in kee
is.
As you can see {:greeting "abc"}
gets printed out on the webpage.
So we have a lot of ceremony already, and it is a bit hard to percieve the benefits of this approach at this point. Unfortunately, we’ll just need to chug through this and hopefully in the end you can start to appreciate the benefits.
Our eventual goal is to create a login page that passes a username and password to a backend database, and if the username/password pair matches what is in the database, then we display a “login successful” page.
Our query is going to be: :user/authenticated
. This value will
initially be false
, but eventually, when the correct
username/password pair is supplied, be changed to be true
.
Git Branch: parameterize-query
(defui SimpleUI
static om/IQuery
(query [_]
'[(:user/authenticated
{:user/name ?name
:user/password ?pword})])
static om/IQueryParams
(params [this]
{:name "" :pword ""})
;; ...
)
(defn my-reader
[env kee parms]
(.log js/console parms) (ref:pq-logging)
;; ...
)
The IQueryParams
indicate which parameters are available to this
component and query. Our IQuery
section has been updated to make
use of these parameters.
Line (pq-logging): We are dumping the parms
parameter of the reader
function to the console. Go inspect the console to see the shape of
the data.
Git Branch: add-remote
;; ...
(defui SimpleUI
static om/IQuery
(query [_] '[(:user/authenticated {:user/name ?name :user/password ?pword})])
static om/IQueryParams
(params [this]
{:name "fenton" :pword "passwErd"}) (ref:ar-hard-code)
;; ...
)
(defn my-reader
[env kee parms]
(let [st (:state env)]
{:value (get @st kee)
:remote true (ref:ar-reader-remote)
}))
(defn remote-connection
[qry cb]
(.log js/console (str (:remote qry)))
(cb {:user/authenticated true}))
(def reconciler
(om/reconciler
{:state app-state
:parser parser
:send remote-connection (ref:ar-wire-recon)
}))
;; ...
Line (ar-reader-remote): Here we return true
from our reader
function to trigger the remote call. Here we return the name of the
remote as the key, :remote
, and set it’s value to true
. Om-next
gives us this remote by default. We could add other remotes if we
wanted to.
Line (ar-wire-recon): We must wire up our remote function in the
reconciler
with the :send
keyword parameter.
Now we have added a function that is stubbing out what will eventually
be an actual call to a remote server. Our remote-connection
function responds with the key :user/authenticate
to true
.
Line (ar-hard-code): Finally lets hardcode in a username password pair. If you look at the console of the browser then, you’ll see the following data spit out:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
So this is the data that our client will send to our server. This is EDN.
Om-next has nothing to say about how you would communicate with a backend server. So you can use any of the methods available to a browser to do this. Some examples of technologies you could use: http, REST, json, websockets, EDN, transit, blah, blah, blah.
The key to understand is that the client has a piece of Clojure EDN data that it will give to you, and you have to send that back to the server somehow. This example happens to use EDN over websockets. Transit with REST might be another good way.
In our example we are using this data:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
Please keep this front and center in your mind. Any good integration is going to be all about data and only data. Here we have a classic piece of Clojure EDN. In classic clojure style, data is KING!
Once the data is received by your tech stack on the server side, you pump it through om-next server. In our example we make use of a reader function and the om-next parser to handle this data from the client. In a full example you’d also have mutators too most likely.
So lets switch gears and head over and build up an om-next server.
So continuing on with our example, by some mechanism, the piece of data:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
is going to arrive. We will fill in the plumbing between the client and server later. Remember that is not the focus of this tutorial, so it will not be explored in detail.
In om-next, it is the job of the Parser, to figure out what to do with both queries and mutations. Checkout the following github project if you haven’t already done so:
Github Repository: https://github.com/ftravers/omn1be
Git Branch: step1-backend
Checkout the project and branch and launch your REPL.
lein repl
Now try some tests in the REPL:
omn1be.core> (parser {:state users}
'[(:user/authenticated
{:user/name "fenton"
:user/password "passwerd"})])
#:user{:authenticated false}
omn1be.core> (parser {:state users}
'[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})])
#:user{:authenticated true}
Lets quickly look at our reader function, even though it doesn’t present any new ideas.
(defn reader
[env kee params]
(let [userz (:state env)
username (:user/name params)
password (:user/password params)]
{:value (valid-user userz username password)} (ref:be-reader)
))
The input params are the same as on the client.
Line (be-reader): just like the client we simply return a map with
the answer attached to the :value
key.
And our parser is dead simple:
(def parser (om/parser {:read reader}))
Thats all there is to a basic om-next server.
For the full working sample checkout the master branches of the two
projects, omn1
and omn1be
.
Start the backend at the command prompt:
cd omn1be; lein repl
(load "websocket")
(in-ns 'omn1be.websocket)
(start)
(in-ns 'omn1be.router)
cd omn1; lein figwheel
Navigate to:
http://localhost:3449/
Of course you’ll need to have datomic installed for this complete example to work.
Our code has one root UI component. This component has a query for
one field, :user/authenticated
. The query for this field accept two
parameters, :user/name
and :user/password
.
The basic idea is that we send this query for the
:user/authenticated
value, passing along the username and password
of the user. This gets looked up in the database and if the pair is
valid, then :user/authenticated
gets set to the value true
otherwise it is set false
.
The first stage to an om next application is to load the Root component. This is dictated by the following line:
(om/add-root! reconciler Login (gdom/getElement "app"))
Here the second param, root-class, is set to the Login
component.
The third param, target
, is the div in the index.html
where to
mount or locate this component. Finally the first argument is the
reconciler to use for this application. The reconciler hold together
all the function and state required to handle data flows in the
application.
Our root component, Login
, has a query of the form:
static om/IQuery
(query
[_]
'[(:user/authenticated
{:user/name ?name
:user/password ?password})])
Basically this says, get the value of :user/authenticated
supplying
as parameters to the query the values for the :user/name
and
:user/password
fields.
?name
and ?password
are query parameter variables that hold the
values for the username and password that this query will eventually
use in its query for :user/authenticated
. We initially set their
value to be the empty string:
static om/IQueryParams
(params [this]
{:name "" :password ""})
In react we can have local state variables. The code:
(initLocalState
[this]
{:username "fenton"
:password "passwErd"})
creates two parameters: :username:
and :password
and sets their
initial values.
In the :onChange
handlers for our two input elements we set the
values of these two react state variables to be whatever the user
types into the name and password input boxes.
(input
#js
{:name "uname"
:type "text"
:placeholder "Enter Username"
:required true :value username
:onChange
(fn [ev]
(let [value (.. ev -target -value)]
(om/update-state! this assoc :username value)))})
Finally when the user clicks the submit button to send the username and password to the backend we take the values from the react component state, and use those values to update the values of the query parameters. Updating a query’s parameter values causes the query to be rerun.
Next we’ll see how this state all runs by logging out to the console each time the reader is run. The reader is the function that is run to handle processing the queries.
We can see everytime a query is run by putting a log statement into our reader function.
(defmethod reader :default
[{st :state :as env} key _]
(log "default reader" key "env:target" (:target env))
{:value (key (om/db->tree [key] @st @st))
;; :remote true
:remote false
})
Here we see a log statement at the top of the reader function. Lets see what a dump of the browser console looks like and try to understand it.
[default reader]: :user/authenticated env:target null(ref:load-comp1)
[props]: {:user/authenticated false} (ref:load-comp2)
[default reader]: :user/authenticated env:target :remote (ref:remote)
In line (load-comp): the query of the component is run before the component is first loaded.
In line (load-comp2): as the component is rendered we dump the react
properties that have been passed into the component, in this case it
is simply the @app-state
.
This is done with line:
(log "props" (om/props this))
In the component rendering.
The line: (remote), comes again from our :default
reader, but this
time it is passed for the remote called :remote
. By default out of
the box in om-next we get a remote named :remote
. So the reader
will get called once for a local call, and once for each remote we
have defined.
So we have traced a basic flow of a simple component. Now lets see
how to trigger a remote read. When our reader is getting called with
the :target
a remote, if we then also return :remote true
in our
returned map from the reader, then our remote functions will also be
called.
Git Repository: https://github.com/ftravers/omn1
Git Branch: simple-remote
So we want to send our stuff to a backend server. Om next creates a default hook for this. So basically what happens again, is that our reader will get called twice, once for trying to satisfy our query from our local state, and once for trying to get the information from the backend.
If we return :remote true
in our reader response map, the remote
hooks will get triggered. So lets see this in action. First lets
wire up some basic ‘remotes’.
First we must write a function that will be our remote query hook:
(defn my-remoter
[qry cb]
(log "remote query" (str qry))
(cb {:some-param "some value"}))
And lets wire this into the reconciler.
(def reconciler
(om/reconciler
{:state app-state
:parser parser
:send my-remoter}))
And finally our reader needs to return :remote true
for the remote
to run:
(defmethod reader :default
[{st :state :as env} key _]
(log "default reader" key "env:target" (:target env))
{:value (key (om/db->tree [key] @st @st))
:remote true})
Now lets see what happens as we trace the programs execution with some logging statements
[default reader]: :some-param env:target null
[props]: {:some-param "not much"}meta
[default reader]: :some-param env:target :remote
[remote query]: {:remote [:some-param]} (ref:remote-query)
[app state]: {:some-param "not much"} (ref:app-state-before-remote)
[default reader]: :some-param env:target null
[props]: {:some-param "value gotten from remote!"}meta
[app state]: {:some-param "value gotten from remote!"}
[default reader]: :some-param env:target null
The first three lines remain unchanged.
Line (remote-query): we see we’ve entered into the hook for the
remote function. We dump the @app-state
Line (app-state-before-remote): before we call the callback, cb
, with our
new data, which should merge the data into our @app-state
map. The
callback is called and we can see that the @app-state
is updated and
the component is re-rendered.
I’m not quite sure why the reader is called at the end…but maybe someone who knows om-next better can explain that.
At this point we aren’t hooking into any backend, we are just stubbing
out the call to the backend. To have a real call to a backend
involves taking our request and sending via http
, json
,
websockets
, edn
, or some other way to our backend. Receiving the
data, doing something with it and creating a response and sending it
back, then getting it back on the client, and updating the local
client data and therefore updating the client webpage.
So that is a lot of stuff. Don’t dispair, I will demonstrate real code that does this, but the scope of this tutorial is to demonstrate how to use om-next with a remote. How exactly data is exchanged with a remote is actually a separate concern. This is actually a wonderful thing. As clojuristas we dont like monolithic frameworks that package the entire world into an opinionated whole. Perhaps like a rails project. We would rather pick the pieces that best suit our needs, and data transport between client and server is not something that om next has an opinion on and it lets you fill in that blank however you would like.
What we need to be clear on is the boundaries between the transport segment and om next. So lets reiterate that now to be absolutely clear.
This boundary or responsibility handoff occurs in our my-remoter
function. Om next hands us the data of the query that we’ve put into
the qry
parameter, then it expects us to call the callback, cb
,
with the results of our remote query. We’ll look into detail of what
the shape of the data is that om next expects us to return the result
in.
Here is a sample of data in and data out that om next would be happy with:
IN:
[:some-param]
OUT:
{:some-param "Some New Value"}
I have written simple websocket client and server libraries that I use. They are located at:
https://github.com/ftravers/websocket-client
and
https://github.com/ftravers/websocket-server
I have chosen to send EDN over this websocket connection.
Another perhaps better choice would be to send JSON over Transit. Perhaps using a Ring server or some other type of web server. My websocket server uses http-kit to act as the websocket server.
Again, what you use is really beyond the scope of this tutorial, and I dont want this tutorial to get bogged down in those details, since it would detract from this tutorials purpose which is solely to educate a user on how to create a typical client server app using om-next.
Truely this tutorial is about how to use om-next in a client/server setup, somewhat agnostic to whatever the backend database of choice is.
So with those caveats declared lets look into what an om-next backend might look like.
The project for the om next backend is a git project located here, go ahead and clone it:
https://github.com/ftravers/omn1be
The project name, omn1be, is the abbreviation of Om Next version 1 Back End.
In our example we are asking if a user has supplied the correct
username password combination, and if so, to set the flag
:user/authenticated
to true
, otherwise set it to false
.
Our complete example contains more pieces than what this tutorial is aiming to teach about. Here is a word diagram about the flow and architecture of the system:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
Again here we need to be clear of where the handoff occurs from the choice of wire or transport architecture occurs and where we enter the land of om-next for the backend. Lets inspect the file layout for the project first:
╭─fenton@ss9 ~/projects ‹system› ‹master*›
╰─➤ cd omn1be
╭─fenton@ss9 ~/projects/omn1be ‹system› ‹upper-case›
╰─➤ tree src
src
`-- omn1be
|-- core.clj
|-- router.clj
`-- websocket.clj
The core.clj
file has all the information about the datomic
database. It has the schema, the testdata, etc. If you need more
help understanding how datomic works, please checkout my tutorial at:
Again, I will highlight the boundaries of the durability layer (i.e. the database), and om-next server side.
The file: websocket.clj
, is the servers side of the transport
layer. Again you could sub this out with whatever type of transport
you wanted to do.
Finally, the file: router.clj
is truely the om-next server side. If
you want to do om-next on the server side then this file will be the
most interesting for you.
Lets point out where the boundary of the server end of the transport layer to the om-next server is.
Have a look at the
Git Branch: full-working-basic-backend
To fire up the backend you could do:
$ cd omn1be; lein repl
(load "websocket")
(in-ns 'omn1be.websocket)
(start)
(in-ns 'omn1be.router)
Then to test it without our front end, we could use the “Simple Websocket Client” chrome extension.
The websocket URL end point is: ws://localhost:7890
Then we can send the following data in it:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
Here is a log of some sent requests and their response from the server:
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
{:user/authenticated true}
[(:user/authenticated
{:user/name "fenton"
:user/password "password"})]
{:user/authenticated false}
So we can see that all we are sending over the wire is an om next parameterized query.
[(:user/authenticated
{:user/name "fenton"
:user/password "passwErd"})]
A good reference for the different types of queries can be found at: Query Syntax Explained.
If we create a server side reader and parser, we can pass this query to it and it will act almost the same as the front end.
When we develop an om next backend there is a symmetry to the front end. Again we will create a reader function and create a parser with this reader function. So we pass from the transport layer, into the om-next server layer in this code:
(defn process-data [data]
(->> data
read-string
(router/parser {:database (be/db)})
prn-str))
Particularly when we call the parser
with the data we recieved. The
result of calling the parser is passed back into the transport layer.