Skip to content

Instantly share code, notes, and snippets.

@martinklepsch
Last active July 5, 2016 09:29
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save martinklepsch/c5a2feacc63ce08565fcffe37951ab64 to your computer and use it in GitHub Desktop.
Save martinklepsch/c5a2feacc63ce08565fcffe37951ab64 to your computer and use it in GitHub Desktop.

Derivatives

A note on terminology: There are a lot of things with similar meanings/use-cases around: subscriptions, reactions, derived atoms, view models. I'll introduce another to make things even worse: derivative. A derivative implements IWatchable and it's value is the result of applying a function to the value of other things (sources) implementing IWatchable. Whenever any of the sources change the value if the derivative is updated.

Why this library

Let's assume you're hooked about the idea of storing all your application state in a single atom (db). (Why this is a great idea is covered elsewhere.)

Most of your components don't need the entirety of this state and instead receive a small selection of it that is enough to allow the components to do their job. Now that data is not always a subtree (i.e. (get-in db ...)) but might be a combination of various parts of your state. To transform the data in a way that it becomes useful for components you can use a function: (f @db).

Now you want to re-render your application whenever db changes so the views are representing the data in db. You end up calling f a lot, and remember, f has to do all the transformation for all components that could be rendered on the page, pretty inefficient!

To optimise we can create derivatives that contain data in shapes ideal to specific components and re-render those components when the derivative supplying the data changes.

These derivatives may depend on other derivatives, all ultimately leading up to your single db atom. To keep things efficient we only recalculate the value of a derivative when any of it's sources changes.

The intention of this library is to make the creation and usage of these interdependent references (derivatives) simple and efficient.

A secondary objective is also to achieve the above without relying on global state being defined at the namespace level of this library. (See re-frame vs. pure-frame.)

What this library helps with

  • transform db into shapes suited for rendering (a.k.a. view models)
  • managing a pool of derivatives so only needed derivatives are created and freed as soon as they become unused (currently Rum specific)
  • server-side rendering (to some degree)

What this library doesn't help with

  • Ensuring the required data is in db (server/client rendering)

Usage

Derivatives of your application state can be defined via a kind of specification like the one below:

(def db-atom (atom 0))

{
 ;; a source with no dependencies
 :db   [[]         your-db-atom]
 ;; a derivative with a dependency
 :inc  [[:db]      (fn [db] (inc db))]
 ;; a derivative with multiple dependencies
 :map  [[:db :inc] (fn [db inc] {:db db :inc inc})]
}

A specification like the above can be easily turned into a map with the same keys where the values are derivatives (see derivatives.core/build).

Also it can be turned into a registry that can help with only creating needed derivatives and freeing them up when they become unused (see derivatives.core/subman).

Comparisons

Plain rum.core/derived-atom

Rum's derived-atoms serve as building block in this library but there are some things which are (rightfully) not solved by derived-atoms:

  • Creation of interdependent derivative chains and
  • a mechanism to only create actually needed derived-atoms.

A small code sample should illustrate this well:

(def *db (atom {:count 0})) ; base db

(def *increased 
  (rum/derived-atom [*db]
                    ::increased 
                    (fn [db]
                      (inc (:count db)))))
  
(def *as-map
  (rum/derived-atom [*db *increased] 
                    ::as-map 
                    (fn [db incd] 
                      {:db db :increased incd})))

compared with the way this could be described using derivatives:

(def *db (atom {:count 0}))

(def spec
  ;; {name    [depends-upon     derive-fn]}
  {:db        [[]               *db]
   :increased [[:db]            (fn [db] (inc (:count db)))]
   :as-map    [[:db :increased] (fn [db incd] {:db db :increased inch})]})

The benefit here is that we don't use vars to make sure the dependencies are met and that we provide this information in a way that can easily be turned into a dependency graph which will later help us only calculating required derivatives (done by derivatives-manager). In comparison the first snippet will create derived-atoms and recalculate them whenever any of their dependencies change, no matter if you're using the derived-atom in any of your views.

Re-Frame Subscriptions

  • In Re-Frame you can do (subscribe [:sub-id "a parameter"]), with derivatives you can't. Instead these parameters need to be put into db and be used (potentially via another derivative) from there.
  • In Re-Frame subscriptions may have side-effects to listen to remote changes etc. This library does not intend to solve this kind of problem and thus side effects are discouraged.
(ns org.martinklepsch.derivatives
(:require [com.stuartsierra.dependency :as dep]
[clojure.set :as s]
[rum.core :as rum]
#?(:cljs [goog.object :as gobj])))
(defn depend'
"Variation of `depend` that takes a list of dependencies instead of one"
[graph node deps]
(reduce #(dep/depend %1 node %2) graph deps))
(defn spec->graph
"Turn a given spec into a dependency graph"
[spec]
(reduce-kv (fn [graph id [dependencies]]
(depend' graph id dependencies))
(dep/graph)
spec))
(defn build
"Given a spec return a map of similar structure replacing it's values with
derived atoms built based on the depedency information encoded in the spec
WARNING: This will create derived atoms for all keys so it may lead
to some uneccesary computations To avoid this issue consider using
`derivatives-manager` which manages derivatives in a registry
removing them as soon as they become unused"
[spec]
(let [graph (spec->graph spec)]
(reduce (fn [m k]
(let [[direct-deps derive] (-> spec k)]
(if (fn? derive) ; for the lack of `atom?`
(assoc m k (rum/derived-atom (map m direct-deps) k derive))
(assoc m k derive))))
{}
(dep/topo-sort graph))))
(defn calc-deps
"Calculate all dependencies for `ks` and return a set with the dependencies and `ks`"
[graph ks]
(apply s/union (set ks) (map #(dep/transitive-dependencies graph %) ks)))
(defn sync-derivatives
"Update the derivatives map `der-map` so that all keys passed in `order`
are statisfied and any superfluous keys are removed"
[spec der-map order]
(reduce (fn [m k]
(let [[direct-deps derive] (-> spec k)]
(if (get m k)
m
(if (fn? derive) ; for the lack of `atom?`
(do
(prn :creating-new-ref k)
(assoc m k (rum/derived-atom (map #(get m %) direct-deps) k derive)))
(assoc m k derive)))))
(select-keys der-map order)
order))
(defn derivatives-manager
"Given a derivatives spec return a map with `get!` and `free!` functions.
- (get! derivative-id token) will retrieve a derivative for
`derivative-id` registering the usage with `token`
- (free! derivative-id token) will indicate the derivative `derivative-id`
is no longer needed by `token`, if there are no more tokens needing
the derivative it will be removed"
[spec]
(let [graph (spec->graph spec)
state (atom {:registry {}
:dervatives {}})
sync! (fn [new-registry]
(let [required? (calc-deps graph (keys new-registry))
ordered (filter required? (dep/topo-sort graph))
new-ders (sync-derivatives spec (:derivatives @state) ordered)]
(swap! state assoc :derivatives new-ders, :registry new-registry)
new-ders))]
{:get! (fn get! [der-k token]
(let [registry (:registry @state)
new-reg (update registry der-k (fnil conj #{}) token)]
(if-let [derivative (get (sync! new-reg) der-k)]
derivative
(throw (ex-info (str "No derivative defined for " der-k) {:key der-k})))))
:release! (fn release! [der-k token]
(let [registry (:registry @state)
new-reg (if (= #{token} (get registry der-k))
(dissoc registry der-k)
(update registry der-k disj token))]
(sync! new-reg)
nil))}))
;; RUM specific code ===========================================================
(let [get-k ":derivatives/get"
release-k ":derivatives/release"]
(defn rum-derivatives
"Given the passed spec add get!/release! derivative functions to
the child context so they can be seen by components using the `deriv`
mixin."
[spec]
#?(:cljs
{:class-properties {:childContextTypes {get-k js/React.PropTypes.func
release-k js/React.PropTypes.func}}
:child-context (fn [_] (let [{:keys [release! get!]} (derivatives-manager spec)]
{release-k release! get-k get!}))}))
(defn rum-derivatives*
"Like rum-derivatives but get the spec from the arguments passed to the components (`:rum/args`) using `get-spec-fn`"
[get-spec-fn]
#?(:cljs
{:class-properties {:childContextTypes {get-k js/React.PropTypes.func
release-k js/React.PropTypes.func}}
:init (fn [s _] (assoc s ::spec (get-spec-fn (:rum/args s))))
:child-context (fn [s] (let [{:keys [release! get!]} (derivatives-manager (::spec s))]
{release-k release! get-k get!}))}))
(defn deriv
"Rum mixin to retrieve a derivative for `:der-k` using the functions in the component context
To get the derived-atom use `get-ref` for swappable client/server behavior"
[der-k]
#?(:cljs
(let [token (rand-int 10000)] ;TODO
{:class-properties {:contextTypes {get-k js/React.PropTypes.func
release-k js/React.PropTypes.func}}
:will-mount (fn [s]
(let [get-der! (-> s :rum/react-component (gobj/get "context") (gobj/get get-k))]
(assert get-der! "No get! derivative function found in component context")
(assoc-in s [::derivatives der-k] (get-der! der-k))))
:will-unmount (fn [s]
(let [release-der! (-> s :rum/react-component (gobj/get "context") (gobj/get release-k))]
(assert release-der! "No release! derivative function found in component context")
(release-der! der-k)
(update s ::derivatives dissoc der-k)))}))))
(def ^:dynamic *derivatives* nil)
(defn get-ref
"Get the derivative identified by `der-k` from the component state.
When rendering in Clojure this looks for `der-k` in the dynvar `*derivatives`"
[state der-k]
#?(:cljs (get-in state [::derivatives der-k])
:clj (get *derivatives* der-k)))
(defn react
"Like `get-ref` wrapped in `rum.core/react`"
[state der-k]
(rum/react (get-ref state der-k)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment