Mook, a novel approach to frontend state management with ClojureScript and React
⚠ Note: this is the second iteration of the Mook article. After getting various feedbacks with the first version of the article and the library, I introduced various deep changes. ⚠
The default way to write UI applications in ClojureScript is to use React or its cousin React Native.
UI applications involve handling the following things:
- Displaying graphical components (~ how to describe them)
In this article we will use a very shallow wrapper around React described in in a previous article and included in the mook library. - How to store and transform state (through user action or external action)
This is the topic of this article. - How to link state and UI.
React handles this point with the following "mantra": UI is a strict (and "dumb") application of the state:UI = f(state)
.
Mook is a young library that gives tools to store application state and to
transform it.
After building ClojureScript applications (with React and React Native)
for about 6 years, I bumped into various limitations. This library is an
attent to circumvent them.
Throughout this article, we'll discover Mook ideas interactively thanks to Yehonathan Sharvit's wonderfull Klipse library. The ClojureScript code is compiled and executed directly in your browser so:
- You will see the result of the code next to it.
- You can modify the code to explore its behavior and instantly see the result on the right panel.
- You can show/hide the "Result" panel to better read the code.
Global state management
React handles only local state at the component level. But handling the global app state is another affair, and we have to turn toward other libraries (or manually crafting it around the ideas of Flux
A traditional way to handle global state in a Javascript is the Redux approach. In the ClojureScript land, the reference is re-frame, which has similar concepts, with different names.
Here are the Redux important ideas:
- Isolate the application reference state into one hashmap structure.
- Expose an opiniated way to read this state by preventing the risk of unwanted mutations.
- Expose an opiniated way to transform this state.
re-frame, in contrast to Redux, doesn't suffer from unwanted mutation risks thanks to Clojure persistent data structures.
Let's explore some problems and limitations of those frameworks of ideas.
Limitation #1: state reading
It's funny to note on a Clojure blog post that one of the first notions introduced in Redux essential tutorial is immutability. Reading the state with useSelector
exposes the risk of mutating the reference state with methods such as array.sort()
. This kind of risk doesn't exist in the Clojure land thanks to its persistent data structures.
Also on the Redux side, new values suffer from the fact that the
comparison of objects in JS are based on references, not values. So every
new value that is an object will fire a full tree comparison, as explained
on this page (in the 4th paragraph).
Clojure doesn't suffer from that since it does structural comparison by
default, treating all data as values.
Finally, re-frame forces one to read the state in a component with a subscription. It forces to split some intention between different namespaces. After a while, changing and/or cleaning those linked parts of the code can be tricky.
To summarize: Since Clojure data structures are immutable and use structural comparison, we could locally and directly read it in the component and use Clojure structural equality to fire re-renders.
Limitation #2: state writing
Redux takes a lot of care to prevent unwanted mutations. So it defines a clear semantics to handle precise state mutations: this is the notion of reducer. Here is the shape of a reducer:
function counterReducer(state, action) {
... // state tranformation here
return newState;
}
Clojure already has a clear interface for state transition: reset!
and swap!
functions on atoms. But re-frame bypasses this interface, works hard to
hide it and exposes a signature similar to Redux:
(defn effect-handler [coeffects event]
(let [item-id (second event)
db (:db coeffects)
new-db (dissoc-in db [:items item-id])]
{:db new-db}))
In the name of the superiority of data over everything (data > functions > macros
) in the intro page of re-frame), some clean and simple properties of functions (composition,
closures) and already existing pieces of the standard library (atoms)
aren't used directly. This mantra might bring unwanted complexity.
To summarize: Since Clojure already has a clear and clean way to handle state transition, why not using it directly instead of hiding it?
Limitation #3: async workflows
Let's talk only about re-frame.
This point is actually the one that pushed me away from re-frame: the existence of the re-frame-async-flow-fx library.
Having to redefine all async logic with pure data deeply is questionable
when promises and core.async already exist.
Once again, the mantra of data > function > macro
seems to push away from the simplicity of some elegantly designed tools
that already exist.
Complex async logic usually appears in the initialization phase of many applications. This library (and re-frame in general) forces to split an intention into multiple coupled event effects when a chain of promises can keep together async pieces of business logic.
To summarize: Why not using tools that already exist to handle async workflows?
Limitation #4: the single hashmap, in-memory database
Last but not least, the idea that putting all the reference state into one hashmap is actually a good idea.
Let's imagine that we are creating a startup and that as CTO, we have to create an UI that allows users to pin books that they liked and recommand them to other users. One of the user stories is that on the user's main public page there is the list of read books and every book has information of the title, the author and a list of categories it belongs to.
You set up the database, expose a CRUD API and start coding the UI with
one big hashmap to store the reference state.
Following the user story, a natural way of storing the data and exposing
it on a coupled API endpoint would be the following:
{:current-user-id 1234
:users [{:id 1234
:name "Dam"
:relations [2345 ...]
:books [{:id 9
:name "Foo"
:author "..."
:summary "..."
:categories [{:id 1
:name "Thriller"}
{:id 2
:name "Nordic"}]}
{:id 8
:name "Bar"
:author "..."
:summary "..."
:categories [{:id 3
:name "Horror"}
{:id 2
:name "Nordic"}]}]}
{:id 2345
:name "Chpill"
...}]}
After some time, the product manager asks for a new feature that would allow users to browse the books also by categories.
😤 No problem, I'm a tough programmer! Let's write a function that extracts this information from the big database hashmap.
(defn get-books-by-categories [db]
(some->> (:users db)
(some #(when (= (:id %) (:current-user-id db))
%))
:books
(reduce (fn [acc book]
(reduce (fn [acc category]
(if (contains? acc category)
(update acc category conj book)
(assoc acc category [book])))
acc
(:categories book)))
{})))
Yeah! I'm tough and Clojure is a blessing since I can put hashmaps as keys of hashmaps. The result would look like this:
{{:id 1
:name "Thriller"} [{:id 9
:name "Foo"
...}
...]
{:id 2
:name "Nordic"} [{:id 9
:name "Foo"
...}
{:id 8
:name "Bar"
...}
...]
...}
But... 🤔
Maybe, that is not such a good idea. When I come back a month later, I struggle to understand what this function does and what is the shape of the data flowing through it.
To summarize: The hashmap data structure might not be very good as an in memory database since it forces one to give a "purpose shape" to the data.
Let's meet Mook
Mook is the synthesis of about 6 years of crafting applications with ClojureScript. It came to life thanks to:
- React Hooks.
- Datascript, a "true" in-memory database with powerful query capabilities.
- Étienne Spillmaeker, aka @chpill, my Clojure buddy since the beginning of my Clojure adventure.
It tries to address the 4 pain points listed above.
Here are the strong Mook ideas:
- Query the state directly and locally in components.
- Use functions and promises to handle state transitions and async
workflows.
Such functions are called commands in Mook parlance. This very similar to Flux actions. - Use Ring style middlewares to extend command behaviors.
- Make commands composable.
- Enable the use of Datascript along with atoms to store state.
Let's see how we could circumvent the previous limitations and then merge it into a usable library. We'll continue with our book platform and implement it with Mook interactively in the page thanks to Klipse.
Step #0: the tools under our hands 🛠
Traditionally, mutating the global state is done through "actions". I chose another semantics: 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.
Alse 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.
To summarize, we have:
- commands
- middlewares
- state stores
Step #1: the data(base(s))
One dominant idea of various architectures around React is to store the reference data in one place instead of spreading it all over the application. Traditionally, an application state can look like that:
{:current-user-id 1234
:current-page :home
:users [{...} ...]
:books [{...} ...]
:categories [{...} ...]
...}
Sadly we saw that this great idea leads to store the data into one big nested hashmap and that hashmaps aren't that good to store and extract information of "complex" data models. Let's how state stores and Datascript can overcome this situation.
A state store is simply a structure that has the same reading, writing and watching behavior than a atom, and a state is a dereferenced store that yields an immutable value.
Mook will force you to name the store and the state for a given state store.
At every moment, Mook will be able to expose this (example) information:
;; The map keys correspond to stores and states names
{:blog.core/local-store* <Atom ...> ;; A store: a regular Clojure atom with
:blog.core/local-store {...} ;; A state: a regular Clojure immutable hashmap
:blog.core/app-db* <DB connection ...> ;; A store: a Datascript "Connection"
:blog.core/app-db #datascript/DB{...} ;; A state: an Immutable Datascript db value
}
But... why bother with this state store(s) notion? As we saw, a classical hashmap isn't crafted to store and query rich, yet very common, data models. Saying that, I precisely aim what we would have with a tradional SQL system: entities that have relations between themselves. The great news is: Datascript can!
We could still store a Datascript DB value as part of our big state hashmap. But complicated Datascript queries can have a reasonable cost and we don't necessarelly want them to replay on every re-render.
State stores is also an optimization to "namespace" re-renders.
In the previous project that I built, the state was split this way:
- A Datascript db that held the reference data that came from Datomic and that was shared across various clients.
- A Clojure atom called the "local store" that was responsible to hold the app local state, typically the current user id.
- A Clojure atom called the "inert store" that would hold data that would never directly fire UI re-renders, typically the Firebase connection.
Let's go back to the point where the product owner asks us to browse books by categories. Instead of jumping into the existing structure of the data, we could have taken a step back and considered the relations between our entities (the following diagram is a kind of SQL entity-relation diagram adapted to Datomic/Datascript semantics)
We will use Datascript to store and query this data instead of a hashmap. When initializing a Datascript db, we can give it a schema so that it can normalize our data (~ understand the relations between the entities).
(ns blog.stores)
(def db-schema
{:user/books {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many}
:user/favorite-books {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many}
:book/categories {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many}})
And here is some data for our database.
(def db-data
(let [id* (atom 0)
get-new-id! #(swap! id* inc)
cat1 {:db/id (get-new-id!)
:category/name "Thriller"}
cat2 {:db/id (get-new-id!)
:category/name "History"}
cat3 {:db/id (get-new-id!)
:category/name "SciFi"}
cat4 {:db/id (get-new-id!)
:category/name "Teenager"}
cat5 {:db/id (get-new-id!)
:category/name "Architecture"}
cat6 {:db/id (get-new-id!)
:category/name "Biography"}
cat7 {:db/id (get-new-id!)
:category/name "Geek"}
book01 {:db/id (get-new-id!)
:book/title "Cochlearius cochlearius"
:book/author "Chryste Metherell"
:book/categories [cat1 cat2]}
book02 {:db/id (get-new-id!)
:book/title "Meleagris gallopavo"
:book/author "Harold Frandsen"
:book/categories [cat3 cat4 cat7]}
book03 {:db/id (get-new-id!)
:book/title "Trachyphonus vaillantii"
:book/author "Nickola Joderli"
:book/categories [cat6 cat2]}
book04 {:db/id (get-new-id!)
:book/title "Alopochen aegyptiacus"
:book/author "Hersh Eliasen"
:book/categories [cat5 cat6]}
book05 {:db/id (get-new-id!)
:book/title "Loxodonta africana"
:book/author "Ciel Kabos"
:book/categories [cat1 cat7 cat6]}]
[{:db/id (get-new-id!)
:user/firstname "Virgil"
:user/lastname "Peron"
:user/books [book01 book05]
:user/favorite-books [book01]}
{:db/id (get-new-id!)
:user/firstname "Lalo"
:user/lastname "Dumont"
:user/books [book01 book02 book03 book04 book05]
:user/favorite-books [book03 book04]}]))
Then let's initialize our database:
Note: In a real application, the data would come from an initialization phase and would be stored through a command. We transact it directly here for the convenience of the article.
(require '[datascript.core :as d])
(require '[clojure.spec.alpha :as s])
(defonce app-db*
(let [conn* (d/create-conn db-schema)]
(d/transact! conn* db-data)
conn*))
(s/def ::app-db d/db?) ;; State
(s/def ::app-db* d/conn?) ;; Store
We will also create a light and fast key-value store to store information purely local to the web application.
(defonce local-store*
(let [id (d/q '[:find ?e .
:where
[?e :user/firstname "Lalo"]]
@app-db*)]
(atom {:blog.core/current-user-id id})))
(s/def ::local-store map?)
(s/def ::local-store* #(satisfies? cljs.core/IAtom %))
Now that we have our data, let's initalize our Mook application:
(ns blog.core)
(require '[mook.core :as m])
(require '[blog.stores :as bs])
;; This is a Mook middleware
;; This is the only mandatory middleware
(def wrap-state-stores
(m/create-state-store-wrapper
[{::m/store-key ::bs/local-store*
::m/state-key ::bs/local-store
::m/store* bs/local-store*}
{::m/store-key ::bs/app-db*
::m/state-key ::bs/app-db
::m/store* bs/app-db*}]))
(m/init-mook!
{::m/command-middlewares [wrap-state-stores
;; Add as many middlewares as you wish.
;; They will be applied in the declared order.
]})
Step #2: reading the state(s)
Now that we have initialized our state stores, we can start building our interface and read data from components.
For the time being, Mook defines two hooks to read the data:
- One simple hook (
use-mook-state
) that takes a state key 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: 1. the result of the handler changes (the handler might close over changing values) 2. the state store changes and the result of the previously known handler changes. - A more evolved one (
use-param-mook-state
), similar to React behaviour with component `key` attribute, where the developer controls the data that will provoke 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.
We have to note that old and new values are compared with Clojure "=
" function. This function works at the data level (structural comparison),
not the reference level so JS objects might not play well with Mook hooks
(once again, as described in the Redux documentation).
And now, let's build the page asked by the product owner: the list of user books grouped by category.
We will use the tiny React wrapper included in mook that relies on cljs-bean. This wrapper is an optimized version of the one described in a previous article. But you could use any wrapper that uses a modern version of React that exposes the hooks API.
Since we are developing an application for the browser, we can use the handy mook macro that will define all official HTML tags in a custom namespace.
(ns blog.tags)
(require 'mook.react)
(refer-clojure :exclude '[map meta time])
(mook.react/def-html-elems!)
And now the page:
(ns blog.core)
(require '[mook.core :as m])
(require '[mook.react :as mr])
(require '[blog.stores :as bs])
(require '[blog.tags :as t])
(require '[cljs-bean.core :as b])
(require '[promesa.core :as p])
(require '[datascript.core :as d])
(defn book-by-category [props']
(let [props (b/->clj props')
[cat-id book-ids :as cat->book] (:cat->book props)
favorite-book-ids-set (-> props :favorite-book-ids set)
category (m/use-mook-state ::bs/app-db #(d/pull % '[*] cat-id))
books (m/use-mook-state ::bs/app-db #(d/pull-many % '[*] book-ids))]
(t/div {:className "card mb-3"}
(t/div {:className "card-body"}
(t/h5 {:className "card-title"}
(:category/name category))
(apply mr/fragment
(->> (for [book books]
(mr/fragment
(t/small {:className "font-italic"}
(:book/title book))
" - "
(t/small (:book/author book))))
(interpose (t/br))))))))
(defn user-books-by-category []
(let [user-id (m/use-mook-state ::bs/local-store ::current-user-id)
user (m/use-param-mook-state ::bs/app-db
user-id
#(d/pull % [:user/firstname :user/lastname] user-id))
favorite-book-ids (m/use-mook-state ::bs/app-db
#(some->> (d/pull % [:user/favorite-books] user-id)
:user/favorite-books
(map :db/id)))
cat->books (m/use-param-mook-state
{::m/state-key ::bs/app-db
::m/params [user-id favorite-book-ids]
::m/handler (fn [db]
(->> (d/q '[:find ?cat-id ?book-id
:in $ ?user-id
:where
[?user-id :user/books ?book-id]
[?book-id :book/categories ?cat-id]]
db user-id)
(reduce (fn [acc [cat-id book-id]]
(update acc cat-id #(-> (or % [])
(conj book-id))))
{})))})]
(t/div {:className "card"}
(t/div {:className "card-body"}
(t/h4 {:className "card-title"}
(:user/firstname user) " "
(:user/lastname user)
"'s books by category")
(for [cat->book cat->books]
(mr/create-element book-by-category {:key (str (print-str cat->book) favorite-book-ids)
:favorite-book-ids favorite-book-ids
:cat->book cat->book}))))))
(js/ReactDOM.render
(mr/create-element user-books-by-category)
(js/document.getElementById "mook-block-1"))
Loading ...
There are various things to note here:
1 - Mook hooks have two arities: the unary one (use-mook-state
spec and use-param-mook-state
spec) 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 unary function call.
Example:
(require '[mook.core :as m])
;; Arity 1
(m/use-mook-state {::m/state-key ::local-store
::m/handler (fn [store]
(::current-user-id store))})
;; Arity 2 (shorthand)
(m/use-mook-state ::local-store ::current-user-id)
;; ---
;; Arity 1
(m/use-param-mook-state {::m/state-key ::app-db
::m/params [current-user-id book-ids]
::m/handler (fn [db] ...)})
;; Arity 3 (shorthand)
(m/use-param-mook-state ::app-db
[current-user-id book-ids]
(fn [db] ...))
2 - The Datalog query is infinitely more expressive than the code written in the "Limitation #4" section. Let's write it again:
[:find ?cat-id ?book-id
:in $ ?user-id
:where
[?user-id :user/books ?book-id]
[?book-id :book/categories ?cat-id]]
In 5 lines, we read the whole intention: get all the category and book id pairs that belong to a given user. In the other case, you have to know the structure of the data to understand what the code does.
3 - The state reading is local. When we change or delete the component for any business reason, there is little chance to have dead code hanging around the project.
Step #3: changing the state(s)
Finally, let's simulate a whole webapp with a new requirement from the product owner. Those are the demands:
- We now have two pages. One for the list of books ordered by categories AND another one with the list of the user's books with their categories. We will simulate the page navigation with tabs and a field in the lightweight key-value state store (the Clojure atom).
- Users can pin their favorite books on the book list page. But there is more. The UX designers come with precise instructions on interactions. When the user clicks on a "pin" button, no other action can happen while the current one isn't finished. Also, to indicate the book being updated, a specific icon must appear only on the concerned book.
Let's go!
(require '[clojure.spec.alpha :as s])
(s/def ::current-route #{:by-book :by-category})
(s/def ::in-progress? boolean?)
;; For the article purpose, we update the state store directly
(swap! bs/local-store* merge {::current-route :by-book
::in-progress? false})
;; ---
;; We define our first command: mook semantics for actions
(defn set-route>> [{::bs/keys [local-store*] :as data}]
(swap! local-store* merge (select-keys data [::current-route]))
(p/resolved (dissoc data ::current-route)))
;; Note that we can spec it! It is a regular function.
;; It is commented for a Klipse integration problem
#_(s/fdef set-route>>
:args (s/cat :data (s/keys :req [::bs/local-store* ::current-route]))
:ret p/promise?)
;; Finally we wrap it so that it will receive the stores in its parameters
(def <set-route>>
(m/wrap set-route>>))
;; ---
(s/def :db/id integer?)
(s/def ::book-id :db/id)
(s/def ::pending-favorite-book-id :db/id)
;; Our second command with async steps
(defn switch-favorite-book>> [{::bs/keys [app-db* local-store*]
::keys [book-id]
:as data}]
(p/chain
(do (swap! local-store* assoc ::in-progress? true)
;; Simulate network call
(p/delay 1500 data))
#(let [user-id (::current-user-id @local-store*)
favorite? (-> (d/q '[:find [?book-id ...]
:in $ ?user-id
:where [?user-id :user/favorite-books ?book-id]]
@app-db* user-id)
set
(contains? book-id))]
(d/transact! app-db* [[(if favorite? :db/retract :db/add) user-id :user/favorite-books book-id]])
(swap! local-store* assoc ::in-progress? false)
(dissoc % ::book-id))))
#_(s/fdef switch-favorite-book>>
:args (s/cat :data (s/keys :req [::bs/app-db* ::bs/local-store* ::book-id]))
:ret p/promise?)
(def <switch-favorite-book>>
(m/wrap switch-favorite-book>>))
;; ---
;; The SVG code of those icons is in a hidden code block
(declare sync-icon)
(declare plus-icon)
(declare star-icon)
Note: you can click on the tabs and on book card icons.
(defn book-detail [props']
(let [props (b/->clj props')
book-id (:book-id props)
favorite-book-ids-set (-> props :favorite-book-ids set)
in-progress? (m/use-mook-state ::bs/local-store ::in-progress?)
[updating? set-updating!] (mr/use-state false)
favorite? (contains? favorite-book-ids-set book-id)
book (m/use-param-mook-state
::bs/app-db
[book-id favorite-book-ids-set]
(fn [db]
(d/pull db '[* {:book/categories [*]}] book-id)))]
(t/div {:className "card mb-3"}
(t/div {:className "card-body"}
(t/h5 {:className "card-title font-italic"}
(:book/title book)
" "
(t/a {:href "#"
:className (mr/classes {"btn" true
"btn-small" true
"float-right" true
"btn-success" favorite?
"btn-outline-secondary" (not favorite?)
"disabled" in-progress?})
:onClick (fn switch-book-status [e]
(.preventDefault e)
(when (not in-progress?)
(set-updating! true)
(p/chain
(<switch-favorite-book>> {::book-id book-id})
#(set-updating! false))))}
(cond
updating? (sync-icon)
favorite? (star-icon)
:else (plus-icon))))
(t/p {:className "card-text"}
(:book/author book))
(apply mr/fragment
(->> (for [category (:book/categories book)]
(t/small (:category/name category)))
(interpose (t/br))))))))
(defn user-books []
(let [user-id (m/use-mook-state ::bs/local-store ::current-user-id)
user (m/use-param-mook-state
::bs/app-db
user-id
(fn [db]
(d/pull db [:user/firstname :user/lastname] user-id)))
favorite-book-ids (m/use-mook-state
::bs/app-db
(fn [db]
(d/q '[:find [?book-id ...]
:in $ ?user-id
:where [?user-id :user/favorite-books ?book-id]]
db user-id)))
book-ids (m/use-param-mook-state
::bs/app-db
[user-id favorite-book-ids]
(fn [db]
(d/q '[:find [?book-id ...]
:in $ ?user-id
:where [?user-id :user/books ?book-id]]
db user-id)))]
(t/div {:className "card"}
(t/div {:className "card-body"}
(t/h4 {:className "card-title"}
(:user/firstname user) " "
(:user/lastname user)
"'s books")
(apply mr/fragment
(for [book-id book-ids]
(mr/create-element book-detail {:favorite-book-ids favorite-book-ids
:book-id book-id})))))))
(defn webapp-root []
(let [current-route (m/use-mook-state ::bs/local-store ::current-route)]
(t/div {:className "p-2"}
(t/ul {:className "nav nav-tabs"}
(t/li {:className "nav-item"}
(let [current? (= current-route :by-book)]
(t/a {:className (mr/classes {"nav-link" true
"active" current?})
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not current?)
(<set-route>> {::current-route :by-book})))}
"By book")))
(t/li {:className "nav-item"}
(let [current? (= current-route :by-category)]
(t/a {:className (mr/classes {"nav-link" true
"active" (= current-route :by-category)})
:href "#"
:onClick (fn [e]
(.preventDefault e)
(when (not current?)
(<set-route>> {::current-route :by-category})))}
"By category"))))
(mr/create-element (case current-route
:by-book user-books
:by-category user-books-by-category
(fn [] (t/p "Unknown page")))))))
(js/ReactDOM.render
(mr/create-element webapp-root)
(js/document.getElementById "mook-block-2"))
Loading ...
Once again, there many things to note in the code above:
- Traditionally, mutating the global state is done through "actions". I chose another semantics after a discussion with my 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.
- As an example of the usefulness of promise chainability, the
switch-book-status
click handler coordinates local and global effects: it first switches the icon of the concerned component (declared with auseState
hook), sends the global command and waits for it to finish to remove the "work in progress" icon. That I know, coordinating local and global effects isn't easily feasible with existing libraries. Once again, thinking about re-frame, enforcing data everywhere might not be such a good idea since achieving this with pure data isn't possible and has to be encapsulated with a library such as re-frame-async-flow-fx. Here we use two very useful properties of functions (in the programming sense): composability and closing over values (closures). - [Update] 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.
- We use references directly in commands. Once again, we do so since Clojure references have sane read and write interfaces on top of immutable data structures. It is impossible to inadvertently mutate the reference state of our application.
- [Update] Mook v0.2.0 introduces a new declarative way for mutations, not covered here. Check the Mook README paragraph on declarative mutations and the TodoMVC example that implements this approach. Please don't hesitate to comment and ask questions on "mook" Slack channel.
A final word: why "Mook"? Because this library is essentially made out of promises and hooks. Mixing those words would give "Pook" or "Prooks". But that doesn't sound very good. Since promises and monads are conceptually very similar, we can think of monads and hooks: "Mook"! And also, monads make you look smart 😏.
https://github.com/lambdam/mook/Thanks
I want to thank particularly two persons:
- Étienne Spillmaeker (@chpill) for his patience and his clever feedbacks on Mook maturation.
- Yehonathan Sharvit (@viebel) for his time taken to make this interactive article with Klipse possible.
I want to thank too Nikita Prokopov (@tonsky) for developing Datascript, which is IMO a key tool for reducing the complexity of frontend applications.
[update] Many thanks to @walterlfor carefully reading and correcting my English mistakes in the article.
I'd like also to thank the Paris Clojure Meetup community for being such a vibrant group of interesting and clever persons.
And finally Rich Hickey and the Clojure core team for... creating Clojure.
Damien RAGOUCY - Summer 2020