Mook is a library designed to handle frontend application state(s).
It serves the same purpose than re-frame or
citrus.
⚠️This library is in an experimental state. Depending on the feedbacks that I
would receive in the coming weeks, things can change.
But that's the point: play with the code and send feebacks (Clojurians Slack
"mook" channel or pull requests)! Check the TodoMVC examples.
Also, check the interactive article that introduces the library and explains the design decisions.
;; Clojure CLI/deps.edn
mook {:mvn/version "0.2.0"}
;; Leiningen/Boot
[mook "0.2.0"]
Traditionally, mutating the global state is done through "actions". I chose another semantics after a discussion with a friend (@chpill): "commands". The semantics is taken from the event sourcing architecture that distinguishes "facts", things that happened for sure, and "commands", sending the intention of a transformation. But this command can fail for many reasons.
A command in mook is a function that takes a map and returns a promise that resolves to a map. The promise expresses the fact that the future result of a command can be a success or a failure. Also the promise has the useful property to be chainable.
This is very similar to an async Ring handler that returns a Manifold deferred (used with the Aleph webserver). And the traditional way of extending handlers in Ring, is to use middlewares.
Mook introduces the notion of state stores: instead of having one source of state that would fire global re-renders on every little change, it enables having smaller pieces of state that would fire partial re-renders.
Typically two types of state stores can be used:
;; Classical one source of truth hashmap
{:foo ... ;; <- any change in the hashmap will fire a whole re-render.
:bar ...}
;; Mook approach with state stores
{:my.app/local-store {...} ;; <- changes to local-store re-render only concerned UI parts
:my.app/app-db <Datascript db value>} ;; <- changes to app-db re-render only concerned UI parts
This is an optimization meant to fire re-renders only by store. It is useful for complex Datascript queries that can be costly on every re-render.
This optimization is a variation around the "one source of thruth" concept since at every point in time, Mook can give an immutable hashmap of the state where keys are the names of the "sub-states".
Now that we saw (briefly) what state stores, commands and middlewares are in Mook context, let's glue them together.
Mook behavior and storage are configured through middlewares.
There is one mandatory middleware to provide on initialization: the state stores
middleware.
Then any other middleware can be added (for http requests, browser local storage
etc...).
Example:
(ns my.app)
(require '[mook.core :as m])
(require '[promesa.core :as p])
;; Datascript (structured business logic)
(def db-schema {...})
(defonce app-db*
(d/create-conn db-schema))
;; Atom (lightweight store)
(defonce local-store*
{::current-user-id nil
::in-progress? false
...})
;; State stores middleware. Mandatory!
(def wrap-state-stores
(m/create-state-store-wrapper
[{::m/store-key ::local-store*
::m/state-key ::local-store
::m/store* local-store*}
{::m/store-key ::app-db*
::m/state-key ::app-db
::m/store* app-db*}]))
;; Logging middleware, for the example
(defn wrap-console-log [command]
(fn process-console-log>> [data]
(println "Data before\n" data)
(-> (command data)
(p/then (fn [data']
(println "Data after\n" data')
data')))))
(m/init-mook!
{::m/command-middlewares [wrap-state-stores
wrap-console-log
;; Add as many middlewares as you wish.
;; They will be applied in the declared order.
]})
⚠️ Notice how map keys are all namespaced. Mook heavilly uses core.spec and
defines specs for almost every value that flows through the architecture.
Three values have noticeable semantics:
:mook.core/store*
: the store itself as a reference (the Clojure atom, the
Datascript "connection"...).:mook.core/store-key
: the name given to the store.:mook.core/state-key
: the name of the state contained in a store (~ the
dereferenced reference).Finally we can launch our React application:
(defn root-component [_props]
...)
(js/ReactDOM.render
(js/React.createElement root-component nil)
(js/document.getElementById "app-root"))
Note: for the time being, Mook stores the state in a singleton. We don't have to use React context to expose the stores.
Mook is only about state management. But Mook relies on the Hooks API (React >=
16.8).
For the view you can use:
Now, there are two things that we can do with our application: read data from the state stores and modify the state stores.
Mook defines two hooks: use-mook-state
and use-param-mook-state
.
Mook hooks have two arities: the unary one that accepts a map with all parameters explicitly given. In a way, this arity acts like labelled arguments in other languages (like OCaml for example). Respectively the binary and ternary arities with positional arguments act like shorthand versions of the function call.
use-mook-state
takes a state store name and a handler. The handler receives
the dereferenced store (~ the state) as its first and only parameter. There are
only two ways for this hook to fire a re-render:
(require '[mook.core :as m])
;; Arity 1
(use-mook-state {::m/state-key ::local-store
::m/handler (fn [state]
(::current-user-id state))})
;; Arity 2 (shorthand)
(use-mook-state ::db* ::current-user-id)
A more evolved one (use-param-mook-state
), similar to React behaviour with
component key
attibute, where the developper controls the data that will
provoque a new comparison. This hook was crafted to address the fact that
complex queries in Datascript might be slow, and we don't want it to replay on
every functional component call. Also this hook fires a re-render when the "key"
value changes or that the result of a new state of the store changes.
(require '[mook.core :as m])
;; Arity 1
(use-param-mook-state {::m/state-key ::app-db
::m/params [current-user-id book-ids]
::m/handler (fn [db] ...)})
;; Arity 3 (shorthand)
(use-param-mook-state ::app-db
[current-user-id book-ids]
(fn [db] ...))
⚠️For the time being and since Mook is in an early stage, there are two ways of transforming the states:
To access mook store context and behaviors defined in the middlewares, a command has to be wrapped with mook middlewares.
This can be done statically in a namespace or dynamically in a React handlers.
(require '[mook.core :as m])
(require '[promesa.core :as p])
;; The command
(defn create-new-todo>> [data]
...)
;; We can spec it! It is a regular function.
(s/fdef create-new-todo>>
:args (s/cat :data ...)
:ret p/promise?)
;; Finally we wrap it so that it will receive the stores in its
;; parameters (and any other thing defined in the middlewares).
(def <set-route>>
(m/wrap set-route>>))
Notice the convention here. ...>>
indicates that the function returns a
promise. <...>
indicated that the function has been wrapped with Mook
middlewares.
The state store middleware merges all the states and stores in the data provided
to a command. In our case, for the input: {:foo "bar"}
, the command will
receive the following map:
{:foo "bar"
::local-store {...}
::local-store* <Atom ...>
::app-db #datascript/DB{...}
::app-db* <DB connection ...>
}
This would be a command definition:
(require '[mook.core :as m])
(require '[datascript.core :as d])
(require '[promesa.core :as p])
;; The command
(defn create-new-todo>> [{::keys [app-db* local-store*] :as data}]
(let [title (:todo/title data)]
(d/transact! app-db*
[{:todo/title title
:todo/completed? false
:todo/created-at (js/Date.)}])
(swap! local-store* assoc ::latest-todo title)
(p/resolved (dissoc data :todo/title))))
(defn set-route>> [{::keys [local-store*] :as data}]
(swap! local-store* merge (select-keys data [::current-route]))
(p/resolved (dissoc data ::current-route)))
;; We can spec it! It is a regular function.
(s/fdef create-new-todo>>
:args (s/cat :data (s/keys :req [::local-store* ::app-db* :todo/title]))
:ret p/promise?)
;; Finally we wrap it so that it will receive the stores in its
;; parameters (and any other thing defined in the middlewares).
(def <create-new-todo>>
(m/wrap set-route>>))
One last mandatory setup is to implement a Watchable
protocol for all
references so that Mook can fire re-renders on state transitions. It is already
implemented for Clojure atoms but not for Datascript databases since it is not a
mandatory dependency.
(require '[mook.core :as m])
(require 'datascript.db')
(extend-type datascript.db/DB
m/Watchable
(m/listen! [this key f]
(d/listen! this key (fn watch-changes [{:keys [db-after] :as _transaction-data}]
(f {::m/new-state db-after}))))
(m/unlisten! [this key]
(d/unlisten! this key)))
Take a look at:
If we want to use the declarative approach, we have... nothing to do.
The command will receive the same keys but we can only use the state values (that are immutable values).
The state store middleware merges all the states and stores in the data provided
to a command. In our case, for the input: {:foo "bar"}
, the command will
receive the following map:
{:foo "bar"
::local-store {...}
::local-store* <Atom ...> ;; <- Present but useless
::app-db #datascript/DB{...}
::app-db* <DB connection ...> ;; <- Present but useless
}
This would be a command definition:
(require '[mook.core :as m])
(require '[datascript.core :as d])
(require '[promesa.core :as p])
;; The command
(defn create-new-todo>> [{::keys [app-db local-store] :as data}]
(let [title (:todo/title data)
new-app-db (d/db-with app-db
[{:todo/title title
:todo/completed? false
:todo/created-at (js/Date.)}])
new-local-store (assoc local-store
::latest-todo
title)]
(p/resolved
(-> data
(dissoc :todo/title)
(assoc ::m/state-transitions [{::m/state-key ::app-db
::m/new-state new-app-db}
{::m/state-key ::local-store
::m/new-state new-local-store}])))))
;; We can spec it! It is a regular function.
(s/fdef create-new-todo>>
:args (s/cat :data (s/keys :req [::local-store ::app-db :todo/title]))
:ret p/promise?)
;; Finally we wrap it so that it will receive the stores in its
;; parameters (and any other thing defined in the middlewares).
(def <create-new-todo>>
(m/wrap create-new-todo>>))
Take a look at:
By taking a close look at two versions of the same command
(create-new-todo>>
), we can see that:
::local-store*
and ::app-db*
. This convention indicates the
use of the reference itself (aka the store).::local-store
and ::app-db
. This convention indicates the
dereferenced version of the state stores, and thus immutable values (aka the
state)An interesting thing with this approach is that local commands and global
commands can be coordinated easily. There is an example of this in the
introductory article (in
the onClick
handler of the book-detail
component). There is another one in
the TodoMVC
examples.
Also, I declared promesa as a Mook
dependency. This is intentional since it exposes a very nice API to work with
async logic. In other words, async logic of Mook commands should be structured
with promesa.
Check this part of the TodoMVC
example.
The main tools used in Mook are Promises and Hooks.
We could craft names such as "Pook" or "Prooks"... but that doesn't sound very good. And since promises and monads are conceptually very close, we can say that the library is about MOnads and hoOKs: "Mook".
I applied Mook to the TodoMVC project and included the sources of the examples
in the repository, in the examples
folder:
Copyright © 2020 Damien RAGOUCY
Distributed under the MIT License
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close