Transitions and Effects

Frontend ClojureScript App Architecture

Update: Storefront is now public! You can check out the source on github.

We recently started extracting the frontend to be rewritten in ClojureScript. It’s been fun to explore the new architecture patterns that Om/React enables.

When exploring the viability of ClojureScript, we looked at CircleCI’s frontend. CircleCI’s frontend is valuable reference to see how a large ClojureScript application is structured. It’s a great, living architecture used in production. I’d recommend Brandon Bloom’s ClojureWest Talk for an overview from the CircleCI frontend team.

Adapting the Design

We ended up adopting a similar design with a few adaptations. We decided to restrict the design further than CircleCI’s:

  • Have only one, mutable app state atom
    • No component state
    • No om/get-shared
    • No separate atoms for tracking UI states
  • Have only one channel where events get enqueued to the application.
  • Separate more of the capabilities of CircleCI’s controller concept.

The motivations behind this are focused around minimizing state and reduce the number of abstractions. Reducing the amount of state indirectly affects complexity of software as less scattered state is easier to debug. And reducing the number of design patterns streamlines understanding the code base. Code should have obvious rules of organization to avoid code soup. Not surprisingly, we prefer to think a little on patterns ahead of time instead of inventing new ones every time a barrier is in the way.

Core to this design is centralizing channels to one queue: the events channel. This abstracts the method of interaction from the core of the application. It also makes reloading the application via figwheel easier.

The application layout is roughly this, with many ideas borrowed from CircleCI:

  • components. Not much different here. UI actions enqueue to the events channel.
  • api adapters talk to the backend. Success and failures enqueue to the events channel.
  • state defines the application state and…
  • keypaths for lookup inside the application state (usually using get-in)
  • events defines all the messages the application can recieve on the event channel. They are similar to state lookup paths: as a vector of keywords.
  • transitions define how to change the application state for a given message. It’s the side-effect-free part of CircleCI’s controllers.
  • effects is where dequeuing events can trigger side effects. This namespace will call functions in api to trigger API side effects or use browser cookies.
  • core bootstraps the application. Calls transition and effects.
  • A few other namespaces that are effectively models (domain-specific, derived computation from the application state).

Let’s look in more depth at the core design of the web application.

The Run Loop

core.cljs is small, but it describes the application at a high level:

;; perform immutable state transitions
;; transition-state is defined in transitions.clj
(defn transition [app-state [event args]]
  (reduce #(transition-state %2 event args %1)
          (reductions conj [] event)))

;; perform side effects.
;; perform-effects is defined in effects.clj
(defn effects [app-state [event args]]
  (doseq [event-fragment (reductions conj [] event)]
    (perform-effects event-fragment event args app-state)))

;; start the event loop to process all events.
(defn start-event-loop [app-state-atom]
  (let [event-ch (get-in @app-state-atom keypaths/event-ch)]
    (go-loop []
      (when-let [event-and-args (<! event-ch)]
          (swap! app-state-atom transition event-and-args)
          (effects @app-state-atom event-and-args)

;; main entry point
(defn main [app-state-atom]
  (routes/install-routes app-state-atom
   {:target (.getElementById js/document "content")})

  (start-event-loop app-state-atom)
  (routes/set-current-page @app-state-atom))

;; starting the application
(defonce app-state-atom (atom (state/initial-state)))
(main app-state-atom)

;; for figwheel
(defn on-jsload []
  (close! (get-in @app-state keypaths/event-ch))
  (swap! app-state assoc-in keypaths/event-ch (chan))
  (main app-state))

;; for debugging in the js console
(defn debug-app-state []
  (clj->js @app-state))

In a nutshell, core calls transition-state to update the application state before calling perform-effects. Changes in application state must live inside transition-state. API calls reside inside perform-effects.

Lets see the magic of having a flexible method dispatch. All messages in the event channel looks as follows:

[event args] ;; -> [[keywords], map]

But each event is actually multiple multimethod invocations:

;; enqueuing to the events channel (simulating button click)
(put! (:event-ch app-state)
      [[:navigate :product] {:id 1}])

;; what the event loop calls from that message
;; (roughly)
(swap! app-state
       #(->> %
             (transition-state []
                               [:navigate :product]
                               {:id 1})
             (transition-state [:navigate]
                               [:navigate :product]
                               {:id 1})
             (transition-state [:navigate :product]
                               [:navigate :product]
                               {:id 1})
(perform-effects []
                 [:navigate :product]
                 {:id 1}
(perform-effects [:navigate]
                 [:navigate :product]
                 {:id 1}
(perform-effects [:navigate :product]
                 [:navigate :product]
                 {:id 1}

This allows us to surface shared behavior to sub-vectors of events. This is useful if we need to do something for all navigation events:

;; example definition of transition-state
(defmulti transition-state identity)

;; set default so we don't have to implement all transition
;; permutations that the event loop calls, but we can log
;; here if we need to find un-processed dispatches
(defmethod transition-state :default [dispatch event args app-state]

;; update the "current page" in the app state for
;; all navigation events
(defmethod transition-state [:navigate] [_ event args app-state]
  (assoc-in app-state keypaths/navigation-event event))

Now we can easily inject into all events traveling through the application:

(defmethod transition-state [] [dispatch event args app-state]
  ;; see all events - useful for debugging
  (js/console.log (clj->js event) (clj->js args))

Or any other similar, but broad behavior:

  • Use analytics to track page views, button clicks, etc.
  • Logging failures, API requests, etc.

More specific dispatch sub-vectors can choose to override the more general implementations if needed. The swap! happens at the end to avoid flickering the UI.

Practically speaking, the dispatch parameter acts as a filter for incoming messages.

Normally, we def the states and events:

;; events.cljs
(def navigate [:navigation])
(def navigate-home (conj navigate :home))
(def navigate-category (conj navigate :category))
(def navigate-product (conj navigate :product))

This is mostly to catch typos. Documenting all possible events the application accepts is also a nice side benefit.

The perform-effects multimethod is exactly the same as transition-state. However, perform-effects where all side effects trigger. The only way for perform-effects to change state is by enqueuing a new event. From there, transition-state can then merge necessary data into the application state. It’s a nice way to encourage code separation.

Technically, there are few mutable components in app state that’s reserved for components or perform-effects:

  • :events-ch for enqueuing new events
  • :cookie is our instance
  • :history is our goog.history.Html5History instance for routing to use. Our routing enqueues to the events channel to trigger changes in the application.

Ideally, it should be more strictly enforced. Although, our small engineering team hasn’t suffered any pain yet.

The Future

Our application is relatively new and there are definitely some unsolved problems that we’ll need more hammock time:

  • Creating a chain of messages that are sometimes shared between actions. For example, having a multi-page form that hits the same API endpoint to update its data before redirecting into a new page.
  • If the API requires multiple calls to achieve a conceptual action (eg - get or create a cart before adding an item to a cart). How should that be partitioned in this design?

Both issues look like the same problem surfacing in different ways as dependent events, but the current solutions that we developed don’t solve the problem in a way we’re entirely happy about. But we hope to talk more about them in the future.