Flows
This is an experimental, proposed feature for re-frame. You can use flows today, with the "re-frame alpha" We'd love to hear your feedback! Please join our discussions on github and slack.
This tutorial introduces Flows, part of Domino 3 (effects).
Not to be confused with...
- re-frame-async-flow-fx. A
re-frame/flowis totally synchronous, running on every event. - The on-changes interceptor. Flows are an evolution of this idea.
- domino. Another take on dataflow programming, inspired by re-frame.
What are flows?¶
A flow describes how to derive a value from other values.
When one part of your app state changes, another part changes in response.
More concretely, when the values change at one or more paths within app-db,
then the value at another path is "automatically" recalculated.
Why do we need flows?¶
We turn to flows when we need a dynamic relationship between values - a "difference which makes a difference".
For instance, how would you model this problem?
- a
lengthand awidthmake anarea - changing either value changes the
area - deleting either value deletes the
area - a bad value invalidates the
area - leaving the page deletes the
area
In re-frame, data coordinates functions.
Here, we need multiple data sources (length, width) to coordinate a single function (area).
A subscription could do this, but with caveats.
We think flows offer a Better Way, both simpler and more practical.
The DataFlow Paradigm
Dataflow programming emerged in the 1970s, so it is almost as foundational as functional programming.
Indeed, reactive programming - so much the rage these days - is simply a subset of dataflow programming.
In contrast with imperative building blocks like if/then, next and goto,
dataflow programming implements control flow via the propagation of change.
Both the functional and dataflow paradigms have profoundly influenced the design of re-frame.
Hence, re-frame's tagline: "derived data, flowing".
A Basic Flow¶
Here's a basic flow. It describes how to derive the area of a room from its dimensions:
:id- uniquely identifies this flow.:inputs- a map ofapp-dbpaths to observe for change.:output- calculates the new derived value.- Takes a map of resolved inputs.
- Simply takes
app-dbif there are no inputs.
:path- denotes where the derived value should be stored.
On every event, when the values at :inputs change, :output is run, and the result is stored in app-db at :path.
A Basic Example¶
To show Flows in action, let's do some live coding.
First, we add the necessary requires (reg-flow is still in the alpha namespace):
And, here's the code for our app: the user can enter height and width values and, in response, they see area:
Registering a flow¶
Now the interesting part, we use reg-flow:
Arity-2 version
In addition to (reg-flow flow), you can also call (reg-flow id flow).
This gives it a signature just like the usual reg-event- and reg-sub calls.
Our example would look like (reg-flow :garage-area {...}).
We write a subscription for the flow's output :path:
And, we use this subscription in a view:
How does it work?¶
event handlers yield effects. Typically, they yield a :db effect, causing a new value of app-db.
But first, re-frame updates your :db effect by running each registered flow.
Caution: implicit behavior ahead
Here, the tradeoff becomes clear. A flow can change app-db implicitly.
This means the :db effect which you express in your event handlers may not match the actual app-db you'll get as a result.
Re-frame achieves this using an interceptor. Here's what it does:
- Destructure the current
app-db, resolving the paths in:inputs- this yields a value like
{:w 10 :h 15}.
- this yields a value like
- Destructure the previous
app-dbas well, to see if any of these values have changed.- For instance, if it sees
{:w 11 :h 24}, that means the inputs have changed. {:w 10 :h 15}would mean no change.
- For instance, if it sees
- If the inputs have changed:
- Call the
:outputfunction, passing it the previous result, and the current:inputs. - Store the newly derived value (in this case,
150) inapp-db, at the:path.
- Call the
Isn't that remarkable? What, you say it's unremarkable? Well, that's even better.
Remarks¶
Reality check. Here's why this basic flow might not excite you:
Can't I just use events?¶
Re-frame can already set values. Events were the one true way to update
app-db. Why invent another mechanism for this?
In this sense, they are redundant. Rather than use a flow, you could simply call a derive-area within each relevant event:
This works just fine... or does it? Actually, we forgot to change the :length event. Our area calculation will be wrong every time the user changes the length! Easy to fix, but the point is that we had to fix it at all. How many events will we need to review? In a mature app, this is not a trivial question.
Design is all tradeoffs. Flows allow us to say "This value simply derives from these inputs. It simply changes when they do." We do this at the expense of some "spooky action at a distance" - in other words, we accept that no particular event will be responsible for that change.
Are flows just reactions?¶
You might notice a similarity with reagent.core/reaction. Both yield an "automatically" changing value.
Reagent controls when a reaction updates, presumably during the evaluation of a component function.
Flows, on the other hand, are part of re-frame time, running every time an event occurs.
When a component derefs a reaction, that component knows to re-render when the value changes.
You can't deref a flow directly. It doesn't emit a value directly to any caller.
Instead, it emits a new version of app-db. The rest of your app reacts to app-db, not your flow.
But really, why do I need flows?¶
Some apps do complex tasks, with deep layers of branching and looping. But most apps do simple things, as well. Many such tasks amount to synchronization - maintaining an invariant within a changing data structure.
And of course, a task which seems complex may just be a chain of simple tasks.
One relatable example is that of trying to maintain cascading error states. Imagine your UI has a validation rule: start date must be before end date.
After the user changes either value, the error state must be calculated.
The result indicates whether to enable the submit button or display an error message.
Now, imagine your UI has many validation rules, each with its own error state. In this case, the submit button state is a secondary calculation which combines these error states. Cascading, derived values.
Data flows from the leaves (what the user entered), through intermediate nodes (error predicate functions), through to the root (submit button state). Both the intermediate values and the root value are important.
Is this a rules engine?¶
You might be tempted to view Flows as having something to do with a rules engine, but it absolutely isn't that. It is simply a method for implementing dataflow. Each value is derivative of other values, with multiple levels of that process arranged in a tree structure in which many leaf values contribute to a terminal root value (think submit button state!).
Can't I just use subscriptions?¶
You could derive your garage's area with a layer-3 subscription:
Just like a flow, this subscription's value changes whenever the inputs change, and (obviously) you call subscribe to access that value.
A flow stores its :output value in app-db, while subscriptions don't. We designed re-frame on the premise that app-db holds your entire app-state.
Arguably, derived values belong there too. We feel there's an inherent reasonability to storing everything in one place.
It's also more practical (see Reactive Context).
Just like with layered subscriptions, one flow can use the value of another. Remember the :inputs to our first flow?
Layering flows¶
In the values of the :inputs map, vectors stand for paths in app-db.
The flow<- function, however, gives us access to other flows.
Here's a flow using two other flows as inputs: ::kitchen-area and ::living-room-area.
When either input changes value, our flow calls the :output function to recalculate its own value:
As before, once :output runs, the resulting value is stored at :path.
So, the new value of app-db will contain a number at the path [:ratios :main-rooms]
Under the hood, flows relate to each other in a dependency graph.
An input like (rf/flow<- ::kitchen-area) creates a dependency.
That means re-frame will always run ::kitchen-area first,
ensuring its output value is current before your :main-room-ratio flow can use it.
Our dataflow model
Dataflow is often conceptualized as a graph.
Data flows through edges, and transforms through nodes.
Here's how our DSL articulates the traditional dataflow model:
flow- a map, serving as a node specification:id- uniquely identifies a node:inputs- a set of edges from other nodesflow<-- declares another node id as an input dependencyreg-flow- creates a running node from a specification
Crucially, the name flow isn't exactly short for "dataflow".
A flow is a static value, specifying one possible segment of a dataflow graph.
Dataflow is a dynamic process, not a value.
Both the data and the graph itself can change over time.
- Changing the data means running the flows which are currently registered.
- Changing the graph is a matter of registering and clearing flows.
Subscribing to flows¶
In our examples so far, we've used a regular subscription, getting our flow's output path.
In re-frame.alpha, you can also subscribe to a flow by name.
This bypasses the caching behavior of a standard subscription.
Here's how you can subscribe to our garage-area flow. The stable way, with a query vector:
And the experimental way, with a query map:
Living and Dying¶
Between death... and arising... is found an existence— a "body"... that goes to the place of rebirth. This existence between two realms... is called intermediate existence.
-- Vasubandhu, on bardos
In practice, some flows are too expensive to run all the time.
It can still be hard to predict when a flow will run, leading to defensive programming.
Sometimes we'd like to simply turn our flow off, so we can stop thinking about it.
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.
Let's try it out. For example, here's a barebones tab picker, and something to show us the value of app-db:
Live?¶
Here's a more advanced version of our room calculator flow.
Notice the new :live-inputs and :live? keys.
Just like :output, :live:? is a function of the resolved :live-inputs.
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:
Try switching tabs.
Notice how the path [:kitchen :area] only exists when you're in the room-calculator tab. What's happening here?
Lifecycle¶
After handling an event, re-frame runs your flows. First, it evaluates :live?, using the new app-db.
Depending on the return value of :live?, re-frame handles one of 4 possible state transitions:
| transition | action |
|---|---|
| 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, arising flows get output, living flows get output as needed, and dying flows get cleaned up.
Cleanup¶
A :cleanup function takes app-db and the :path, and returns a new app-db.
Try adding this :cleanup key into the :kitchen-area flow above (be sure to eval the code block again).
By default, :cleanup dissociates the path from app-db. By declaring this :cleanup key in your flow, you override that default function. Now, instead of removing :area, you set it to :unknown!.
Now, is this a good idea? After all, we might consider the area known, as long as we know the width and length. Maybe we should do no cleanup, and keep the value, even when :live? returns false. In that case, our :cleanup function would simply be: :cleanup (fn [db _] db).
The point is, you express when the signal lives or dies, not your render tree.
Redefining and Undefining¶
Not only do flows have a lifecycle (defined by :live? and :cleanup), but this lifecycle also includes registration and deregistration.
- When you call
reg-flow, that flow comes alive.:outputruns, even if the inputs haven't changed.- That's because the flow itself has changed.
- When you call
clear-flow, it dies (running:cleanup). - Re-frame provides
:reg-flowand:clear-floweffects for this purpose.
Here's another demonstration. Think of it as a stripped-down todomvc. You can add and remove items in a list:
Now, imagine your business adds some requirements:
- At least 1 item per person.
- No more than 3 items per person.
First things first, we express these requirements as data:
Then, we'll use a flow to evaluate which requirements are met.
State, not events
These requirements aren't about what happens, only what things are. It's your app state that matters, not any particular event or view. Our flow doesn't care how it happened that a requirement was met, nor what to do next.
For reasons that will become clear, let's write a factory function for this flow. It builds a flow that validates our item list against any given requirements:
And let's 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 whenever :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:
Your app is working fine, until your next design meeting. Now they want a way to change the max item limit. A little contrived, I know. But not uncommon from a programming perspective.
Luckily, our flow factory can make a new flow for any requirement.
Therefore, putting in this feature is just a matter of triggering the :reg-flow effect.
We build a basic form to change the requirement:
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 ...)]:- The old
:error-stateflow runs:cleanup - The new
:error-stateflow runs:output
- The old
Not only does changing the inputs lead to new output, but so does changing the flow itself. Let's test it out: