Sunday, December 13, 2015

React & Flux Architecture with Clojurescript

React & Flux Architecture with Clojurescript


Over the last few months I’ve been using Clojure and Clojurescript for full stack development. I’m particularly taken with using Reagent, a minimal implementation of React in Clojurescript.

Install Leiningen

First we need to install the Clojure automation library leiningen, which I thoroughly recommend for using with all your Clojure projects.

Install Leiningen (copied from leiningen website)

Download the lein script (or on Windows lein.bat)
Place it on your $PATH where your shell can find it (eg. ~/bin)
Set it to be executable (chmod a+x ~/bin/lein)
Run it (lein) and it will download the self-install package

Create a project

Now we will create a Reagent project to get us started, in your terminal type

lein new reagent test_project
cd test_project

to run the project we just type

lein figwheel

Leiningen will download and install all the project dependencies (defined in packages.json), and run a hot-loaded development version of your project.
You should see something like this

Retrieving lein-figwheel/lein-figwheel/0.5.0-2/lein-figwheel-0.5.0-2.pom from clojars
Retrieving figwheel-sidecar/figwheel-sidecar/0.5.0-2/figwheel-sidecar-0.5.0-2.pom from clojars
Retrieving http-kit/http-kit/2.1.19/http-kit-2.1.19.pom from clojars
Retrieving figwheel/figwheel/0.5.0-2/figwheel-0.5.0-2.pom from clojars
Retrieving figwheel-sidecar/figwheel-sidecar/0.5.0-2/figwheel-sidecar-0.5.0-2.jar from clojars
Retrieving lein-figwheel/lein-figwheel/0.5.0-2/lein-figwheel-0.5.0-2.jar from clojars
Retrieving http-kit/http-kit/2.1.19/http-kit-2.1.19.jar from clojars
Retrieving figwheel/figwheel/0.5.0-2/figwheel-0.5.0-2.jar from clojars
Retrieving org/clojure/core.async/0.2.374/core.async-0.2.374.pom from central
Retrieving org/clojure/tools.analyzer.jvm/0.6.9/tools.analyzer.jvm-0.6.9.pom from central
Retrieving org/clojure/tools.analyzer/0.6.7/tools.analyzer-0.6.7.pom from central
Retrieving org/clojure/core.memoize/0.5.8/core.memoize-0.5.8.pom from central
Retrieving org/clojure/core.cache/0.6.4/core.cache-0.6.4.pom from central
Retrieving org/clojure/data.priority-map/0.0.4/data.priority-map-0.0.4.pom from central
Retrieving org/clojure/tools.reader/1.0.0-alpha1/tools.reader-1.0.0-alpha1.pom from central
Retrieving org/clojure/tools.analyzer/0.6.7/tools.analyzer-0.6.7.jar from central
Retrieving org/clojure/tools.analyzer.jvm/0.6.9/tools.analyzer.jvm-0.6.9.jar from central
Retrieving org/clojure/core.cache/0.6.4/core.cache-0.6.4.jar from central
Retrieving org/clojure/core.memoize/0.5.8/core.memoize-0.5.8.jar from central
Retrieving org/clojure/core.async/0.2.374/core.async-0.2.374.jar from central
Retrieving org/clojure/data.priority-map/0.0.4/data.priority-map-0.0.4.jar from central
Figwheel: Starting server at http://localhost:3449
Figwheel: Watching build - app
Compiling "target/cljsbuild/public/js/app.js" from ("src/cljs" "src/cljc" "env/dev/cljs")...
Successfully compiled "target/cljsbuild/public/js/app.js" in 9.736 seconds.
Figwheel: Starting CSS Watcher for paths  ["resources/public/css"]
Figwheel: Starting nREPL server on port: 7002
Launching ClojureScript REPL for build: app
Figwheel Controls:
          (stop-autobuild)                ;; stops Figwheel autobuilder
          (start-autobuild [id ...])      ;; starts autobuilder focused on optional ids
          (switch-to-build id ...)        ;; switches autobuilder to different build
          (reset-autobuild)               ;; stops, cleans, and starts autobuilder
          (reload-config)                 ;; reloads build config and resets autobuild
          (build-once [id ...])           ;; builds source one time
          (clean-builds [id ..])          ;; deletes compiled cljs target files
          (print-config [id ...])         ;; prints out build configurations
          (fig-status)                    ;; displays current state of system
  Switch REPL build focus:
          :cljs/quit                      ;; allows you to switch REPL to another build
    Docs: (doc function-name-here)
    Exit: Control+C or :cljs/quit
 Results: Stored in vars *1, *2, *3, *e holds last exception object
Prompt will show when Figwheel connects to your application
To quit, type: :cljs/quit
cljs.user=>

Now open your browser to http://localhost:3449 to view your new reagent project.
The command above has generated boiler-plate code for our reagent project and gives us a little helping hand to get us started.

Improve the routing

So, the reagent seed project gives you one file src/cljs/test_project/core.cljs
, which contains initialization of the app, view definitions and routes. Let’s move the routing out to a separate file, to make it easier to extend. Here is what you should be starting with in core.cljs

; src/cljs/test-project/core.cljs
(ns test-project.core
  (:require [reagent.core :as reagent :refer [atom]]
            [reagent.session :as session]
            [secretary.core :as secretary :include-macros true]
            [accountant.core :as accountant]))

;; -------------------------
;; Views

(defn home-page []
  [:div [:h2 "Welcome to test_project"]
   [:div [:a {:href "/about"} "go to about page"]]])

(defn about-page []
  [:div [:h2 "About test_project"]
   [:div [:a {:href "/"} "go to the home page"]]])

(defn current-page []
  [:div [(session/get :current-page)]])

;; -------------------------
;; Routes

(secretary/defroute "/" []
  (session/put! :current-page #'home-page))

(secretary/defroute "/about" []
  (session/put! :current-page #'about-page))

;; -------------------------
;; Initialize app

(defn mount-root []
  (reagent/render [current-page] (.getElementById js/document "app")))

(defn init! []
  (accountant/configure-navigation!)
  (accountant/dispatch-current!)
  (mount-root))

So let’s trim down the core so you just have

; src/cljs/test-project/core.cljs
(ns test-project.core
  (:require [reagent.core :as reagent :refer [atom]]
            [reagent.session :as session]
            [secretary.core :as secretary :include-macros true]
            [accountant.core :as accountant]
            [test-project.routes]))

(defn current-page []
  [:div [(session/get :current-page)]])

(defn mount-root []
  (reagent/render [current-page] (.getElementById js/document "app")))

(defn init! []
  (accountant/configure-navigation!)
  (accountant/dispatch-current!)
  (mount-root))

We now need to create a file to put the routes in called routes.cljs, in the same directory as the core.cljs .. and let’s put the routing from core in there

; src/cljs/test-project/routes.cljs
(ns test-project.routes
  (:require [reagent.session :as session]
            [secretary.core :as secretary :include-macros true]
            [test-project.pages.home :refer [home-page]]
            [test-project.pages.about :refer [about-page]]))

(secretary/defroute "/" []
  (session/put! :current-page #'home-page))

(secretary/defroute "/about" []
  (session/put! :current-page #'about-page))

We’re also going to create a subfolder called pages and put the 2 view components in there, which we’ll call home.cljs and about.cljs respectively:

; src/cljs/test-project/pages/home.cljs
(ns test-project.pages.home
  (:require [reagent.core :as reagent :refer [atom]]))

(defn home-page []
  [:div [:h2 "Welcome to test_project"]
   [:div [:a {:href "/about"} "go to about page"]]])
; src/cljs/test-project/pages/about.cljs
(ns test-project.pages.about
  (:require [reagent.core :as reagent :refer [atom]]))

(defn about-page []
  [:div [:h2 "About test_project"]
   [:div [:a {:href "/"} "go to the home page"]]])

You may need to quit and restart lein figwheel to get it to find the new files in the hot-reloader. If you refresh the browser it should look and function as before.

Create a Flux store

so we have a bare-bones project that generates two pages; a home page and an about page, and it allows us to navigate between them.

We want to create a store for the app, so that our components can respond to changes in the store as a single source of truth for the app.
We use the reagent atom to store application state, it behaves like the standard clojure atom, except that when a component dereferences it, it forces a re-render of the component, as a React component does when props or state changes.
Let’s create a store in the same folder as our core called store.cljs and store a variable for the title of each page

; src/cljs/test-project/store.cljs
(ns test-project.store
  (:require [reagent.core :as reagent :refer [atom]]))

(def app-title (atom "Test Project"))

and now we can access it in our page components by requiring and dereferencing the store atom like this

; src/cljs/test-project/pages/about.cljs
(ns test-project.pages.about
  (:require [reagent.core :as reagent :refer [atom]]
            [test-project.store :refer [app-title]]))

(defn about-page []
  [:div [:h2 "About " @app-title]
   [:div [:a {:href "/"} "go to the home page"]]])

and

; src/cljs/test-project/pages/home.cljs
(ns test-project.pages.home
  (:require [reagent.core :as reagent :refer [atom]]
            [test-project.store :refer [app-title]]))

(defn home-page []
  [:div [:h2 "Welcome to " @app-title]
   [:div [:a {:href "/about"} "go to about page"]]])

Handle Flux actions properly

Now this is where we will introduce a new concept. Most implementations using Reagent will simply reset! and deref the store atoms to implement a Flux-style architecture. However, Flux requires the use of a dispatcher, which manages the sequencing of actions to the stores. We already have the functionality for the components to listen to the stores and respond to updates, but we don’t yet have a robust mechanism for sending actions to multiple stores. Let’s implement an actions mechanism by creating actions.cljs in the core folder. This doesn’t need to use reagent as it won’t be forcing any rendering in components.

; src/cljs/test-project/actions.cljs
(ns test-project.actions)

(def dispatcher (atom {}))
(def actions (atom {}))

(defn emit [action data]
  (swap! dispatcher #(merge {:action action} data)))

(defn register [action callback]
  (swap! actions conj {action callback}))

(add-watch dispatcher :watcher
  (fn [key atom old-state new-state]
    (if-let [action (get @actions (:action new-state))]
      (action new-state))))

This allows us to emit an action from anywhere in the app, by naming the action and sending a map of data that our store will use when processing the action.
So our store now needs to implement a specific action, e.g. store.cljs becomes

; src/cljs/test-project/store.cljs
(ns test-project.store
  (:require [reagent.core :as reagent :refer [atom]]
            [test-project.actions :as actions]))

(def app-title (atom "Test Project"))

(defn update-title [data]
  (reset! app-title (:value data)))

(actions/register :update-title update-title)

, which just updates the title when an action called :update-title is emitted and has a map containing the new value.

We can now call this action from a component. We’ll create an input box on the home page that sends an action to update the title when it is changed. Our home page becomes

; src/cljs/test-project/pages/home.cljs
(ns test-project.pages.home
  (:require [reagent.core :as reagent :refer [atom]]
            [test-project.store :refer [app-title]]
            [test-project.actions :as actions]))

(defn update-text [evt]
  (actions/emit :update-title {:value (-> evt .-target .-value)}))

(defn home-page []
  [:div [:h2 "Welcome to " @app-title]
    [:div [:a {:href "/about"} "go to about page"]]
    [:div "change title:"
      [:input {:value @app-title :on-change update-text}]]])

We should now be able to change the text in the input box on the home page, and dynamically see the title update on the home page, and the title should appear changed when we navigate to the about page.

No comments: