slonoed

Clojure render react

November 14, 2016 • ☕️☕️ 11 min read

What is this about?

In this tutorial I’ll show how to create simple web application with server render inside JVM and Reactjs on frontend. All code was written in clojure and clojurescript and shares UI code between backend and frontend.

I assume you already know something about clojure(script) and understand concept of reactjs. If not, you may find this links helpfull.

I use OSX for coding. If you use defferent OS you migth need to change some commands. But code itself remains the same.

You can find some links inside the text. I suggest you’ll read them briefly (I don’t include info you can find in documentation).

The idea

The app itself is very simple. It’s a list of books. You can click on the book to open a detailed view. You can click the “Add book” button to open the modal with a form.

App

The most interesting is a server side render. When you click on add button URL changes to /add. When you click on book in list URL changes to /book_name. But nothing will be fetched from the server because of clientside render. However, when you reload the page with URL /add, the server renders all html include visible form. Why so overengineering? The key is speed. Your users don’t have to to wait until all the JS code arrives at the browser. They can see page immediately. And they also don’t have to wait the page reloading when they move across your app. Also, you can obtain nice bonuses like search engine availability.

Prepare the stuff

Firstly, you need to install Java. Secondly, you need to install lein tool.

Hello world

Inside you working directory call

lein new default books
cd books

Here is the structure of the project

.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── doc
│   └── intro.md
├── project.clj
├── resources
├── src
│   └── books
│       └── core.clj
└── test
    └── books
        └── core_test.clj

Now start REPL

lein repl

And run these commands in REPL

(require 'books.core)
(book.core/foo "Best")

The output should be Best Hello, World!.

Sharpen tools

I prefer to write tools before coding. At first, add dependencies to project.clj file.

(defproject books "0.1.0-SNAPSHOT"
  :description "Books list"
  :dependencies [[org.clojure/tools.namespace "0.3.0-alpha2"
                  :exclusions [org.clojure/tools.reader]]
                 [javax.servlet/servlet-api "2.5"]
                 [http-kit "2.2.0"]
                 [compojure "1.5.1"]
                 [org.clojure/clojure "1.9.0-alpha12"]
                 [rum "0.10.7" :exclusions [cljsjs/react cljsjs/react-dom]]
                 [cljsjs/react-dom "15.3.1-0" :exclusions [cljsjs/react]]
                 [cljsjs/react-dom-server "15.3.1-0" :exclusions [cljsjs/react]]
                 [cljsjs/react-with-addons "15.3.1-0"]
                 [org.clojure/clojurescript "1.9.229"]
                 [hiccup "1.0.5"]]
  :resource-paths ["resources"]
  :plugins [[lein-figwheel "0.5.7"]]
  :figwheel {:css-dirs ["resources/public/css"]}
  :cljsbuild {:builds [{:id "exchange"
                      :source-paths ["src/"]
                      :compiler {:main "books.client"
                                 :optimizations :none
                                 :asset-path "/public/js/out"
                                 :output-to "resources/public/js/books.js"
                                 :output-dir "resources/public/js/out"}}]})

When you start, REPL Leiningen automatically downloads the dependencies.

Clojure REPL has one annoying thing – it starts long. That’s why we need the reloadable system. We will use separate namespace user to control our server in development. Just create file ./src/user.clj with code

(ns user
  (:use org.httpkit.server)
  (:require [books.core :as b]
            [clojure.tools.namespace.repl :refer [refresh]]))

(defonce server (atom nil))

(defn app [req]
    {:status  200
     :headers {"Content-Type" "text/html"}
     :body    "hello HTTP!"})

(defn go []
  (reset! server (run-server #'app {:port 8080})))

(defn reset
  []
  (when-not (nil? @server)
     (@server :timeout 100)
     (reset! server nil))
  (refresh :after 'user/go))

Simple web server

In your core.clj file add

(ns books.core)

(defn app
  [req]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    "hello HTTP!"})

Now start REPL and call (reset). It should start web server on port 8080.

Hello http browser image

From this point you can use code reload without restarting REPL. Try to change response body from hello HTTP! to hello clojure! and call (reset) inside REPL.

Hello clojure browser image

Routing

http-kit plays well with Compojure. We need few server routes.

  • books list route
  • book route
  • add book form route
  • add book route
  • static files route
  • not found route
;; src/books/core.clj
(ns books.core
  (:use [compojure.route :only [files not-found]]
        [compojure.handler :only [site]]
        [compojure.core :only [defroutes GET POST DELETE ANY context]]
        org.httpkit.server))

; App route
(defn render
  [req]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    "hello clojure!"})

(defn add-book
  [req]
  {:status 201})

(defroutes all-routes
  (GET "/" [] render)
  (GET "/:route" [] render)
  (POST "/" [] add-book)
  (files "/public/" {:root "resources/public"})
  (not-found "Page not found"))

(def app (site all-routes))

Prepare fronted

Now we need to setup frontend part (javascript + css). Create file resources/public/css/books.css and put some css there (I don’t wont to cover css styling here, this part is up to you). Now prepare page HTML with hiccup. Add code to render method.

(defn render
  [req]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    (hiccup/html
              "<!doctype html>"
              [:html
               [:head
                [:link {:rel "stylesheet" :media "screen"
                        :href  "/public/css/books.css"}]]
               [:body
                [:div#application
                 "PLACE FOR GENERATED HTML"]
                [:script {:src "/public/js/books.js"}]
                [:script
                 "books.client.init()"]]])})

Then create file src/books/client.cljs (note file extension cljs) with content

(ns books.client)

(defn init
  []
  (js/console.log "App ready"))

Now call (reset) in REPL and start different REPL for clojurescript build.

lein figwheel

If you reload browser you will see “App ready” message in browser console. Сongratulations! Now we can write some code (:

Books list

We will store all books is one vector (wrapped in atom). Each item is the vector with title, year, author. Place this code in core.src files after ns form.

(defonce books (atom [["The Old Man and the Sea" 1952 "Ernest Hemingway"]
                      ["Hyperion" 1989 "Dan Simmons"]
                      ["The 7 Habits of Highly Effective People" 1989 "Stephen Covey"]]))

Components and server render

A component can be taken from closure and closurescript. To achieve it we’ll use reader conditional. In short: this language feature allows you to mark some code forms only for each or other language. Create file src/books/components.cljc (note cljc extension) with this code.

(ns books.components
  (:require [rum.core :as r]))

(r/defc link [[title year author]]
  [:li {:key title}
   [:a {:href title} title]])

(r/defc books-list [books]
  [:.list
   [:ul
    (map link books)]
   [:a.btn {:href "/add"} "Add book"]])

(r/defc details [[title year author]]
  [:.details
   [:a {:href "/"} "List"]
   [:label "Title"] title
   [:label "Author"] author
   [:label "Year"] year])

(r/defc add-form []
  [:.modal
   [:form {:action "/" :method "POST"}
    [:input {:type "text" :name "title"  :placeholder "Title"}]
    [:input {:type "text" :name "author" :placeholder "Author"}]
    [:input {:type "text" :name "year"   :placeholder "Year"}]
    [:button.btn {:type "submit"} "Add"]
    [:a.btn {:href "/"} "Cancel"]]])

(r/defc app [{:keys [books route]}]
  [:.page
   ;; If route match any book title - show detailed view
   ;; else - show list
   (if-let [book (some #(when (= route (first %)) %) books)]
     (details book)
     (books-list books))
   ;; If route is "add" – render additional modal window with form
   (when (= route "add")
     (add-form))])

In core.clj use rum.core/render-html to render this component. And update code of add-book function. It should receive form data, write to books atom and redirect to list.

Updated code:

(ns books.core
  (:require [hiccup.core :as hiccup]
            [rum.core :as r]
            [books.components :as c])
  (:use [compojure.route :only [files not-found]]
        [compojure.handler :only [site]]
        [compojure.core :only [defroutes GET POST DELETE ANY context]]
        org.httpkit.server))

(defonce books (atom [["The Old Man and the Sea" 1952 "Ernest Hemingway"]
                      ["Hyperion" 1989 "Dan Simmons"]
                      ["The 7 Habits of Highly Effective People" 1989 "Stephen Covey"]]))

(defn render
  [\{\{route :route} :route-params}]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    (hiccup/html
              "<!doctype html>"
              [:html
               [:head
                [:meta {:charset "utf-8"}]
                [:link {:rel "stylesheet" :media "screen"
                        :href  "/public/css/books.css"}]]
               [:body
                [:div#application
                 (r/render-html (c/app {:route route
                                        :books @books}))]
                [:script {:src "/public/js/books.js"}]
                [:script
                 "books.client.init()"]]])})

(defn add-book
  [\{\{:strs [title author year]} :form-params :as req}]
  (swap! books conj [title year author])
  {:status 303
   :headers {"Location" "/"}})

(defroutes all-routes
  (GET "/" [] render)
  (GET "/:route" [] render)
  (POST "/" [] add-book)
  (files "/public/" {:root "resources/public"})
  (not-found "Page not found"))

(def app (site all-routes))

Don’t forget to write some styles in resources/public/css/books.css. You can get some from repo Call (reset) in clojure REPL and refresh browser page. Now we get simple web app that works without javascript.

Add client logic

To sync html from server and react DOM tree in browser we need to pass all the state that we use for rendering html to browser. And then apply this state to reactjs tree. To simplify work with links I handle all link in one handler. In real app you probably want to create separate actions. Create file src/controllers.cljs with this content:

(ns books.controllers
  (:import [goog.history Html5History EventType]
           [goog History]
           [goog.net XhrIo]
           [goog.date Date]))

(defonce state nil)
(defonce history nil)


(defn local-link?
  "Check DOM node is 'A' link to local address
  Like <a href='/add'>link</a>
  "
  [a]
  (and (= (.-tagName a) "A")
       (goog.string/startsWith (.-href a)
                               (str
                                 js/location.protocol
                                 "//"
                                 js/location.host))))


(defn handle-url-change
  "set :route field in state when routing changed"
  [e]
  (let [t (.slice (.-token e) 1)
        t' (js/decodeURIComponent t)]
    (swap! state assoc :route t')))


(defn start!
  "Start function. Expects state map"
  [state']
  (set! state state')
  ;; Setup history
  (set! history (Html5History.))
  (doto history
    (.setPathPrefix "")
    (.setUseFragment false)
    (goog.events/listen EventType.NAVIGATE
                        #(handle-url-change %))
    (.setEnabled true))

;; Small hack. When use click link on page we check is this same domain link
;; and instead reloading page only change history
(goog.events/listen js/document.body "click"
                    (fn [e]
                      (when (local-link? (.-target e))
                        (.preventDefault e)
                        (.setToken history (.. e -target -pathname))))))


(defn add-book
  "Save book to server. After success redirect to list page."
  [[title year author :as book]]
  (let [esc #(js/encodeURIComponent %)
        form-data (str "title=" (esc title) "&"
                       "year=" (esc title) "&"
                       "author=" (esc author))
        callback (fn []
                   (swap! state update :books conj book)
                   (.setToken history "/"))]
    (.send XhrIo "/" callback "POST" form-data)))

In core.clj change render to provide state for client code.

(defn render
  [\{\{route :route} :route-params}]
  (let [state {:route route
               :books @books}]
    {:status  200
    :headers {"Content-Type" "text/html"}
    :body    (hiccup/html
               "<!doctype html>"
               [:html
                [:head
                 [:meta {:charset "utf-8"}]
                 [:link {:rel "stylesheet" :media "screen"
                         :href  "/public/css/books.css"}]]
                [:body
                 [:div#application
                  (r/render-html (c/app state))]
                 ;; Serialize data for client
                 [:script#initial-data {:type "application/edn"}
                  (pr-str state)]
                 [:script {:src "/public/js/books.js"}]
                 [:script
                  "books.client.init()"]]])}))

In client.cljs tie it together:

(ns books.client
  (:require [books.controllers :as ctrls]
            [books.components :as c]
            [rum.core :as r]
            [cljs.reader :as reader]))

(defn load-init-state []
  (reader/read-string (.-text (js/document.getElementById "initial-data"))))

(r/defc wrapper < r/reactive
  [s]
  (c/app (r/react s)))

(defn init
  []
  (let [init-state (atom (load-init-state))]
    (r/mount (wrapper init-state) (js/document.getElementById "application"))
    (ctrls/start! init-state)))

The last step is to put the submit handler on form (components.cljc). Note changed ns form. And form on-change property.

(ns books.components
  (:require [rum.core :as r]
            #?(:cljs [books.controllers :as c]))) ;; there are no controllers in clojure

(r/defc link [[title year author]]
  [:li
   [:a {:href (str "/" title)} title]])

(r/defc books-list [books]
  [:.list
   [:ul
    (map link books)]
   [:a.btn {:href "/add"} "Add book"]])

(r/defc details [[title year author]]
  [:.details
   [:a {:href "/"} "List"]
   [:label "Title"] title
   [:label "Author"] author
   [:label "Year"] year])

(r/defc add-form []
  [:.modal
   [:form {:action "/"
           :method "POST"
           :on-submit #?(:clj nil
                         ;; We need handler only in cljs
                         :cljs (fn [e]
                                 (.preventDefault e)
                                 (c/add-book ["Hello" 12324 "Dmitry"])))}
    [:input {:type "text" :name "title"  :placeholder "Title"}]
    [:input {:type "text" :name "author" :placeholder "Author"}]
    [:input {:type "text" :name "year"   :placeholder "Year"}]
    [:button.btn {:type "submit"} "Add"]
    [:a.btn {:href "/"} "Cancel"]]])

(r/defc app [{:keys [books route]}]
  [:.page
   ;; If route match any book title - show detailed view
   ;; else - show list
   (if-let [book (some #(when (= route (first %)) %) books)]
     (details book)
     (books-list books))
   ;; If route is "add" – render additional modal window with form
   (when (= route "add")
     (add-form))])

Tips, notes, pitfalls

You can find all code in repo.

This report shows only the basics of rum. I skip few useful topics. It’s documentation pretty good.

I didn’t mention about hot reload at all. I think you can find more in figwheel documentation.

Trick with links only for this demonstration. In a real app you should handle all the links carefully.

In a huge app direct changing state can create mess. To prevent from it you can use redux like technique. You can create core.async/chan, put any actions to it and create infinite loop to handle this action and change state.

Be careful when you use some float number functions in components. For example Math.sin in Java and Javascript looks the same, but can produce slightly different numbers. If it happens, react will rerender node.

Questions

If you have any questions or have found any mistakes in this text, please write me message in twitter.


If you have any questions or want to discuss this text, please reach me in twitter.