Recent history in single-page applications (SPA) has seen the rise of reactive programming (RA) as the predominant development paradigm. Reactive paradigm is a declarative way to manage changes in application status (versus traditional imperative programming), based on the concept of event streams. A stream (aka observable) is a reactive data structure, similar to cells in spreadsheet applications. When you change a value (generate an event that signals a value change), the event is propagated on all dependent “cells” updating dependent values where necessary. We can have many flavors of streams: continuous/discrete, hot/cold, synchronous/asynchronous, etc.
Reactive programming is like a spreadsheet: when something changes, all things that are dependent on it change automatically, and so on in cascade.
Functional programming advantages
Another ongoing revolution in software development is the awareness of the advantages of functional programming (FP) versus object-oriented programming (OOP). For decades OOP was seen as the best way to program and develop software, and FP was relegated to an academic, impractical alternative.
FP + RP = FRP happiness
Mixing together RP and FP, we got functional reactive programming (FRP), a great mix to start our futuristic, revolutionary web project (or at least, parts of it).
FRP conjugates the effectiveness of spreadsheet-like state management with all the advantages of FP: immutability, purity, composability, etc.
This is a transparent clean way to represent the human-machine interaction.
But with this single Cycle, it’s maybe not the easiest way to manage deeply nested components, especially when components interact with traditional code (jQuery plugin, third-party frontend SDK, etc). Well, it’s not impossible—you can write “drivers” that wrap third-party code and pass sources/sinks from parent components to sub-components down to the deep, but it’s not so easy.
One Cycle to rule them all! Or not?
What if every component has it’s own Cycle? It’s for sure a more complex architecture. But it may help to scale your SPA, reduce components coupling, and ease third-party library integration without losing the functional benefits of our code. You can see every component as a standalone human-machine interaction cycle. Every component instance has a state, the state generates a DOM representation by FP definitions, humans interact with the DOM, the generated event changes the state, and the cycle restarts.
Deep diving Gitlab/Github, I found Turbine, a framework based on solid FP theory, that explores the cycle per component architecture.
But Turbine maintains the Cycle rule of parent component to pass state streams to subcomponent. To be clear, this is a good practice; in this way the data direction is linear from high-level components to nested components. But this imposes a component/sub-component binding.
We try to be a bit more pragmatic. The DOM is a dirty, impure, and luxurious place where many technologies and methodologies converge (jQuery plugins, Google Maps SDK, Facebook SDK, CSS, and all other things that build the web experience). Our proposal is to provide a way to express side effects at the component level (keeping them well-signaled) and to make the view-generating function a proactive element instead of a reactive one, like Cycle or Turbine does. In addition, a component can signal events to high-level model streams breaking the rule of single-data direction.
By this changed specification, it is possible to maintain the logic that reacts to events and generate the new local state pure but make every component able to react to its own events. More importantly, this is totally transparent to the parent component. Parent components don’t need to know how the sub-component interacts with the dirty DOM world or high-level model streams.
The isolation and decoupling of nested components make it natural to subdivide the reactive streams networks into two layers. They are the high-level model layer for logic that interests many components (external data sync, high-level business logic, etc.), and the view-model layer, responsible for reacting to component view events and updating the component state. The view-model can receive events from the high-level model layer and send events back to models.
If, in Cycle, side effects are isolated in DOM and other drivers in this distributed architecture, side effects can be managed directly by the reactive streams network at the component level. It’s important to signal where side effects happen and to not mix pure with impure. As we will see, functional reactive programming libraries offer a proper way to isolate side effects.
TODOMVC: the sample app
Now let’s give the architecture a try with the usual TODOMVC example. The ideas explored in this article can be implemented with many existing SPA frameworks and functional streaming libraries. In this case, I have opted for Mithril, a minimal and fast vdom framework that offers a standard and efficient routing solution in few kb of code, and Most Core, a high-performance monadic reactive event stream library,one of the “most” functional ones, I think.
The files are subdivided to show the layers:
- Models (/app/js/model)
- View-Models (/app/js/vm)
- Views (/app/js/view)
In the proposed solution we use Most adapters as the glue between the dirty DOM world and the pure VM.
An adapter is an entangled pair of an event stream and a function (we call it a trigger) to induce (cause) events in that stream.
To express clearly stream/trigger entanglement, we use suffix “$” for streams and “_$” for the trigger. So function _$<name> is the trigger entangled to stream function $<name>.
Based on this convention, we have created a helper function to mass generate triggers/streams and a function that generates standard Mithril components from our VM and View (see js/mm.js).
Components are a mechanism to encapsulate parts of a view to make code easier to organize and/or reuse.
In Mithril a component can be expressed in three different syntactic forms: POJO, Class, and Function (aka closure). As you probably guessed, we would use the function one. In that form, the component is a function that is called one time at component instantiation and returns a POJO with at least a view property.
The view property is a function called every time the object is rendered, returning the component vnode tree (and so the DOM representation).
In addition to view property, we can add an additional property to hook on DOM object lifecycle:
- oninit: called one time at first object initialization.
- oncreate: when the DOM object is rendered and attached to the page. At this moment we can alter the real DOM object. A great point to work with third-party SDK or libraries.
- onupdate: similar to oncreate but called every time the DOM of the object is updated.
- onremove: called before a DOM element is removed from the document.
To glue together Most, we have created a function named ‘mm’ (see app/js/mm.mjs) that is given a VM and a View return a standard Mithril component.
The result component will have lifecycle events configured to start and manage the Most streams that we return from the VM.
Every component instance has an immutable state map. The state is initialized at component declaration and is replaced as results of VM streams process.
Models are standard ES6 modules that export Most streams/triggers and other useful methods. In our sample, the Todo Model offers streams/triggers to manage the task list (see /app/js/model/todo.mjs). For convenience, it exports a couple of pure functions used by components. To make clear what goes in Model and what in View-Model thinks about the use of the logic. If it’s about the core business logic of the app or affects many components, it probably goes in a Model. If it’s a logic that manages the behavior of a single component, like its presentation, it goes into the VM.
The VM is the core of our architecture. Every VM is a pure function that takes in input an immutable vnode (we have used immutable js, but this is not mandatory) and returns a collection of Most triggers/streams. The vnode can be used to retrieve attributes declared on the component definition on the view.
The VM can import Modules streams and depends on them to build its internal stream network.
VM can declare streams that produce side effects (see tap). A common side effect is to trigger an event on a Model stream. All streams returned from the VM will be run at component init (by Mithril oninit), and they will last until the component is removed (the resources will be cleaned by Most after an event triggered by Mithril onremove).
Streams returned by VM are managed in three different ways:
- State streams: recreate component state, cause redraw;
- Effect Streams: cause redraw;
- Lifecycle Streams.
If the name of the returned stream equals a status key name, the stream will be used to update that state property (we can call the stream a “state stream”).
Every component state re-creation (that happens when a state stream received an event) will cause the additional side effect of requesting the redraw of the vdom (calling Mithril redraw method).
In addition to state streams, we can return other, arbitrary streams (that we call “effect streams”). Effect streams (streams whose names do not coincide with the name of a status property) are useful to manage side effects that must start and end with the component DOM but do not alter component internal status (for example, the triggering of events on high-level Model). Effect streams generate the redraw side effect as state streams.
The third type of stream that a VM can return is a lifecycle stream. Lifecycle streams are streams that will hook with the Mithril lifecycle events. So if you want something to happen on init/update/remove, you can depend on these streams. Lifecycle streams have names—$oncreate, $oninit, $onupdate, $ondelete—and do not trigger a Mithril redraw by default.
The view is the most simple part. It’s a pure function that generates a proactive vdom tree. As input, it receives two maps: the immutable status map and a collection of triggers that can be used to catch DOM events.
Put it all together
Our code is intentionally subdivided into modules to show how to scale the SPA. The VMs can be used to build different components or can be combined for use in a view that groups different functionalities. The Views can be easily replaced by JSX (not done here to bypass compilation).
We can create one or more component modules that put together initial state, VM, and View, creating and exporting components ready to use in Views.
The app imports high-level components used as pages in routes (see /app/js/app.mjs).
We have played with Mithril and Most Core to develop SPA in a distributed cycle, per component, bidirectional-flow way. The architecture is not distant to the old MVVM principles. It’s for sure less elegant and purist than Cycle, JS, or Turbine, but it may be easiest to scale and integrate with third-party libraries (to be demonstrated).
Links to Resources