Skip to content

Clojure in the client

Subhash Gopalakrishnan edited this page Feb 23, 2015 · 24 revisions

Cast & Crew

  • 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.

Meet & Greet

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 the js 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!"

Raising the pitch

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 id app)
  • 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 of rendering.
  • 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.

Organizing the components

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.

Adding videos

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

Clearing up

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 implement IRenderState which allows the application state to be passed down during rendering. At this point, each input 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 "")

Deleting a video

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 to video-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 and put! 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")))))

Finishing touches

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