An extensible state management system for Clojure/ClojureScript inspired by the Redux library for Javascript.
Include in your project.clj
's dependencies':
[com.ben-allred/collaj "0.6.1"]
Creates a new store from a reducer function. create store
takes an optional initial-state
and zero or more
ehancers
. If initial-state
is not supplied, your reducer function must supply initial state via a zero argument arity.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]))
(defn my-reducer [state [type data]]
(case type
:add-to-list (conj state data)
state))
(def store (collaj/create-store my-other-reducer []))
(defn my-other-reducer
([] 0) ;; initial state
([state action]
(case (first action)
:inc (inc state)
:dec (dec state)
state)))
(def other-store (collaj/create-store my-reducer))
The resulting store has two functions: :get-state
which returns the current value of the store and :dispatch
which
causes your reducer to produce a new state based on the current state and the value dispatched. The value dispatched must
be a sequential clojure data structure (vector list or seq) and the first element in the dispatched value must be a
keyword. The keyword represents the dispatched value "type".
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]))
(defn my-reducer [state [type]]
(case type
:inc (inc state)
:dec (dec state)
state))
(let [{:keys [dispatch get-state]} (collaj/create-store my-reducer 0)]
(println (get-state))
(dispatch [:inc])
(println (get-state))
(dispatch [:inc])
(dispatch [:inc])
(dispatch [:inc])
(println (get-state))
(dispatch [:dec])
(println (get-state)))
;; 0
;; 1
;; 4
;; 3
;; => nil
The Reducing function passed to create-store
can be composed using functions in the com.ben-allred.collaj.reducers
namespace.
The combine
function takes a map of keys to reducers and returns a new reducer whose state is generated and
updated by calling the supplied reducers and collected into a map with the same keys as the map passed in.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.reducers :as collaj.red]))
(defn counter
([] 0)
([state _] (inc state)))
(defn list-of-things
([] [])
([state [type data]]
(case type
:add-to-list (conj state data)
state)))
(def my-reducer (collaj.red/combine {:counter counter :list-of-things list-of-things}))
(let [{:keys [dispatch get-state]} (collaj/create-store my-reducer)]
(println (get-state))
(dispatch [:anything])
(println (get-state))
(dispatch [:add-to-list :some-value])
(dispatch [:add-to-list :some-other-value])
(println (get-state)))
;; {:counter 0 :list-of-things []}
;; {:counter 1 :list-of-things []}
;; {:counter 3 :list-of-things [:some-value :some-other-value]}
;; => nil
The assoc
function takes an initial reducer (that presumably always returns a map) and assoc's keys and values
by calling each reducer with its isolated part of the state and the action. Any part of the state not assoc'ed with
a specific reducer gets passed to the initial reducer.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.reducers :as collaj.red]))
(defn person
([] {})
([state [type person]]
(case type
:set-person person
state)))
(defn friends
([] #{})
([state [type friend]]
(case type
:add-friend (conj state friend)
:add-frienemy (conj state friend)
state)))
(defn enemies
([] #{})
([state [type enemy]]
(case type
:add-enemy (conj state enemy)
:add-frienemy (conj state enemy)
state)))
(def my-reducer (collaj.red/assoc person :friends friends :enemies enemies))
(let [{:keys [dispatch get-state]} (collaj/create-store my-reducer)]
(println (get-state))
(dispatch [:set-person {:name "Jan"}])
(println (get-state))
(dispatch [:add-friend "Bill"])
(dispatch [:add-enemy "Jax"])
(println (get-state))
(dispatch [:set-person {:name "Simon" :favorite-colors #{:yellow :teal} :friends :i-get-assoc'ed-over}])
(println (get-state))
(dispatch [:add-friend "Susan"])
(dispatch [:add-frienemy "Harper"])
(println (get-state)))
;; {:friends #{} :enemies #{}}
;; {:name "Jan" :friends #{} :enemies #{}}
;; {:name "Jan" :friends #{"Bill"} :enemies #{"Jax"}}
;; {:name "Simon" :favorite-colors #{:yellow :teal} :friends #{"Bill"} :enemies #{"Jax"}}
;; {:name "Simon" :favorite-colors #{:yellow :teal} :friends #{"Susan" "Bill" "Harper"} :enemies #{"Jax" "Harper"}}
;; => nil
The assoc-in
function takes an initial reducer (that presumably always returns a map) and assoc's in key paths and
values by calling each reducer with its isolated part of the state and the action. Any part of the state not assoc'ed
in with a specific reducer gets passed to the initial reducer.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.reducers :as collaj.red]))
(defn person
([] {})
([state [type person]]
(case type
:set-person person
state)))
(defn friends
([] #{})
([state [type friend]]
(case type
:add-friend (conj state friend)
:add-frienemy (conj state friend)
state)))
(defn enemies
([] #{})
([state [type enemy]]
(case type
:add-enemy (conj state enemy)
:add-frienemy (conj state enemy)
state)))
(def my-reducer (collaj.red/assoc-in person [:best :friends] friends [:worst :enemies] enemies))
(let [{:keys [dispatch get-state]} (collaj/create-store my-reducer)]
(println (get-state))
(dispatch [:set-person {:name "Jan"}])
(println (get-state))
(dispatch [:add-friend "Bill"])
(dispatch [:add-enemy "Jax"])
(println (get-state))
(dispatch [:set-person {:name "Simon" :favorite-colors #{:yellow :teal} :best {:friends :i-get-assoc'ed-over}}])
(println (get-state))
(dispatch [:add-friend "Susan"])
(dispatch [:add-frienemy "Harper"])
(println (get-state)))
;; {:worst {:enemies #{}} :best {:friends #{}}}
;; {:name "Jan" :worst {:enemies #{}} :best {:friends #{}}}
;; {:name "Jan" :worst {:enemies #{"Jax"}} :best {:friends #{"Bill"}}}
;; {:name "Simon" :favorite-colors #{:yellow :teal} :best {:friends #{"Bill"}} :worst {:enemies #{"Jax"}}}
;; {:name "Simon" :favorite-colors #{:yellow :teal} :best {:friends #{"Susan" "Bill" "Harper"}} :worst {:enemies #{"Jax" "Harper"}}}
;; => nil
The map-of
function takes a key-fn and a reducer and builds a map of non-nil results of calling (key-fn action)
by
passing their state into the supplied reducer.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.reducers :as collaj.red]))
(defn reducer
([] 0)
([state [type]]
(case type
:add (+ state value)
:subtract (- state value)
state)))
(let [{:keys [get-state dispatch]} (collaj/create-store (collaj.red/map-of (comp :key second) reducer))]
(println (get-state))
(dispatch [:add {:key :number-1}])
(dispatch [:subtract {:key :number-2}])
(dispatch [:add {:key :number-2}])
(dispatch [:another-type {:key :number-1}])
(dispatch [:subtract {}])
(println (get-state)))
;; nil
;; {:number-1 1 :number-2 0}
;; => nil
The comp
function composes reducers into a single reducer producing the new state by threading it through
every reducer from right to left. Only the first reducer is used to produce initial state.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.reducers :as collaj.red]))
(defn reducer-1
([] #{})
([state [type value]]
(case type
:remove (disj state value)
state))
(defn reducer-2
([] ::never-gonna-see-me)
([state [type value]]
(case type
:add (conj state value)
state))
(let [{:keys [get-state dispatch]} (collaj/create-store (collaj.red/comp reducer-1 reducer-2))]
(println (get-state))
(dispatch [:add :some-value])
(dispatch [:add :some-other-value])
(dispatch [:remove :some-value])
(println (get-state)))
;; #{}
;; #{:some-other-value}
;; => nil
Enhancers exist in the com.ben-allred.collaj.enhancers
namespace. These can be mixed and matched when creating your store by
passing any of them as additional arguments to create-store
. The resulting enhancements happen in the order they are
passed to create-store
. Typically, you'll need enhancers that alter the data being dispatched to be first in the list
(such as with-fn-dispatch
and with-keyword-dispatch
). You will likely want with-log-middleware
last - or at least
after with-fn-dispatch
- because you may want to see the actions that are actually hitting your reducer and probably
don't need to see functions stringified in your logs. Keep this in mind when using these (or other) store enhancers.
The with-subscribers
enhancer adds a :subscribe
function to the store which allows you to supply one or more
functions to subscribe to changes to the store. The :subscribe
function can optionally take a type-fn
which can
filter out which types your subscription is notified about. :subscribe
returns a zero arity function that will
cancel the subscription.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(def store (collaj/create-store my-reducer collaj.en/with-subscribers))
(def dispatch (:dispatch store))
(def subscribe (:subscribe store))
(subscribe (fn [action] (println "all actions" action)))
(def unsubscribe-ab
(subscribe #{:type-a :type-b}
(fn [action] (println "only types a and b" action))))
(dispatch [:type-a])
;; "all actions [:type-a]"
;; "only-types a and b [:type-a]"
(dispatch [:type-b {:some :data}])
;; "all actions [:type-a {:some :data}]"
;; "only-types a and b [:type-a {:some :data}]"
(dispatch [:type-c])
;; "all actions [:type-c]"
(unsubscribe-ab)
(dispatch [:type-a])
;; "all actions [:type-a]"
This enhancer adds a :watch
function to the store which takes a path from the root of your state tree and a function to
be invoked when any nested value changes at the supplied path. :watch
returns an :unwatch
function to be called when
watching the state is no longer needed.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(defn my-reducer
([]
{:numbers #{} :letters {:lower-case #{} :upper-case #{}}})
([state [type value]]
(case type
:numbers/add (update state :numbers conj value)
:letters.lower-case/add (update-in state [:letters :lower-case] conj value)
:letters.upper-case/add (update-in state [:letters :upper-case] conj value)
state)))
(def store (collaj/create-store my-reducer collaj.en/with-watchers))
(def dispatch (:dispatch store))
(def watch (:watch store))
(watch [:numbers] (fn [old-value new-value]
(println "numbers are now" new-value)))
(watch [:letters :lower-case] (fn [old-value new-value]
(println "lower-case letters are now" new-value)))
(def unwatch-root
(watch [] (fn [old-value new-value]
(println "entire state was" old-value)
(println "entire state is" new-value))))
(dispatch [:numbers/add 3])
;; numbers are now #{3}
;; entire state was {:numbers #{}, :letters {:lower-case #{}, :upper-case #{}}}
;; entire state is {:numbers #{3}, :letters {:lower-case #{}, :upper-case #{}}}
(dispatch [:letters.lower-case/add "z"])
;; lower-case letters are now #{z}
;; entire state was {:numbers #{3}, :letters {:lower-case #{}, :upper-case #{}}}
;; entire state is {:numbers #{3}, :letters {:lower-case #{z}, :upper-case #{}}}
(dispatch [:letters.upper-case/add "L"])
;; entire state was {:numbers #{3}, :letters {:lower-case #{z}, :upper-case #{}}}
;; entire state is {:numbers #{3}, :letters {:lower-case #{z}, :upper-case #{L}}}
(dispatch [:letters.lower-case/add "z"]) ;; does not change state
(unwatch-root)
(dispatch [:letters.lower-case/add "r"])
;; lower-case letters are now #{z r}
This allows you to dispatch keywords and will wrap them into a vector before getting to your reducer.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(defn my-reducer
([] nil)
([state action]
action)
(def store (collaj/create-store my-reducer collaj.en/with-keyword-dispatch))
((:dispatch store) :do-something)
((:get-state store))
;; => [:do-something]
This allows you to dispatch functions that gets invoked with dispatch
and get-state
(in a vector).
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(def store (collaj/create-store (constantly 13) collaj.en/with-fn-dispatch))
((:dispatch store) (fn overflow-the-stack [[dispatch get-state]]
(if (= (get-state) 14)
(dispatch [:didn't-blow-up])
(dispatch overflow-the-stack))))
;; StackOverflowError
This allows you to dispatch a core.async/chan
. Values placed on the channel will be dispatched as they happen until
the channel is closed.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[clojure.core.async :as async]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(def store (collaj/create-store my-reducer 13 collaj.en/with-chan-dispatch))
(def dispatch-chan (async/chan))
((:dispatch store) dispatch-chan)
(async/go
(async/>! dispatch-chan [:some-event {:some :data}])
(async/>! dispatch-chan [:some-othe-event {:some [:other :data]}]))
A middleware for "peeking" at the action being dispatched and the resulting state.
(ns my-namespace.core
(:require [com.ben-allred.collaj.core :as collaj]
[com.ben-allred.collaj.enhancers :as collaj.en]))
(def middleware (rdex/with-log-middleware
(comp log/debug (partial str "dispatching action: "))
(comp log/debug (partial str "updated state is: "))
(def store (collaj/create-store my-reducer 13 middleware))
You can make your own custom enhancer. An enhancer is a function that accepts the next
enhancer in the middleware
chain and returns a function that accepts the reducer
and initial-state
and returns a store.
(defn do-nothing-enhancer [next]
(fn [reducer initial-state]
(next reducer initial-state)))
(defn hijack-enahncer [next]
(fn [reducer initial-state]
(assoc (next (constantly :muaaahahahaha!) nil) :favorite-number 17)))
There is an apply-middleware
function in the core namespace that takes one or more dispatch middleware functions and
creates an enhancer to be used when creating your store. A dispatch middleware is a function that accepts the get-state
function and returns a function that accepts the next middleware in the dispatch middleware chain, and returns a new
dispatching function.
(defn do-nothing-dispatch-middleware [get-state]
(fn [next]
(fn [action]
(next action))))
(defn hijack-dispatch-middleware [get-state]
(fn [next]
(fn [action]
(next [:hijacked-action]))))
(def enhancer (collaj/apply-middleware
do-nothing-dispatch-middleware
hijack-dispatch-middleware))
There is a create-local-store
function which works the same as create-store
accept it takes another dispatch
function as the first argument. The resulting store will give you a new dispatch
function which will interact with
your local store and also call the supplied dispatch
function. Consider using the same middleware for both stores if
you want to be able to dispatch values that are not in the default format.
(def store (collaj/create-store reducer))
(def local-store (:dispatch store) local-reducer)
;; calling (:dispatch local-store) will also call (:dispatch store) with the same value
There are also a create-custom-store
and create-custom-local-store
functions which work the same way, except that
they take an additional first argument that will be used to create the ref value that generates the store. If you are
interested in using a custom ref type other than clojure.core/atom
, use these functions to create your store.
(def store (collaj/create-custom-store some.other.ref/function reducer initial-state enhancer))
(def sub-store (collaj/create-custom-local-store some.other.ref/function (:dispatch store) reducer))