Skip to content

tiensonqin/citrus

 
 

Repository files navigation

Scrum is now Citrus as of v3.0.0 to avoid confusion with Agile term “Scrum”. Older versions are still available under the old name Scrum. To migrate to v3.0.0+ replace all occurrences of scrum with citrus.

citrus logo

State management library for Rum

I am a big fan of Rum the library, as well as Rum the liquor. In almost every classic Rum-based cocktail, citrus is used as an ingredient to 1) pair with the sugar-based flavor of the Rum and 2) smooth the harshness of the alcohol flavor. Wherever you find Rum, it is almost always accompanied with some form of citrus to control and balance the cocktail. I think it is very fitting for how this library pairs with Rum. — @oakmac

Discuss on Clojurians Slack #citrus

Table of Contents

Motivation

Have a simple, re-frame like state management facilities for building web apps with Rum while leveraging its API.

Features

⚛️ Decoupled application state in a single atom

📦 No global state, everything lives in Reconciler instance

🎛 A notion of a controller to keep application domains separate

🚀 Reactive queries

📋 Side-effects are described as data

⚡️ Async batched updates for better performance

🚰 Server-side rendering with convenient state hydration

Apps built with Citrus

Installation

Add to project.clj / build.boot: [org.roman01la/citrus "3.0.0"]

Usage

(ns counter.core
  (:require [rum.core :as rum]
            [citrus.core :as citrus]))

;;
;; define controller & event handlers
;;

(def initial-state 0) ;; initial state

(defmulti control (fn [event] event))

(defmethod control :init []
  {:local-storage
   {:method :get
    :key :counter
    :on-read :init-ready}}) ;; read from local storage

(defmethod control :init-ready [_ [counter]]
  (if-not (nil? counter)
    {:state (js/parseInt counter)} ;; init with saved state
    {:state initial-state})) ;; or with predefined initial state

(defmethod control :inc [_ _ counter]
  (let [next-counter (inc counter)]
    {:state next-counter ;; update state
     :local-storage
     {:method :set
      :data next-counter
      :key :counter}})) ;; persist to local storage

(defmethod control :dec [_ _ counter]
  (let [next-counter (dec counter)]
    {:state next-counter ;; update state
     :local-storage
     {:method :set
      :data next-counter
      :key :counter}})) ;; persist to local storage


;;
;; define effect handler
;;

(defn local-storage [reconciler controller-name effect]
  (let [{:keys [method data key on-read]} effect]
    (case method
      :set (js/localStorage.setItem (name key) data)
      :get (->> (js/localStorage.getItem (name key))
                (citrus/dispatch! reconciler controller-name on-read))
      nil)))


;;
;; define UI component
;;

(rum/defc Counter < rum/reactive [r]
  [:div
   [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"]
   [:span (rum/react (citrus/subscription r [:counter]))]
   [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]])


;;
;; start up
;;

;; create Reconciler instance
(defonce reconciler
  (citrus/reconciler
    {:state
     (atom {}) ;; application state
     :controllers
     {:counter control} ;; controllers
     :effect-handlers
     {:local-storage local-storage}})) ;; effect handlers

;; initialize controllers
(defonce init-ctrl (citrus/broadcast-sync! reconciler :init))

;; render
(rum/mount (Counter reconciler)
           (. js/document (getElementById "app")))

How it works

With Citrus you build everything around a well known architecture pattern in modern SPA development:

📦 Model application state (with reconciler)

📩 Dispatch events (with dispatch!, dispatch-sync!, broadcast! and broadcast-sync!)

📬 Handle events (with :controllers functions)

🕹 Handle side effects (with :effect-handlers functions)

🚀 Query state reactively (with subscription, rum/react and rum/reactive)

Render (automatic & efficient ! profit 👍)

Reconciler

Reconciler is the core of Citrus. An instance of Reconciler takes care of application state, handles events, side effects and subscriptions, and performs async batched updates (via requestAnimationFrame):

(defonce reconciler
  (citrus/reconciler {:state (atom {})
                      :controllers {:counter control}
                      :effect-handlers {:http http}}))

:state

The value at the :state key is the initial state of the reconciler represented as an atom which holds a hash map. The atom is created and passed explicitly.

:controllers

The value at the :controllers key is a hash map from controller name to controller function. The controller stores its state in reconciler's :state atom at the key which is the name of the controller in :controllers hash map. That is, the keys in :controllers are reflected in the :state atom. This is where modeling state happens and application domains keep separated.

Usually controllers are initialized with a predefined initial state value by dispatching :init event.

NOTE: the :init event pattern isn't enforced at all in Citrus, but we consider it is a good idea for 2 reasons:

  • it separates setup of the reconciler from initialization phase, because initialization could happen in several ways (hardcoded, read from global JSON/Transit data rendered into HTML from the server, user event, etc.)
  • allows setting a global watcher on the atom for ad-hoc stuff outside of the normal Citrus cycle for maximum flexibility

:effect-handlers

The value at the :effect-handlers key is a hash map of side effect handlers. Handler function asynchronously performs impure computations such as state change, HTTP request, etc. The only built-in effects handler is :state, everything else should be implemented and provided by user.

Dispatching events

Dispatched events communicate intention to perform a side effect, whether it is updating the state or performing a network request. By default effects are executed asynchronously, use dispatch-sync! when synchronous execution is required:

(citrus.core/dispatch! reconciler :controller-name :event-name &args)
(citrus.core/dispatch-sync! reconciler :controller-name :event-name &args)

broadcast! and its synchronous counterpart broadcast-sync! should be used to broadcast an event to all controllers:

(citrus.core/broadcast! reconciler :event-name &args)
(citrus.core/broadcast-sync! reconciler :event-name &args)

Handling events

A controller is a multimethod that returns effects. It usually has at least an initial state and :init event method. An effect is key/value pair where the key is the name of the effect handler and the value is description of the effect that satisfies particular handler.

(def initial-state 0)

(defmulti control (fn [event] event))

(defmethod control :init [event args state]
  {:state initial-state})

(defmethod control :inc [event args state]
  {:state (inc state)})

(defmethod control :dec [event args state]
  {:state (dec state)})

It's important to understand that state value that is passed in won't affect the whole state, but only the part corresponding to its associated key in the :controllers map of the reconciler.

Side effects

A side effect is an impure computation e.g. state mutation, HTTP request, storage access, etc. Because handling side effects is inconvenient and usually leads to cumbersome code, this operation is pushed outside of user code. In Citrus you don't perform effects directly in controllers. Instead controller methods return a hash map of effects represented as data. In every entry of the map the key is a name of the corresponding effects handler and the value is a description of the effect.

Here's an example of an effect that describes HTTP request:

{:http {:url "/api/users"
        :method :post
        :body {:name "John Doe"}
        :headers {"Content-Type" "application/json"}
        :on-success :create-user-ready
        :on-error :create-user-failed}}

And corresponding handler function:

(defn http [reconciler ctrl-name effect]
  (let [{:keys [on-success on-error]} effect]
    (-> (fetch effect)
        (then #(citrus/dispatch! reconciler ctrl-name on-success %))
        (catch #(citrus/dispatch! reconciler ctrl-name on-error %)))))

Handler function accepts three arguments: reconciler instance, the name key of the controller which produced the effect and the effect value itself.

Notice how the above effect provides callback event names to handle HTTP response/error which are dispatched once request is done. This is a frequent pattern when it is expected that an effect can produce another one e.g. update state with response body.

NOTE: :state is the only handler built into Citrus. Because state change is the most frequently used effect it is handled a bit differently, in efficient way (see Scheduling and batching section).

Subscriptions

A subscription is a reactive query into application state. It is an atom which holds a part of the state value retrieved with provided path. Optional second argument is an aggregate function that computes a materialized view. You can also do parameterized and aggregate subscriptions.

Actual subscription happens in Rum component via rum/reactive mixin and rum/react function which hooks in a watch function to update a component when an atom gets updated.

;; normal subscription
(defn fname [reconciler]
  (citrus.core/subscription reconciler [:users 0 :fname]))

;; a subscription with aggregate function
(defn full-name [reconciler]
  (citrus.core/subscription reconciler [:users 0] #(str (:fname %) " " (:lname %))))

;; parameterized subscription
(defn user [reconciler id]
  (citrus.core/subscription reconciler [:users id]))

;; aggregate subscription
(defn discount [reconciler]
  (citrus.core/subscription reconciler [:user :discount]))

(defn goods [reconciler]
  (citrus.core/subscription reconciler [:goods :selected]))

(defn shopping-cart [reconciler]
  (rum/derived-atom [(discount reconciler) (goods reconciler)] ::key
    (fn [discount goods]
      (let [price (->> goods (map :price) (reduce +))]
        (- price (* discount (/ price 100)))))))

;; usage
(rum/defc NameField < rum/reactive [reconciler]
  (let [user (rum/react (user reconciler 0))])
    [:div
     [:div.fname (rum/react (fname reconciler))]
     [:div.lname (:lname user)]
     [:div.full-name (rum/react (full-name reconciler))]
     [:div (str "Total: " (rum/react (shopping-cart reconciler)))]])

Scheduling and batching

This section describes how effects execution works in Citrus. It is considered an advanced topic and is not necessary to read to start working with Citrus.

Scheduling

Events dispatched using citrus/dispatch! are always executed asynchronously. Execution is scheduled via requestAnimationFrame meaning that events that where dispatched in 16ms timeframe will be executed sequentially by the end of this time.

;; |--×-×---×---×--|---
;; 0ms            16ms

Batching

Once 16ms timer is fired a queue of scheduled events is being executed to produce a sequence of effects. This sequence is then divided into two: state updates and other side effects. First, state updates are executed in a single swap!, which triggers only one re-render, and after that other effects are being executed.

;; queue = [state1 http state2 local-storage]

;; state-queue = [state1 state2]
;; other-queue = [http local-storage]

;; swap! reduce old-state state-queue → new-state
;; doseq other-queue

Server-side rendering

Server-side rendering in Citrus doesn't require any changes in UI components code, the API is the same. However it works differently under the hood when the code is executed in Clojure.

Here's a list of the main differences from client-side:

  • reconciler accepts a hash of subscriptions resolvers and optional :state atom
  • subscriptions are resolved synchronously
  • controllers are not used
  • all dispatching functions are disabled

Subscriptions resolvers

To understand what is subscription resolving function let's start with a small example:

;; used in both Clojure & ClojureScript
(rum/defc Counter < rum/reactive [r]
  [:div
   [:button {:on-click #(citrus/dispatch! r :counter :dec)} "-"]
   [:span (rum/react (citrus/subscription r [:counter]))]
   [:button {:on-click #(citrus/dispatch! r :counter :inc)} "+"]])
;; server only
(let [state (atom {})
      r (citrus/reconciler {:state state
                           :resolvers resolvers})] ;; create reconciler
  (->> (Counter r) ;; initialize components tree
       rum/render-html ;; render to HTML
       (render-document @state))) ;; render into document template
;; server only
(def resolvers
  {:counter (constantly 0)}) ;; :counter subscription resolving function

resolver is a hash map from subscription path's top level key, that is used when creating a subscription in UI components, to a function that returns a value. Normally a resolver would access database or any other data source used on the backend.

Resolver

A value returned from resolving function is stored in Resolver instance which is atom-like type that is used under the hood in subscriptions.

Resolved data

In the above example you may have noticed that we create state atom, pass it into reconciler and then dereference it once rendering is done. When rendering on server Citrus collects resolved data into an atom behind :state key of the reconciler, if the atom is provided. This data should be rendered into HTML to rehydrate the app once it is initialized on the client-side.

NOTE: in order to retrieve resolved data the atom should be dereferenced only after rum/render-html call.

Synchronous subscriptions

Every subscription created inside of components that are being rendered triggers corresponding resolving function which blocks rendering until a value is returned. The downside is that the more subscriptions there are down the components tree, the more time it will take to render the whole app. On the other hand it makes it possible to both render and retrieve state in one render pass. To reduce rendering time make sure you don't have too much subscriptions in components tree. Usually it's enough to have one or two in root component for every route.

Request bound caching

If you have multiple subscriptions to same data source in UI tree you'll see that data is also fetched multiple times when rendering on server. To reduce database access load it's recommended to reuse data from resolved subscriptions. Here's an implementation of a simple cache:

(defn resolve [resolver req]
  (let [cache (volatile! {})] ;; cache
    (fn [[key & p :as path]]
      (if-let [data (get-in @cache path)] ;; cache hit
        (get-in data p) ;; return data from cache
        (let [data (resolver [key] req)] ;; cache miss, resolve subscription
          (vswap! cache assoc key data) ;; cache data
          (get-in data p))))))

Managing resolvers at runtime

If you want to display different data based on certain condition, such as user role or A/B testing, it is useful to have predefined set of resolvers for every of those cases. Based on those conditions a web server can construct different resolver maps to display appropriate data.

;; resolvers
(def common
  {:thirdparty-ads get-ads
   :promoted-products get-promoted})
   
(def anonymous-user
  {:top-products get-top-products})
  
(def returning-user
  {:suggested-products get-suggested-products})

;; conditional resolver construction
(defn make-resolver [req]
  (cond
    (anonymous? req) (merge common anonymous-user)
    (returning? req) (merge comomn returning-user)
    :else common))

Best practices

  • Pass the reconciler explicity from parent components to children. Since it is a reference type it won't affect rum/static (shouldComponentUpdate) optimization. But if you prefer to do it Redux-way, you can use context in Rum as well https://github.com/tonsky/rum/#interop-with-react
  • Set up the initial state value by broadcast-sync!ing an :init event before first render. This enforces controllers to keep state initialization in-place where they are defined.

Recipes

FAQ

Passing reconciler explicitly is annoying and makes components impossible to reuse since they depend on reconciler. Can I use DI via React context to avoid this?

Yes, you can. But keep in mind that there's nothing more straightforward and simpler to understand than data passed as arguments explicitly. The argument on reusability is simply not true. If you think about it, reusable components are always leaf nodes in UI tree and everything above them is application specific UI. Those leaf components doesn't need to know about reconciler, they should provide an API which should be used by application specific components that depend on reconciler and pass in data and callbacks that interact with reconciler.

Testing

Testing state management logic in Citrus is really simple. Here's what can be tested:

  • controllers output (effects)
  • state changes

NOTE: Using synchronous dispatch citrus.core/dispatch-sync! makes it easier to test state updates.

(ns app.controllers.counter)

(defmulti control (fn [event] event))

(defmethod control :init [_ [initial-state] _]
  {:state initial-state})

(defmethod control :inc [_ _ counter]
  {:state (inc counter)})

(defmethod control :dec [_ _ counter]
  {:state (dec counter)})
(ns app.test.controllers.counter-test
  (:require [clojure.test :refer :all]
            [citrus.core :as citrus]
            [app.controllers.counter :as counter]))

(def state (atom {}))

(def r
  (citrus/reconciler
    {:state state
     :controllers
     {:counter counter/control}}))

(deftest counter-control
  (testing "Should return initial-state value"
    (is (= (counter/control :init 0 nil) {:state 0})))
  (testing "Should return incremented value"
    (is (= (counter/control :inc nil 0) {:state 1})))
  (testing "Should return decremented value"
    (is (= (counter/control :dec nil 1) {:state 0}))))

(deftest counter-state
  (testing "Should initialize state value with 0"
    (citrus/dispatch-sync! r :counter :init 0)
    (is (zero? (:counter @state))))
  (testing "Should increment state value"
    (citrus/dispatch-sync! r :counter :inc)
    (is (= (:counter @state) 1)))
  (testing "Should deccrement state value"
    (citrus/dispatch-sync! r :counter :dec)
    (is (= (:counter @state) 0))))

Roadmap

  • Get rid of global state
  • Make citrus isomorphic
  • Storage agnostic architecture? (Atom, DataScript, etc.)
  • Better effects handling (network, localStorage, etc.)
  • Provide better developer experience using clojure.spec

Contributing

If you've encountered an issue or want to request a feature or any other kind of contribution, please file an issue and provide detailed description.

This project is using Leiningen build tool, make sure you have it installed.

Run tests with lein test.

License

Copyright © 2017 Roman Liutikov

Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.

About

State management library for Rum

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • Clojure 98.9%
  • HTML 1.1%