From 16eb5d462da5218f00aaab902501abefa4b89535 Mon Sep 17 00:00:00 2001 From: Kimo Knowles Date: Tue, 21 Nov 2023 03:27:22 +0100 Subject: [PATCH] [docs] Polish & update --- docs/Flows.md | 92 ++++++++++++++--------------- docs/flows-advanced-topics.md | 105 ++++++++++++++++++++++++---------- src/re_frame/flow/alpha.cljc | 2 +- 3 files changed, 119 insertions(+), 80 deletions(-) diff --git a/docs/Flows.md b/docs/Flows.md index cfd05a352..3735ff0f6 100644 --- a/docs/Flows.md +++ b/docs/Flows.md @@ -335,21 +335,7 @@ For this, we use a `:live?` function. The quote above deals with phenomenal life, but you can also think of `:live?` as in a tv or internet broadcast. Data flows, but only when the flow itself is live. -Here's another room area flow: - -
-(rf/reg-flow - {:id :kitchen-area - :inputs {:w [:kitchen :width] - :h [:kitchen :length]} - :output (fn [{:keys [w h]}] (* w h)) - :path [:kitchen :area] - :live-inputs {:tab [:tab]} - :live? (fn [{:keys [tab]}] - (= tab :kitchen))}) -
- -A barebones tab picker, and something to show us the value of `app-db`: +Let's try it out. For example, here's a barebones tab picker, and something to show us the value of `app-db`:
(def tabs [:kitchen :garage]) @@ -382,15 +368,25 @@ A barebones tab picker, and something to show us the value of `app-db`: ### Live? -Here's a more advanced version of our kitchen calculator flow. -This replaces our first `:kitchen-area` flow, since it has the same `:id`: - +Here's a more advanced version of our room calculator flow. -Notice the different types of inputs. `:w [:kitchen :width]` represents an input as an `app-db` path, while `:tab :current-tab` identifies the value from the `:current-tab` flow we defined earlier. +
+(rf/reg-flow + {:id :kitchen-area + :inputs {:w [:kitchen :width] + :h [:kitchen :length]} + :output (fn [{:keys [w h]}] (* w h)) + :path [:kitchen :area] + :live-inputs {:tab [:tab]} + :live? (fn [{:keys [tab]}] + (= tab :kitchen))}) +
-Also, notice the new `:tab` input, and the new `:live?`. +Notice the new `:live-inputs` and `:live?` keys. +Just like `:output`, `:live:?` is a function of the resolved `:live-inputs`. -Just like `:output`, `:live:?` is a function of `app-db` and the `:inputs`. Re-frame only calculates the `:output` when the `:live?` function returns a truthy value. Otherwise, the flow is presumed dead. +Re-frame only calculates the `:output` when the `:live?` function returns a truthy value. +Otherwise, the flow is presumed dead. Let's test it out: @@ -410,7 +406,8 @@ Let's test it out:
-Try switching tabs. Notice how `:area` only exists when you're in the `room-calculator` tab. What's happening here? +Try switching tabs. +Notice how the path `[:kitchen :area]` only exists when you're in the `room-calculator` tab. What's happening here? ### Lifecycle @@ -419,14 +416,12 @@ Depending on the return value of `:live?`, re-frame handles one of 4 possible st | transition | action | |---|---| -| From **live** to **live** | run `:output` | -| From **dead** to **live** | run `:init` and `:output` | +| From **live** to **live** | run `:output` (when `:inputs` have changed) | +| From **dead** to **live** | run `:output` | | From **live** to **dead** | run `:cleanup` | | From **dead** to **dead** | do nothing | -Basically, *living* flows get output, *dying* flows get cleaned up, *arising* flows get initiated and output. - -And independently of all this, `:output` only runs when `:inputs` have changed value. +Basically, *arising* flows get output, *living* flows get output as needed, and *dying* flows get cleaned up. ### Cleanup @@ -457,32 +452,32 @@ Not only do flows have a lifecycle (defined by `:live?`, `:init` and `:cleanup`) Here's another demonstration. Think of it as a stripped-down todomvc. You can add and remove items in a list: +
-(rf/reg-sub ::items :-> (comp reverse ::items)) +(rf/reg-sub :items :-> (comp reverse :items)) (rf/reg-event-db ::add-item - (fn [db [_ id]] (update db ::items conj id))) + (fn [db [_ id]] (update db :items conj id))) (rf/reg-event-db ::delete-item - (fn [db [_ id]] (update db ::items #(remove #{id} %)))) + (fn [db [_ id]] (update db :items #(remove #{id} %)))) (defn item [id] [:div "Item" id]) (defn items [] - (into [:div] (map item) @(rf/subscribe [::items]))) + (into [:div] (map item) @(rf/subscribe [:items]))) (defn controls [] - (let [id (atom 0)] - (fn [] - [:div - [:span {:style clickable - :on-click #(do (rf/dispatch [::add-item (inc @id)]) - (swap! id inc))} "Add"] " " - [:span {:style clickable - :on-click #(do (rf/dispatch [::delete-item @id]) - (swap! id dec))} "Delete"] " "]))) + (let [id (or (apply max @(rf/subscribe [:items])) 0)] + [:div + [:span {:style clickable + :on-click #(rf/dispatch [::add-item (inc id)])} + "Add"] " " + [:span {:style clickable + :on-click #(rf/dispatch [::delete-item id])} + "Delete"] " "])) (defonce item-counter-basic-root (rdc/create-root (js/document.getElementById "item-counter-basic"))) @@ -516,10 +511,9 @@ It builds a flow that validates our item list against the requirements:
(defn error-state-flow [{:keys [min-items max-items] :as requirements}] - {:id ::error-state - :path [::error-state] - :inputs {:items [::items] - :tab (rf/flow<- :current-tab)} + {:id :error-state + :path [:error-state] + :inputs {:items [:items]} :output (fn [{:keys [items]}] (let [ct (count items)] (cond @@ -535,15 +529,15 @@ And register a flow that fits our base requirements:
Now this flow is calculating an error-state value, and adding it to `app-db` after every event. -This happens as long as the `::items` have changed... right? -Actually, there's another way to make a flow recalculate - we can reregister it. +This happens as long as the `:items` have changed... right? +Actually, there's another way to make a flow recalculate - we can re-register it. Let's update the app to display our new error state:
(defn warning [] - (let [error-state (rf/sub :flow {:id ::error-state})] + (let [error-state (rf/sub :flow {:id :error-state})] [:div {:style {:color "red"}} (->> @error-state (get {:too-many "Too many items. Please remove one." @@ -594,9 +588,9 @@ And a corresponding event, which triggers our `:reg-flow` effect: What happens after `:reg-flow` runs? Are there now two flows? Actually, no. - If you register a new flow with the same `:id`, it replaces the old one. -- When we trigger `[:reg-flow (error-state-flow ...)]` +- When we trigger `[:reg-flow (error-state-flow ...)]`: - The old `:error-state` flow runs `:cleanup` - - The new `:error-state` flow runs `:init` and `:output` + - The new `:error-state` flow runs `:output` Not only does changing the inputs lead to new output, but so does changing the flow itself. Let's test it out: diff --git a/docs/flows-advanced-topics.md b/docs/flows-advanced-topics.md index fc259b270..a3ad0631f 100644 --- a/docs/flows-advanced-topics.md +++ b/docs/flows-advanced-topics.md @@ -51,19 +51,54 @@ Introducing yet another demo app! Turns out, we were measuring the kitchen to fi :ct num-bags-to-buy}]]})))
-How can we get a correct value for `num-balloons-to-fill-kitchen`? You might try calling `(rf/subscribe [::num-balloons-to-fill-kitchen])`, but re-frame comes back with a warning about reactive context, and memory leaks... oh my! +How can we get a correct value for `num-balloons-to-fill-kitchen`? +You might try calling `(rf/subscribe [::num-balloons-to-fill-kitchen])`, but re-frame comes back with a warning about reactive context, +and memory leaks... oh my! ### Reactive context -We express some [business logic in subscriptions](https://github.com/day8/re-frame/issues/753), and some in events, but they're not really compatible. -Between subscriptions and events, there is a [coloring problem](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/). +To know if a thing has changed, you have to remember what it was. +To propagate change from one value to the next, you have to remember the relationship between them (a [`watchable`](https://clojuredocs.org/clojure.core/add-watch)). +Remembering is a side-effect. -Subscriptions can only be accessed within a [reactive context](/re-frame/FAQs/UseASubscriptionInAnEventHandler). -Since an event handler isn't reactive, it can't access any subscriptions. +Reagent does this. Its main constructs - *reactive atom*, and *component* - are stateful, impure. +We depend on this memory. It abstracts the essential complexity of reactive programming. -Furthermore, subscriptions have an `input-signals` function. This allows the value of one subscription to flow into another. But events have no such thing. +Reagent manages atoms and components with an event loop. Only in the context of this loop can be sure reagent's memory is consistent. +Literally, this is called [`*ratom-context*`](https://github.com/reagent-project/reagent/blob/a14faba55e373000f8f93edfcfce0d1222f7e71a/src/reagent/ratom.cljs#L12). -That means, to get a usable value for `num-balloons-to-fill-kitchen`, we have to duplicate the business logic that we wrote into our subscription, along with the *entire* subgraph of inputs which our subscription is composed of: +Generally, `*ratom-context*` only has value during the evaluation of a component function (i.e., at "render time"). +When `*ratom-context*` has no value, reactive atoms behave differently. + +You can simply call [`reagent.ratom/reactive?`](http://reagent-project.github.io/docs/master/reagent.ratom.html#var-reactive.3F) +to find out whether your code is running in a reactive context. + +#### Reactive context in re-frame + +Now, here's where re-frame enters the picture: + +- An **event handler** is a pure function, with no reactive context (it has an [interceptor](/re-frame/Interceptors) context). +- A **subscription**, on the other hand, is a reactive atom (with *no* interceptor context). +- Calling `subscribe` has the side-effect of *creating* a **subscription**. + +Outside of a component function, a subscription's behavior differs: +Not only the behavior of the reactive atom, but also the behavior of re-frame's subscription [caching](#caching) mechanism. + +#### What this means for your app + +Subscriptions and event handlers differ in purity and runtime context. +This means they have a [coloring problem](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/). + +We [express some business logic with subscriptions](https://github.com/day8/re-frame/issues/753), and some with events. +This introduces the coloring problem to our business domain. + +We can ignore the problem in [some cases](https://github.com/day8/re-frame/issues/740#issuecomment-955749230), +but the essential consequence of calling `subscribe` in an event handler is an unsafe cache. +Calling `subscribe` allocates physical memory on the client, and re-frame has no way to deallocate it. +This puts us back in C territory. + +Thus, to safely get a value for `num-balloons-to-fill-kitchen`, we have to duplicate the business logic that we wrote into our subscription, +along with the *entire* subgraph of subscription inputs:
(rf/reg-event-fx @@ -72,7 +107,7 @@ That means, to get a usable value for `num-balloons-to-fill-kitchen`, we have to (let [kitchen-area (get-in db [:kitchen :area]) kitchen-height (get-in db [:kitchen :height]) kitchen-volume (* area height) ;; eyelids start drooping here - std-balloon-volume 2.5 + std-balloon-volume 2.5 num-balloons (/ kitchen-volume std-balloon-volume) num-bags-to-buy (js/Math.ceil (/ num-baloons balloons-per-bag))] @@ -88,33 +123,37 @@ We sympathize with you developers, for the hours you may have spent poring over ### Caching -Subscriptions have a hidden caching mechanism, which stores the value as long as there is a component in the render tree which uses it. -Basically, when components call `subscribe` with a particular `query-v`, re-frame sets up a callback. -When those components unmount, this callback deletes the stored value. -It removes the subscription from the graph, so that it will no longer recalculate. +Subscriptions have a hidden caching mechanism, which stores the value as long as there is a component in the render tree which uses it. +Basically, when components call `subscribe` with a particular `query-v`, re-frame sets up a callback. +When those components unmount, this callback deletes the stored value. +It removes the subscription from the graph, so that it will no longer recalculate. This is a form of [reference counting](https://en.wikipedia.org/wiki/Reference_counting) - once the last subscribing component unmounts, then the subscription is freed. -This often works as intended, and nothing gets in our way. -It's elegant in a sense - a view requires certain values, and those values only matter when the view exists. And vice versa. -But when these values are expensive to produce or store, their existence starts to matter. -The fact that some view is creating and destroying them starts to seem arbitrary. +This often works as intended, and nothing gets in our way. +It's elegant in a sense - a view requires certain values, and those values only matter when the view exists. And vice versa. +But when these values are expensive to produce or store, their existence starts to matter. +The fact that some view is creating and destroying them starts to seem arbitrary. Subscriptions don't *need* to couple their behavior with that of their calling components. -The easy, automatic lifecycle behavior of subscriptions comes with a coupling of concerns. You can't directly control this lifecycle. +The easy, automatic lifecycle behavior of subscriptions comes with a coupling of concerns. You can't directly control this lifecycle. You have to contol it by proxy, by mounting and unmounting your views. You can't *think* about your signal graph without thinking about views first. -The `app-db` represents your business state, and signals represent outcomes of your business logic. Views are just window dressing. +The `app-db` represents your business state, and signals represent outcomes of your business logic. Views are just window dressing. We're tired of designing our whole business to change every time we wash the windows! ### Paths -A [layer-2](/re-frame/subscriptions/#the-four-layers) subscription basically *names* an `app-db` path. What does a layer-3 subscription *name*? +A [layer-2](/re-frame/subscriptions/#the-four-layers) subscription basically *names* an `app-db` path. +What does a layer-3 subscription *name*? -A materialized view, or a derived value. +A materialized view of data, or a derived value. -Subscriptions occupy their own semantic domain, separate from `app-db`. Only within view functions (and other subscriptions) can we access this domain. Outside of views, they form an impenetrable blob. +Subscriptions occupy their own semantic territory, separate from `app-db`. +Only within view functions (and other subscriptions) can we access this domain. +Outside of views, they form an impenetrable blob. -So, re-frame is simple. `app-db` represents and *names* the state of your app. Except, so does this network of subscription names. But you can't really *use* those, so just forget about it. +So, re-frame is simple. `app-db` represents and *names* the state of your app. +Except, so does this network of subscription names. But you can't always *use* those, only sometimes. ### Statefulness @@ -165,18 +204,24 @@ Why not simply have *everything* derive from `app-db`? ### A better way -Here's the good news about flows: +Here's the good news about [flows](/re-frame/Flows): -__You can access a flow's output value any time, anywhere,__ since flows are controlled by re-frame/interceptors, not reagent/reactions. +__You can access a flow's output value any time, anywhere,__ +since flows are controlled by re-frame/interceptors, not reagent/reactions. Instead of thinking about reactive context, just think about the outcome of the latest event. +If you know `app-db`, you know your flow value. +You can also [subscribe to flows](/re-frame/Flows/#subscribing-to-flows). -__If you know a flow's name, you know its output location,__ since flows store their output in `app-db`, at a static path. -It doesn't matter how many other flows that flow depends on. The correct value simply stays where you put it. +__If you know a flow's name, you know its location,__ +since flows store their output in `app-db`, at a static path. +It doesn't matter what other flows & paths it depends on. +The value you need simply stays where you put it. -__A flow's lifecycle is a pure function of `app-db`__. -That means you explicitly define when a flow lives, dies, is registered or cleared. You do this directly, not via your component tree. +__A flow's lifecycle is a pure function of `app-db`__. +That means you explicitly define when a flow lives, dies, is registered or cleared. +You do this directly, not via your component tree. -Like many Clojure patterns, flows are *both* nested *and* flat. +Like many Clojure patterns, flows are *both* nested *and* flat. Even though `::num-balloons-to-fill-kitchen` depends on other flows, we can access it directly:
@@ -198,7 +243,7 @@ Even though `::num-balloons-to-fill-kitchen` depends on other flows, we can acce (rf/reg-event-fx ::order-ballons-for-kitchen-prank (fn [{:keys [balloons-per-bag] :as cofx} _] - (let [num-balloons (rf/flow-output db ::num-balloons-to-fill-kitchen) ;; easy! + (let [num-balloons (rf/get-flow db ::num-balloons-to-fill-kitchen) ;; easy! num-bags-to-buy (js/Math.ceil (/ num-balloons balloons-per-bag))] diff --git a/src/re_frame/flow/alpha.cljc b/src/re_frame/flow/alpha.cljc index b7a7524e3..6bf483022 100644 --- a/src/re_frame/flow/alpha.cljc +++ b/src/re_frame/flow/alpha.cljc @@ -99,7 +99,7 @@ (swap! flows empty)) ([id] (when-let [flow (lookup id)] - (swap! flows dissoc flow) + (swap! flows dissoc id) (swap! flows vary-meta update ::cleared assoc (:id flow) flow)))) (defn flow<- [id] {::flow<- id})