Develop ClojureScript SPA with combination of integrant and re-frame
In this post, I'll introduce how to develop ClojureScript Single Page Application by using the combination of integrant and re-frame.
I also introduced that in the previous post. But as the previous example was special case, Ethereum DApp, it was a little complex to understand. So I develop an example again in pure cljs.
https://github.com/223kazuki/re-integrant-app
I tentatively call this pattern "re-integrant".
Overview
The SPA developed in this application pattern consists of three layers.
- Integrant layer that manages the whole lifecycle of the application.
- Re-frame layer that manages app-db that is updated by user interaction.
- Reagent layer represents view that subscribes and dispatches app-db via re-frame handlers.
And the application is divided into modules by integrant. Re-frame handlers are registered in each modules' namespaces when the modules initialize.
Project Structure
I adopted the similar structure to the server side integrant application like duct template. And I created dev directory to manage development settings.
.
├── project.clj
├── dev
│ ├── resources
│ │ └── dev.edn
│ └── src
│ └── user.cljs
├── resources
│ ├── config.edn
│ └── public
│ ├── css
│ │ └── site.css
│ └── index.html
└── src
└── re_integrant_app
├── core.cljs
├── module
│ ├── app.cljs
│ ├── moment.cljs
│ └── router.cljs
├── utils.cljc
└── views.cljs
project.clj
The versions of primary libraries are bellow.
[org.clojure/clojure "1.9.0"]
[org.clojure/clojurescript "1.10.339"]
[reagent "0.8.0"]
[re-frame "0.10.5"]
[integrant "0.7.0"]
The build settings of ClojureScript in each profiles are bellow. Figwheel executes cljs.user/reset
on jsload during development.
:cljsbuild
{:builds
[{:id "dev"
:source-paths ["src" "dev/src"]
:figwheel {:on-jsload cljs.user/reset}
:compiler {:main cljs.user
:output-to "resources/public/js/compiled/app.js"
:output-dir "resources/public/js/compiled/out"
:asset-path "js/compiled/out"
:source-map-timestamp true
:preloads [devtools.preload
day8.re-frame-10x.preload]
:closure-defines {"re_frame.trace.trace_enabled_QMARK_" true
"day8.re_frame.tracing.trace_enabled_QMARK_" true}
:external-config {:devtools/config {:features-to-install :all}}}}
{:id "min"
:source-paths ["src"]
:compiler {:main re-integrant-app.core
:output-to "resources/public/js/compiled/app.js"
:optimizations :advanced
:closure-defines {goog.DEBUG false}
:pretty-print false}}
{:id "test"
:source-paths ["src" "test"]
:compiler {:main re-integrant-app.runner
:output-to "resources/public/js/compiled/test.js"
:output-dir "resources/public/js/compiled/test/out"
:optimizations :none}}]}
config.edn
I added :module/moment
that provides Moment in each seconds.
{:re-integrant-app.module/moment {}
:re-integrant-app.module/router
["/" {"" :home
"about" :about}]
:re-integrant-app.module/app
{:mount-point-id "app"
:routes #ig/ref :re-integrant-app.module/router
:moment #ig/ref :re-integrant-app.module/moment}}
Module
It doesn't do anything different from what I introduced in the last post. But I wrote process explicitly for the sake of ease. I added multimethods, reg-sub and reg-event to that we can add the implementation of handlers. We can register all of handlers by using it when the module initializes. The implementation of ::now
subscription means that it provides Moment object in each seconds only when it's subscribed. (Please refer to Subscribing to External Data)
;; Initial DB
(def initial-db {::now nil})
;; Subscriptions
(defmulti reg-sub identity)
(defmethod reg-sub ::now [k]
(re-frame/reg-sub-raw
k (fn [app-db _]
(let [close (create-loop #(re-frame/dispatch [::fetch-now]) 1000)]
(reagent.ratom/make-reaction
#(get-in @app-db [::now])
:on-dispose close)))))
;; Events
(defmulti reg-event identity)
(defmethod reg-event ::init [k]
(re-frame/reg-event-db
k [re-frame/trim-v]
(fn-traced
[db _]
(-> db
(merge initial-db)
(assoc ::now (js/moment))))))
(defmethod reg-event ::halt [k]
(re-frame/reg-event-db
k [re-frame/trim-v]
(fn-traced
[db _]
(->> db
(filter #(not= (namespace (key %)) (namespace ::x)))
(into {})))))
(defmethod reg-event ::fetch-now [k]
(re-frame/reg-event-db
k [re-frame/trim-v]
(fn-traced
[db _]
(js/console.log "tick!")
(assoc db ::now (js/moment)))))
;; Init
(defmethod ig/init-key :re-integrant-app.module/moment
[k {:keys [:dev]}]
(js/console.log (str "Initializing " k))
(when dev (js/console.log "It's dev mode."))
(let [subs (->> reg-sub methods (map key)) ;; Get the keywords of handlers.
events (->> reg-event methods (map key))] ;; Same as above.
(->> subs (map reg-sub) doall) ;; Execute multimethod and register handlers.
(->> events (map reg-event) doall) ;; Same as above.
(re-frame/dispatch-sync [::init])
{:subs subs :events events}))
;; Halt
(defmethod ig/halt-key! :re-integrant-app.module/moment
[k {:keys [:subs :events]}] ;; Get the keywords of handlers.
(js/console.log (str "Halting " k))
(re-frame/dispatch-sync [::halt])
(->> subs (map re-frame/clear-sub) doall) ;; Clear handlers.
(->> events (map re-frame/clear-event) doall)) ;; Same as above.
View
It's not different from what was in the previous post. Only when home-panel which is subscribing ::moment/now
opens, :module/moment
provides Moment objects.
(defn home-panel []
(let [now (re-frame/subscribe [::moment/now])]
(fn []
[:div
[sa/Segment
[:h2 "Now"]
(when-let [now @now]
(str now))]])))
(defn about-panel []
(fn [] [:div "About"]))
(defn none-panel []
[:div])
(defmulti panels identity)
(defmethod panels :home-panel [] #'home-panel)
(defmethod panels :about-panel [] #'about-panel)
(defmethod panels :none [] #'none-panel)
(def transition-group
(reagent/adapt-react-class js/ReactTransitionGroup.TransitionGroup))
(def css-transition
(reagent/adapt-react-class js/ReactTransitionGroup.CSSTransition))
(defn app-container []
(let [title (re-frame/subscribe [:re-integrant-app.module.app/title])
active-panel (re-frame/subscribe [::router/active-panel])]
(fn []
[:div
[sa/Menu {:fixed "top" :inverted true}
[sa/Container
[sa/MenuItem {:as "span" :header true} @title]
[sa/MenuItem {:as "a" :href "/"} "Home"]
[sa/MenuItem {:as "a" :href "/about"} "About"]]]
[sa/Container {:className "mainContainer" :style {:margin-top "7em"}}
(let [panel @active-panel]
[transition-group
[css-transition {:key panel
:classNames "pageChange" :timeout 500 :className "transition"}
[(panels panel)]]])]])))
core.cljs
It's also not changed so much. You need to require all modules because we can't use integrant's load-namespaces in ClojureScript. And I defined config as an atom because I want to change it during development.
(ns re-integrant-app.core
(:require [integrant.core :as ig]
[re-integrant-app.module.app]
[re-integrant-app.module.router]
[re-integrant-app.module.moment])
(:require-macros [re-integrant-app.utils :refer [read-config]]))
(defonce system (atom nil))
(def config (atom (read-config "config.edn")))
(defn start []
(reset! system (ig/init @config)))
(defn stop []
(when @system
(ig/halt! @system)
(reset! system nil)))
(defn ^:export init []
(start))
dev.edn
It's the development setting. I set :dev true
in :module/moment
to check if it's reflected.
{:re-integrant-app.module/moment {:dev true}}
user.cljs
It's the main namespace during development. It loads dev.edn and merge it to core/config. Figwheel call reset
on jsload.
(ns cljs.user
(:require [re-integrant-app.core :refer [system config start stop]]
[meta-merge.core :refer [meta-merge]])
(:require-macros [re-integrant-app.utils :refer [read-config]]))
(enable-console-print!)
(println "dev mode")
(swap! config #(meta-merge % (read-config "dev.edn")))
(defn reset []
(stop)
(start))
Development
You can start Figwheel server and open cljs repl by following command. When you save the code, Figwheel detect that, build it and reflect it to browser.
% lein dev
Figwheel: Cutting some fruit, just a sec ...
Figwheel: Validating the configuration found in project.clj
Figwheel: Configuration Valid ;)
Figwheel: Starting server at http://0.0.0.0:3449
Figwheel: Watching build - dev
Figwheel: Cleaning build - dev
Compiling build :dev to "resources/public/js/compiled/app.js" from ["src" "dev/src"]...
Successfully compiled build :dev to "resources/public/js/compiled/app.js" in 29.414 seconds.
Figwheel: Starting CSS Watcher for paths ["resources/public/css"]
Launching ClojureScript REPL for build: dev
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
(figwheel.client/set-autoload false) ;; will turn autoloading off
(figwheel.client/set-repl-pprint false) ;; will turn pretty printing off
Switch REPL build focus:
:cljs/quit ;; allows you to switch REPL to another build
Docs: (doc function-name-here)
Exit: :cljs/quit
Results: Stored in vars *1, *2, *3, *e holds last exception object
Prompt will show when Figwheel connects to your application
[Rebel readline] Type :repl/help for online help info
ClojureScript 1.10.339
dev:cljs.user=>
And as I created user.cljs as main namespace, we can get config, rewrite it and reset system in repl.
dev:cljs.user=> @config
{:re-integrant-app.module/moment {:dev true},
:re-integrant-app.module/router ["/" {"" :home, "about" :about}],
:re-integrant-app.module/app
{:mount-point-id "app",
:routes {:key :re-integrant-app.module/router},
:moment {:key :re-integrant-app.module/moment}}}
dev:cljs.user=> (swap! config update-in [:re-integrant-app.module/moment :dev] not)
{:re-integrant-app.module/moment {:dev false},
:re-integrant-app.module/router ["/" {"" :home, "about" :about}],
:re-integrant-app.module/app
{:mount-point-id "app",
:routes {:key :re-integrant-app.module/router},
:moment {:key :re-integrant-app.module/moment}}}
dev:cljs.user=> (reset)
{:re-integrant-app.module/moment
{:subs (:re-integrant-app.module.moment/now),
:events
(:re-integrant-app.module.moment/init
:re-integrant-app.module.moment/halt
:re-integrant-app.module.moment/fetch-now)},
:re-integrant-app.module/router
{:subs
(:re-integrant-app.module.router/active-panel
:re-integrant-app.module.router/route-params),
:events
(:re-integrant-app.module.router/init
:re-integrant-app.module.router/halt
:re-integrant-app.module.router/go-to-page
:re-integrant-app.module.router/set-active-panel),
:router
{:history #object[pushy.core.t_pushy$core31222],
:routes ["/" {"" :home, "about" :about}]}},
:re-integrant-app.module/app
{:subs (:re-integrant-app.module.app/title),
:events
(:re-integrant-app.module.app/init
:re-integrant-app.module.app/halt
:re-integrant-app.module.app/set-title),
:container #object[HTMLDivElement [object HTMLDivElement]]}}
Summary
In this post, I introduced how to develop ClojureScript Single Page Application by using the combination of integrant and re-frame. Although it is a little thick stack, you can adopt it to a complicated SPA that has a lot of depedencies, has complex lifecycle and need to change settings depending on profiles.