You are here

Server-Side and Client-Side Rendering Using the Same Code With Re-Frame

Never miss one of my articles: Readers of my newsletter get my articles before anyone else. Subscribe here!

In the last few days, I was playing around with Re-Frame. Check it out, it is awesome. Even if you're not planning to ever use clojure - Even if you're not even doing web development - At least read the Readme.md. It's the best Readme.md I've ever read!

Anyway, I was playing around with Re-Frame. And after I got the simple example running, I tried to make it do something it's not supposed to do: Render a page on the server side, using the same source code as on the client side. If that works, it would be great: You could create a single page application that renders everything on the client, but still provide basic functionality when JavaScript is switched off. And it would be great for SEO: Search engines get plain HTML and don't have to interpret JavaScript to see the content.

The things I tried basically work now, but it's not 100% usable yet. Here's my current solution, and what already works...

I will not explain how re-frame or ClojureScript work in this post, so you probably should read the re-frame Readme.md before continuing...

Simple Example Rendered on the Server

So, I started with the simple Re-Frame example and tried to render the page on the server.

With Clojure 1.7's Reader Conditionals I can now write code that can run on the server (as Clojure code on the JVM) and also on the client (ClojureScript compiled to JavaScript). Basically, everything that's in a ".cljc" file is available on the server and on the client, while ".clj" is only available on the server and ".cljs" is only available on the client.

I created the following project structure:

src
\---main
    +---clojure       <-- Clojure code to run on the server (.clj)
    +---clojurescript <-- ClojureScript code to run on the client (.cljs)
    \---crossover     <-- Crossover code to run on server and client (.cljc)

Then I simply copied the ClojureScript code from the simple example to src/main/clojurescript and created a hiccup view in src/main/clojure that created a page that looks exactly like the HTML page from the simple example. After everything was running (which required a tweak or two), I started to move stuff around.

Crossover Code

I moved all the "view" functions from the ClojureScript file to a new crossover file called "renderer.cljc". The clojurescript then should only call one function from the renderer. The blue code is unchanged, I have only changed the bold lines and removed all the "View Components":

src/main/clojurescript/simpleexample/core.cljs

(ns simpleexample.core (:require-macros [reagent.ratom :refer [reaction]]) (:require [reagent.core :as reagent] [re-frame.core :refer [register-handler path register-sub dispatch dispatch-sync subscribe]]
[simpleexample.page.renderer :as renderer]))
(def initial-state {:timer (js/Date.) :time-color "#f34"}) (defonce time-updater (js/setInterval #(dispatch [:timer (js/Date.)]) 1000)) ;; -- Event Handlers ---------------------------------------------------------- (register-handler ;; setup initial state :initialize ;; usage: (submit [:initialize]) (fn [db _] (merge db initial-state))) ;; what it returns becomes the new state (register-handler :time-color ;; usage: (submit [:time-color 34562]) (path [:time-color]) ;; this is middleware (fn [time-color [_ value]] ;; path middleware adjusts the first parameter value)) (register-handler :timer (fn ;; the first item in the second argument is :timer the second is the ;; new value [db [_ value]] (assoc db :timer value))) ;; return the new version of db ;; -- Subscription Handlers --------------------------------------------------- (register-sub :timer (fn [db _] ;; db is the app-db atom (reaction (:timer @db)))) ;; wrap the computation in a reaction (register-sub :time-color (fn [db _] (reaction (:time-color @db)))) ;; -- Entry Point ------------------------------------------------------------- (defn ^:export run [] (dispatch-sync [:initialize])
(reagent/render [renderer/simple-example]
(js/document.getElementById "app")))

Now, obviously, this didn't work out of the box. The crossover code (in the .cljc files) was missing all of the Re-Frame dependencies. Also, the hiccup collection used by Re-Frame is slightly different than what hiccup expects on the server (since Re-Frame has to process the template to handle subscriptions and other things). And, third, the ClojureScript event handler for the input field obviously doesn't work when rendering on the server.

I could provide the missing dependencies for ClojureScript with a reader conditional, but I'd also have to provide an alternative implementation when compiling as Clojure code. Fortunately, this is quite easy. Then I created a function that translates from a Re-Frame hiccup collection to a regular hiccup collection, that is only used on the server (but I need it only in the server-side hiccup view - see later). And third, I decided to use an empty event handler function when rendering on the server.

src/main/crossover/simpleexample/page/renderer.cljc

(ns simpleexample.page.renderer
;; Import Re-Frame dependencies when compiling as ClojureScript
  #?(:cljs (:require [re-frame.core :refer [dispatch-sync subscribe]])))

;; Use this app-db instead of the Re-Frame app-db when compiling as Clojure
#?(:clj (def app-db
          (atom {:time-color (atom "#77f")
                 :timer (atom "--.--.----")})))

;; Use this Re-Frame's "subscribe" when compiling as Clojure
#?(:clj (defn- subscribe [v]
          (get @app-db (first v))))

;; -- View Components ---------------------------------------------------------
(defn greeting
  [message]
  [:h1 message])


(defn clock
  []
  (let [time-color (subscribe [:time-color])
        timer (subscribe [:timer])]

    (fn clock-render
      []
      (let [time-str (str @timer)
            style {:style {:color @time-color}}]
        [:div.example-clock style time-str]))))


(defn color-input
  []
  (let [time-color (subscribe [:time-color])]

    (fn color-input-render
      []
      [:div.color-input
       "Time color: "
       [:input {:type "text"
                :value @time-color
                ;; Use empty on-change handler when compiling to Clojure
                :on-change #?(:clj  ""
                              :cljs #(dispatch-sync
                                       [:time-color (-> % .-target .-value)]))}]])))

(defn simple-example
  []
  [:div
   [greeting "Hello world, it is now"]
   [clock]
   [color-input]])

And here's how I translate (simple-example) on the server to create the static content:

src/main/clojure/simpleexample/page/view.clj

(ns exampilistic.wiki.page.views
  (:require
    [hiccup
     [page :refer [html5]]
     [element :refer [javascript-tag]]
     [page :refer [include-js]]]
    [exampilistic.wiki.page.renderer :as renderer]))

;; Create a regular hiccup collection from a Re-Frame hiccup collection
(declare reframe>hiccup)

(defn- map-reframe>hiccup
  [reframe-hiccup-element]
  (cond (keyword? reframe-hiccup-element) reframe-hiccup-element
        (map? reframe-hiccup-element) reframe-hiccup-element
        (vector? reframe-hiccup-element) (if (fn? (first reframe-hiccup-element))
                       (let [result (apply (first reframe-hiccup-element) (rest reframe-hiccup-element))]
                         (if (fn? result)
                           (apply result [])
                           result))
                       (reframe>hiccup reframe-hiccup-element))
        :else (throw (IllegalStateException. (str "Illegal element " reframe-hiccup-element)))))

(defn- reframe>hiccup
  [reframe-elements]
  (into []
        (map map-reframe>hiccup reframe-elements)))

;; create the hiccup collections that represent the static page
(defn show-page [page-name]
  (html5
    [:head
     [:title "Replace me"]]
    [:body
     [:div {:id :app}
      (reframe>hiccup (renderer/simple-example))]
     (include-js "/js/goog/base.js")
     (include-js "/js/main/main.js")
     (javascript-tag "goog.require(\"simpleexample.core\"); window.onload = function () { simpleexample.core.run(); }")]))

Conclusion

Creating the static HTML on the server is already working. Just uncomment "window.onload = ..." in show-page and you'll still see the correct content on the client - But without the dynamic behavior. And the server side rendering uses (almost) exactly the same code as the client side rendering - So we can be sure to see (almost) the same page as if the client would have rendered it - Except for the date, which will only be rendered by the client.

What's still missing is that the server should make sure that the client uses the same values in it's app-db as the server used for rendering the static content. I think this is not that hard, I just didn't implement it yet.

Another inconvenience is that the server will render the whole page again in window.onload, throwing away anything the server rendered. I was thinking, maybe I could only re-render everything on the first user interaction. I.e., only when the user clicks a link, start Re-Frame and render the new state...

Maybe I'll package up everything as a small library when I get it working. I could also create a pull request for Re-Frame, but right now I think a standalone library (e.g. Re-Frame-Server) would be better...

Feedback Welcome

What do you think about this solution? Do you think I can solve the remaining problems? Will it be usable in bigger applications, or will I hit a wall somewhere? Do you have any questions? Please Contact Me!

Are you interested in managing IT and software projects or teams? Are you in Europe in Autumn 2016? We are preparing a great conference for you: Advance IT Conference

Posting Type: 
Connect with David Tanzer: Send me an email: Business@DavidTanzer.net
A curated list of posts with summary information can be found here: My Favorite Posts.
RSS-Feed