-
Notifications
You must be signed in to change notification settings - Fork 1
Clojure in the client
- ClojureScript compiles Clojure code, but instead of bytecode for the JVM, emits Javascript that can run on the browser.
- ReactJS is a Javascript framework that breaks up the client-side application into an application model and views that work upon the model to produce UI in the form of a DOM
- Om is a ClojureScript adapter to ReactJS. It guides the UI to become a set of stateless functions that transact only on the mutable application state.
The easiest way to create an Om project is to use the mies-om
template, as follows:
lein new mies-om clj-stack
Browsing through the files, project.clj
looks like this:
(defproject clj-stack "0.1.0-SNAPSHOT"
:description "FIXME: write this!"
:url "http://example.com/FIXME"
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2755"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
[org.omcljs/om "0.8.8"]]
:plugins [[lein-cljsbuild "1.0.4"]]
:source-paths ["src" "target/classes"]
:clean-targets ["out/clj_stack" "out/clj_stack.js"]
:cljsbuild {
:builds [{:id "clj-stack"
:source-paths ["src"]
:compiler {
:output-to "out/clj_stack.js"
:output-dir "out"
:optimizations :none
:cache-analysis true
:source-map true}}]})
-
core.async
is included as a dependency, because it helps in asynchronous communication between UI components, as we shall see later - The
cljsbuild
specifies the source-path and thejs
file to which the source needs to be compiled to
Let's build the project with:
lein cljsbuild auto clj-stack
This build process compiles the ClojureScript code to Javascript files each time they are edited. The Javscript files are in turn referenced in index.html
. Now, when you open the project's index.html
in your browser, you'll see a cheery greeting of "Hello World!"
Now that we have something simple working, let's try and build an actual UI. How about a page that displays a list of videos, monitors usage and analyses patterns from it? We need to start by defining the application model. Look into core.cljs
:
(ns clj-stack.core
(:require [om.core :as om]
[om.dom :as dom]))
(enable-console-print!)
(def app-model (atom {:text "Hello world!"}))
(om/root
(fn [app owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text app)))))
app-model
{:target (. js/document (getElementById "app"))})
om/root
ties together the following:
-
app-model
defines a mutable data structure that acts as the application model. This model is updated by defined transactions - DOM container element in the page (look into
index.html
for an element with idapp
) - An adapter function that converts the application state into DOM elements which can then be rendered into the above container
So, in order to get closer to our goal of building YouTube, we need to
- Change the application model to something like this:
(def app-model (atom {:videos
[{:id 1 :title "Intro to Datomic" :url "http://www.youtube.com/embed/RKcqYZZ9RDY"}
{:id 2 :title "The Functional Final Frontier" :url "http://www.youtube.com/embed/DMtwq3QtddY"}]}))
- Modify the adapter function to convert each video into a list-item and bundle them together into a list:
(fn [app owner]
(reify om/IRender
(render [_]
(apply dom/ul nil
(map #(dom/li nil (:title %)) (:videos app))))))
There are a couple of points worth noting here:
- The function is starting to resemble a HTML template, but it is actual working code. This allows you to go beyond any template language and use all the power of ClojureScript to construct the DOM structure
- The
reify
function constructs an Object implementing said interfaces. In this case it is returning a UI component which is capable ofrender
ing. - It is easy to see how this can get messy. In the next section, we'll see how to organize the components in a hierarchy aligned with the actual view.
It is possible to logically separate each part of the view into a "Component" function, which returns an Object implementing IRender
. This component function is also provided the part of the application model that it needs to work with. For eg, we factor out the app-view
into a videos-view
, which contains multiple video-views
:
(ns clj-stack.core
(:require [om.core :as om]
[om.dom :as dom]))
(enable-console-print!)
(def app-model (atom {:videos
[{:id 1 :title "Intro to Datomic" :url "http://www.youtube.com/embed/RKcqYZZ9RDY"}
{:id 2 :title "The Functional Final Frontier" :url "http://www.youtube.com/embed/DMtwq3QtddY"}]}))
(defn video-view [video owner]
(reify om/IRender
(render [_]
(dom/li nil
(dom/a #js {:href (:url video)} (:title video))))))
(defn videos-view [videos owner]
(reify om/IRender
(render [_]
(apply dom/ul nil
(om/build-all video-view videos)))))
(defn app-view [model owner]
(reify om/IRender
(render [_]
(dom/div nil
(dom/h2 nil "Poor man's YouTube")
(om/build videos-view (:videos model))))))
(om/root
app-view
app-model
{:target (. js/document (getElementById "app"))})
-
om/build
takes a function (with a component interface) and applies it on the model (or a part thereof) and produces a DOM structure which can then be nested within another DOM.om/build-all
does the same for a collection of objects -
#js ..
is a reader literal (or tagged literal)for indicating that the following form needs to be parsed into a JavaScript object. It is usually used as part of the second parameter for the DOM functions and used to set attributes on the DOM node.
In order to allow users to submit videos, we need to construct a form for input of title and url. Let's build a view for it
(defn new-video-view [videos owner]
(reify om/IRender
(render [_]
(dom/div nil
(dom/input #js {:ref "new-video-title" :type "text" :placeholder "Title"})
(dom/input #js {:ref "new-video-url" :type "text" :placeholder "URL"})
(dom/button #js {:onClick #(add-video videos owner)} "Add")))))
- We use the
#js
reader literal to introduce attributes in the DOM nodes eg.type
-
ref
indicates the node's id and will be used later to find the user-provided values - Event handlers can be easily incorporated the same way. Here, we respond to the button's click by calling a function
add-video
(defn add-video [videos owner]
(let [title (-> (om/get-node owner "new-video-title") .-value)
url (-> (om/get-node owner "new-video-url") .-value)]
(om/transact! videos #(conj % {:id (rand-int 1000) :title title :url url}))
(println "app-model: " @app-model)))
Here, videos
is part of the application model but is not just a simple vector. It is what is referred to as a 'Cursor' - a "pointer" into the application model. This allows updates at that point using transact!
and a transforming function. In add-video
, we simply add a new video to the list of existing videos. The new-video-view
will be functional when you add the following line to app-view
(om/build new-video-view (:videos model))
Refresh the browser and try adding a video. Open the browser console and you will be able to see the updated value of app-model
being printed. The only hitch is that the input elements are not being cleared after the addition of a video
We could always use the ref
value of the nodes to clear the value once the video is added. Instead, let's try a slightly complicated route to demonstrate how we can maintain application state that is local to a component. Here's a modified version of new-video-view
:
(defn new-video-view [videos owner]
(reify
om/IInitState
(init-state [_] {:new-video-name "" :new-video-url ""})
om/IRenderState
(render-state [_ state]
(dom/div nil
(dom/input #js {:ref "new-video-title" :type "text" :placeholder "Title"
:value (:new-video-name state)
:onChange #(om/set-state! owner :new-video-name (.. % -target -value))})
(dom/input #js {:ref "new-video-url" :type "text" :placeholder "URL"
:value (:new-video-url state)
:onChange #(om/set-state! owner :new-video-url (.. % -target -value))})
(dom/button #js {:onClick #(add-video videos owner)} "Add")))))
- Firstly, we implement another interface called
IInitState
through which we provide an initial application state (both values to "") - Instead of
IRender
, we implementIRenderState
which allows the application state to be passed down during rendering. At this point, eachinput
element reads its initial value from the application state As the user changes the value of the input, the new value is updated in the application state
Now, it's easy to clear the input
controls. We simply need to reset the application state in add-video
using the following:
(om/set-state! owner :new-video-name "")
(om/set-state! owner :new-video-url "")
This seems like a simple task because we just need to add a button
to video-view
. But the real problem is that the application model within video-view
is only the particular video. We need access to the parent vector which holds this video, in order to delete it.
Here, we'll use the strategy of informing the parent component about this video being deleted. This will demonstrate how different components can correspond with each other asynchronously. The required ingredients are:
- Include
cljs.core.async
in requirements:
(ns clj-stack.core
(:require-macros [cljs.core.async.macros :refer [go]])
(:require [om.core :as om]
[om.dom :as dom]
[cljs.core.async :refer [chan put! <!]]))
- A
channel
is an abstraction over asynchronous callbacks. Here,videos-view
creates a channel and passes it down tovideo-view
by merging it with the initial application state. It also waits on the channel and removes all videos that come through it.go
helps set up this event loop.
(defn videos-view [videos owner]
(reify
om/IInitState
(init-state [_] {:delete_channel (chan)})
om/IWillMount
(will-mount [_]
(let [delete_channel (om/get-state owner :delete_channel)]
(go (loop []
(let [del-video (<! delete_channel)]
(om/transact! videos
(fn [vs] (vec (remove #(= % del-video) vs))))
(recur))))))
om/IRenderState
(render-state [_ {:keys [delete_channel]}]
(apply dom/ul nil
(om/build-all video-view videos
{:init-state {:delete_channel delete_channel}})))))
- Lastly, we set up
video-view
to receive the delete channel through its application state andput!
the video to be deleted into the channel when the button is clicked
(defn video-view [video owner]
(reify om/IRenderState
(render-state [_ {:keys [delete_channel]}]
(dom/li nil
(dom/a #js {:href (:url video)} (:title video))
(dom/button #js {:onClick #(put! delete_channel @video)} "Delete")))))
Bootstrap is an easy way to make things look pretty. Add this snippet to index.html
to quickly include Bootstrap in this project:
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap-theme.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
Code at this point
Next up Clojure in the server