├── .gitignore ├── README.md ├── deps.edn └── src └── re_state ├── core.cljc └── flow.cljc /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache 2 | .nrepl-port 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # re-state 2 | 3 | Use state machines to describe your re-frame application. Based on the [SAM 4 | Pattern](http://sam.js.org/) and 5 | [Restate your UI: Creating a user interface with re-frame and state machines](http://blog.cognitect.com/blog/2017/8/14/restate-your-ui-creating-a-user-interface-with-re-frame-and-state-machines). 6 | 7 | ## Overview 8 | 9 | The main concept of the library is the "flow". This is a map of control state 10 | keys to control state maps, where a control state map has the following keys: 11 | 12 | - `:intents`: A map of intent to proposed next states 13 | - `:preds`: A map of predicate keywords 14 | - `:effects`: A map of effects predicate keywords 15 | 16 | Below you'll find a description of each. 17 | 18 | ### `:intents` 19 | 20 | ```clojure 21 | {nil {:intents {:init #{:empty}}} 22 | 23 | :empty {:intents {:add-water #{:in-between}} 24 | :preds #{:empty?}} 25 | 26 | :in-between {:intents {:add-water #{:in-between :full} 27 | :drink #{:in-between :empty}} 28 | :preds #{:not-filled? :not-empty?}} 29 | 30 | :full {:intents {:drink #{:in-between}} 31 | :preds #{:filled?}}} 32 | ``` 33 | 34 | The intents map an event intent to possible next states. 35 | 36 | ### `:preds` 37 | 38 | ```clojure 39 | {nil {:intents {:init #{:empty}}} 40 | 41 | :empty {:intents {:add-water #{:in-between}} 42 | :preds #{:empty?}} 43 | 44 | :in-between {:intents {:add-water #{:in-between :full} 45 | :drink #{:in-between :empty}} 46 | :preds #{:not-filled? :not-empty?}} 47 | 48 | :full {:intents {:drink #{:in-between}} 49 | :preds #{:filled?}}} 50 | ``` 51 | 52 | `:preds` are a set of transition predicate keywords. These keywords are 53 | registered using the multimethod `accept?`. Each must be true for the proposed 54 | state to be accepted. 55 | 56 | For example if in the above flow we were to be in the `:in-between` state with 57 | the intent of `:add-water` we'd have the two possible next states of 58 | `:in-between` and `:full`. To determine which is the next valid state we pass a 59 | context to the predicate functions registered for the predicates keywords of 60 | `:in-between` and `:full`. This context looks like the following: 61 | 62 | ```clojure 63 | {:prev-db {:v 1} ;; the previous db 64 | :next-db {:v 2} ;; the next proposed db 65 | :event [:add-water] ;; the event, a vector of [intent & values] 66 | :prev-state :in-between} ;; the previous state 67 | ``` 68 | 69 | To implement the predicates `:filled?` and `:not-filled?` we `defmethod` 70 | `re-state.core/accept?`: 71 | 72 | ```clojure 73 | (defmethod re-state.core/accept? :filled? [_ {:keys [next-db]}] 74 | (= (:v next-db) 10)) 75 | 76 | (defmethod re-state.core/accept? :not-filled? [_ {:keys [next-db]}] 77 | (< (:v next-db) 10)) 78 | ``` 79 | 80 | Of course the value `10` could be something stored in our db as well, possibly 81 | set during the `:init` event. 82 | 83 | In this case `next-db` with `:v` equal to `2` would mean we are still in an 84 | `:in-between` state. 85 | 86 | ### `:effects` 87 | 88 | Using a different example than those above, here is what a flow for 89 | launching a rocket with a countdown might look like: 90 | 91 | ```clojure 92 | {nil {:intents {:init #{:ready}}} 93 | :ready {:intents {:start #{:counting}} 94 | :preds #{:counter-max? :not-aborted? :not-started?}} 95 | :counting {:intents {:decr-counter #{:launched :counting} 96 | :abort #{:aborted}} 97 | :preds #{:started?} 98 | :effects #{:countdown}} 99 | :aborted {:preds #{:aborted?}} 100 | :launched {:preds #{:counter-zero?}}} 101 | ``` 102 | The transition effects are a set of transition effect keywords. 103 | 104 | The transition effects handler should return a map of effects, like those in 105 | re-frame. To implement transition effects we `defmethod` 106 | `re-state.core/effects`. 107 | 108 | ```clojure 109 | (defmethod re-state.core/effects :countdown [_ _] 110 | {:dispatch [:decr-counter]}) 111 | ``` 112 | 113 | After the `:preds` are used to determine what the next valid state to accept is, 114 | the `:effects` are checked using the accepted state and if a handler is found it 115 | is called and its effects are merged. 116 | 117 | Like the transition predicate, the transition effects handler takes a context. 118 | It has the same keys as the transition predicate context, but also includes a 119 | `:next-state` key with the accepted state. 120 | 121 | ## re-frame interceptor 122 | 123 | - `interceptor`: an interceptor that will use the `:re-state.core/flow`, 124 | associng in a `:re-state.core/state` key into your db with the next state as 125 | well as adding to the re-frame context any transition effects. 126 | 127 | Usage is simple. Just add the interceptor to your `reg-event-x` declarations 128 | where the event name is the same key as the intent in the registered maps. The 129 | handler then returns a new proposed db. 130 | 131 | For example: 132 | 133 | ```clojure 134 | (reg-event-db 135 | :add-water 136 | [rs/interceptor] 137 | (fn [db _] 138 | (update-in db [:v] inc))) 139 | ``` 140 | 141 | Note that it is still possible to return effects other than `db` using 142 | `reg-event-fx` but since they could be overridden by those returned from a 143 | transition effects handler it is suggested you avoid that. 144 | 145 | ### Setup 146 | 147 | You'll have to initally bootstrap your application with an event that will set 148 | the `:re-state.core/flow` for your app. For example: 149 | 150 | ```clojure 151 | (rf/reg-event-fx 152 | :bootstrap 153 | (fn [cofx event] 154 | {:re-state.core/flow app-flow})) 155 | ``` 156 | 157 | Once you do this your events can start using the interceptor. 158 | 159 | ### Dynamically adding flows 160 | 161 | As you noticed above we can create an effect which will set the flow. This means 162 | we can modify the flow of the app later, merging more flows into it, growing the 163 | capabilities of the app. This is useful if you were to store flows in a database 164 | and send them via ajax to your app. The `re-state.flow` namespace provides a 165 | useful function to facilitate this: `merge-flows`. It takes one or more flows as 166 | arguments and merges them into a single flow. 167 | 168 | ### Many effects 169 | 170 | It is intuitive to be able to return multiple effects, but how do we resolve 171 | conflicts if lets say two effects return a `:dispatch` in their map? 172 | 173 | For this we have the multimethod `re-state.core/resolve-effects-conflict`. This 174 | takes the effects key and two values for that effect we must resolve by 175 | returning a vector of `[new-effect new-value]`. Included by default are handlers 176 | for `:dispatch` and `:dispatch-n`. These look the like the following: 177 | 178 | ``` 179 | (defmethod resolve-effects-conflict :dispatch [_ x y] 180 | [:dispatch-n [x y]]) 181 | 182 | (defmethod resolve-effects-conflict :dispatch-n [_ x y] 183 | [:dispatch-n (into x y)]) 184 | ``` 185 | 186 | Any app specific effects your app generates that could conflict can be handled 187 | this way. 188 | 189 | ## Deeper concepts 190 | 191 | There are few conceptual points to be aware of that will help when designing 192 | your apps. 193 | 194 | ### Control State 195 | 196 | We're using a somewhat unconventional use of the word "state", at least in the 197 | context of application development. In re-frame the db is what persists the 198 | stateful information in the app. But the states we refer to are more correctly 199 | called control states. 200 | 201 | Normally applications don't explicitly define their control states. That is, 202 | they don't try to give a name to any current state the app might be in. 203 | 204 | By defining our control states we are distilling a lot of information down into 205 | a unique keyword that we can used to build our app representation. In the 206 | article [Restate your UI: Creating a user interface with re-frame and state 207 | machines](http://blog.cognitect.com/blog/2017/8/14/restate-your-ui-creating-a-user-interface-with-re-frame-and-state-machines) 208 | it is pointed out that fragmenting the control state into several different 209 | properties leads to error prone code. We have to make sure all these different 210 | properties are always in sync, and do this across many different handlers. 211 | 212 | What we want in the end is a single value to determine what the current control 213 | state is and to use that to show potential next actions. In practice this means 214 | creating a re-frame subscription to the `:re-state.core/state` key in `db` and 215 | using that as the primary "switch" in your views. 216 | 217 | ### Events 218 | 219 | In a normal re-frame application events have quite a bit of power. With no 220 | interceptors to interpret them they have the ability to directly modify what 221 | will become the next db. 222 | 223 | With the interceptor provided in `re-state.core` we instead consider the 224 | updated db from an event handler to be only a proposal. This proposed db can be 225 | compared to the previous db in the transition predicates to determine the next 226 | control state. 227 | 228 | While we have to accept a new control state we do not have to commit a new 229 | proposed db. Consider a form where a user submits invalid information. Our event 230 | handler would give us the next db, which could for example involve putting the 231 | resulting values into an entities vector. In the invalid case we would not 232 | accept the next db and we can handle this by returning the value of `:prev-db` 233 | as `:db` in our transition effects handler. 234 | 235 | ```clojure 236 | (defn invalid-form-effects [{:keys [prev-db]}] 237 | {:db prev-db}) 238 | ``` 239 | 240 | ### Transition Predicates 241 | 242 | The transition predicates are the "why" of your application. If you were to 243 | reach an unexpected control state you would go back and refer to them to see the 244 | rules that were applied to determine that control state. 245 | 246 | How you design your transition predicates depends on the complexity of your db. 247 | You can optimize in cases where there are only a few known states, and 248 | determining which is the correct control state is easy, or you can thoroughly 249 | check the next-db against the prev-db to be absolutely certain what control 250 | state you're in. 251 | 252 | Writing your transition predicates using something like `core.logic` would be a 253 | good fit. Most apps probably wouldn't need that though, where simple boolean 254 | checks could be applied. 255 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:aliases {:repl {:extra-deps {org.clojure/tools.nrepl {:mvn/version "0.2.12"} 2 | reagent/reagent {:mvn/version "0.8.0-alpha2"} 3 | re-frame/re-frame {:mvn/version "0.10.2"}}}}} 4 | -------------------------------------------------------------------------------- /src/re_state/core.cljc: -------------------------------------------------------------------------------- 1 | (ns re-state.core 2 | (:require [clojure.set :refer [rename-keys]] 3 | [re-frame.core :as rf] 4 | [re-state.flow :as fl])) 5 | 6 | (defn get-intent [context] 7 | (get-in context [:coeffects :event 0])) 8 | 9 | (defn get-state [context] 10 | (get-in context [:coeffects ::state])) 11 | 12 | (defn get-flow [context] 13 | (get-in context [:coeffects ::flow])) 14 | 15 | (def effects-keys-alias 16 | {:db :next-db ::state :next-state}) 17 | 18 | (def coeffects-keys-alias 19 | {:event :event :db :prev-db ::state :prev-state}) 20 | 21 | (defn context->rs-context 22 | [context] 23 | (merge (rename-keys (:effects context) effects-keys-alias) 24 | (rename-keys (:coeffects context) coeffects-keys-alias))) 25 | 26 | (defmulti accept? 27 | "Checks if the transition predicate keyword provided as the first argument 28 | should be accepted in the context passed in the second argument." 29 | (fn [k _] k)) 30 | 31 | (defmulti effects 32 | "Returns the effects for the transition effects keyword provided as the first 33 | argument in the context passed in the second argument. Noop if k is nil." 34 | (fn [k _] k)) 35 | 36 | (defmethod effects nil [_ _] nil) 37 | 38 | (defmulti resolve-effects-conflict 39 | "Returns a vector of [effect-name effect-value] for the duplicate effects key 40 | found when collecting all effects." 41 | (fn [k _ _] k)) 42 | 43 | (defmethod resolve-effects-conflict :dispatch [_ x y] 44 | [:dispatch-n [x y]]) 45 | 46 | (defmethod resolve-effects-conflict :dispatch-n [_ x y] 47 | [:dispatch-n (into x y)]) 48 | 49 | (defn merge-effects-with [f m [k v]] 50 | (if (contains? m k) 51 | (recur f (dissoc m k) (f k (get m k) v)) 52 | (assoc m k v))) 53 | 54 | (defn with-context [f x context] 55 | (cond 56 | (vector? x) 57 | (f (first x) (assoc context :params (rest x))) 58 | 59 | :else 60 | (f x context))) 61 | 62 | (defn make-accept? [context] 63 | (let [rs-context (context->rs-context context)] 64 | (fn [intent state] 65 | (every? #(with-context accept? % rs-context) 66 | (fl/get-transition-predicates (get-flow context) intent state))))) 67 | 68 | (defn get-effects [context] 69 | (let [rs-context (context->rs-context context) 70 | intent (get-intent context) 71 | next-state (get-in context [:effects ::state])] 72 | (->> (fl/get-next-effects (get-flow context) intent next-state) 73 | (map #(with-context effects % rs-context)) 74 | (apply concat) 75 | (reduce (partial merge-effects-with resolve-effects-conflict) {})))) 76 | 77 | (defn get-next-states [context] 78 | (fl/get-next-states (get-flow context) 79 | (get-state context) 80 | (get-intent context))) 81 | 82 | (defn transition [context] 83 | {:post [(some? (get-in % [:effects ::state]))]} 84 | (let [accept? (make-accept? context) 85 | intent (get-intent context)] 86 | (assoc-in context [:effects ::state] 87 | (some #(when (accept? intent %) %) 88 | (get-next-states context))))) 89 | 90 | (defn next-effects [context] 91 | (update-in context [:effects] merge (get-effects context))) 92 | 93 | (defn assoc-db-state [context] 94 | (assoc-in context [:effects :db ::state] 95 | (get-in context [:effects ::state]))) 96 | 97 | ;; ------------------------- 98 | ;; INTERCEPTOR 99 | 100 | (defn interceptor-handler 101 | "Takes a re-frame context and returns a new context with potentially a new 102 | state in effects as well as any other effects the transition requires. 103 | 104 | Requires the following to be in coeffects: 105 | 106 | state - The current control state 107 | flow - The application flow 108 | " 109 | [context] 110 | (-> context 111 | (transition) 112 | (next-effects) 113 | (assoc-db-state))) 114 | 115 | (def interceptor 116 | [(rf/inject-cofx ::state) 117 | (rf/inject-cofx ::flow) 118 | (rf/->interceptor 119 | :id ::step 120 | :after #(interceptor-handler %))]) 121 | 122 | ;; ------------------------- 123 | ;; COFX 124 | 125 | (def app-state (atom nil)) 126 | 127 | (rf/reg-cofx ::state 128 | (fn [coeffects] 129 | (assoc coeffects ::state @app-state))) 130 | 131 | (rf/reg-fx ::state 132 | (fn [value] 133 | (reset! app-state value))) 134 | 135 | (def app-flow (atom nil)) 136 | 137 | (rf/reg-cofx ::flow 138 | (fn [coeffects] 139 | (assoc coeffects ::flow @app-flow))) 140 | 141 | (rf/reg-fx ::flow 142 | (fn [value] 143 | (reset! app-flow value))) 144 | 145 | (comment 146 | (require '[clojure.set :refer [rename-keys]]) 147 | (require '[re-state.flow :as fl]) 148 | 149 | (def rocket 150 | {nil {:intents {:init #{:ready}}} 151 | :ready {:intents {:start #{:counting}} 152 | :preds #{:counter-max? :not-aborted? :not-started?}} 153 | :counting {:intents {:decr #{:launched :counting} 154 | :abort #{:aborted}} 155 | :preds #{:started?} 156 | :effects #{:count-down}} 157 | :aborted {:preds #{:aborted?}} 158 | :launched {:preds #{:counter-zero?}}}) 159 | 160 | (def context 161 | {:effects {:db {:counter 0 :started? true}} 162 | :coeffects {:db {} 163 | :event [:decr] 164 | ::state :counting 165 | ::flow rocket}}) 166 | 167 | (defmethod accept? :counter-max? [_ {:keys [next-db]}] 168 | (= (:counter next-db) 10)) 169 | 170 | (defmethod accept? :not-aborted? [_ {:keys [next-db]}] 171 | (false? (:aborted? next-db))) 172 | 173 | (defmethod accept? :aborted? [_ {:keys [next-db]}] 174 | (true? (:aborted? next-db))) 175 | 176 | (defmethod accept? :not-started? [_ {:keys [next-db]}] 177 | (false? (:started? next-db))) 178 | 179 | (defmethod accept? :started? [_ {:keys [next-db]}] 180 | (true? (:started? next-db))) 181 | 182 | (defmethod accept? :counter-zero? [_ {:keys [next-db]}] 183 | (zero? (:counter next-db))) 184 | 185 | (defmethod effects :count-down [_ _] 186 | {:dispatch [:decr]}) 187 | 188 | (-> context 189 | (transition) 190 | (next-effects) 191 | (assoc-db-state)) 192 | ) 193 | -------------------------------------------------------------------------------- /src/re_state/flow.cljc: -------------------------------------------------------------------------------- 1 | (ns re-state.flow) 2 | 3 | (defn get-transition-predicates [flow intent state] 4 | (get-in flow [state :preds])) 5 | 6 | (defn get-next-effects [flow intent state] 7 | (get-in flow [state :effects])) 8 | 9 | (defn get-next-states [flow state intent] 10 | (get-in flow [state :intents intent])) 11 | 12 | (defn- merge-state-vals [l r] 13 | (cond 14 | (map? r) 15 | (merge-with into l r) 16 | 17 | :else 18 | (into l r))) 19 | 20 | (defn merge-flows 21 | [& flows] 22 | (apply merge-with (partial merge-with merge-state-vals) flows)) 23 | --------------------------------------------------------------------------------