For the last few years, the core team of Cycle.js (André and me) has been redesigning the architecture and the developer experience of the framework. This February we finally found a solution to our problems that still stays true to the core ideas of the framework.
This blog post marks the first in a series that will cover the new design and its development. In this installment, I want to bring everyone onto the same page. What where the problems I described earlier and how does the new design solve them. In the later articles I will cover the new run
function (the core of the framework) and the new HTTP driver and especially the issues I encountered while implementing those. *cough* race conditions *cough*.
The status quo
Everyone that is familiar with Cycle.js may skip this part, for the rest here is how the framework work in the current version: Everything in your application is based around the notion of streams. The kinds of streams that RxJS made popular. All you application code is doing is reading streams of events from the outside (ie click events on the DOM or responses of HTTP requests), transform and combine them and finally giving streams of commands back to the outside (ie a new virtual DOM to render on the DOM or a HTTP request to execute).
Let's take a concrete example, a simple counter:
function main(sources) {
const incrementStream = sources.DOM.select(".increment")
.events("click")
.mapTo(1);
const decrementStream = sources.DOM.select(".decrement")
.events("click")
.mapTo(-1);
const valueStream = xs
.merge(incrementStream, decrementStream)
.fold((sum, current) => sum + current, 0);
const domStream = valueStream.map(x =>
div([
h2(`The current value is ${x}`),
button(".increment", "Increment"),
button(".decrement", "Decrement")
])
);
return {
DOM: domStream
};
}
As you can see we are listing to the click events of the two buttons and convert those events into +1
and -1
. We then merge
those two streams and use fold
to sum up all numbers (fold
is similar to array.fold
, but instead of calculating the value once, fold
will send out the current value after every number that comes in). We then take the stream of all the sums and transform it into a virtual dom tree that is then given to the outside for rendering.
This stream-centric design has some nice benefits. First, all of the application logic is a pure function. It does not directly access the DOM API, it does not do HTTP requests to 3rd parties or do any other interaction with the outside world. Everything happens through the sources and the sinks (ie input and output of the main
function). This means that we do not need to mock the actual APIs with something like JsDOM, we can just provide some inputs to the application and assert on the outputs. Second, adding async behavior does not add any complexity, synchronous code looks exactly like asynchronous code. Third, on the top level, we can intercept and modify/filter/log any command that any component in the hierarchy sent. One nice use case for this intercepting every HTTP request the components do and add some API token to the headers for example. We could also add some rate limiting here in case we are fetching from a third party API. We could also put this functionality in a library that provides a function that wraps you application and returns a new application with logging. This pattern has evolved out of the community and there are several libraries that provide such "main wrappers". Last, there is only unidirectional data flow. All the data comes in from the sources, gets transformed and leaves through the sinks. It is really easy to trace commands back to the data or events that caused them.
The problem
The streaming idea works really well if the outside is interactive, for example it is a really good approach for the DOM where the user may interact at any time. However there is also another kind of outside: question and answer style effects. The most simple example for this is doing HTTP requests. Usually when you send a request, you want to wait for the result and then work with the data. But at the moment doing a request looks like this:
function main(sources) {
const responseStream = sources.HTTP.select("myRequest");
const domStream = responseStream.startWith(initialData).map(view);
const requestStream = sources.DOM.select(".requestButton")
.events("click")
.mapTo({
url: myUrl,
method: "GET",
category: "myRequest"
});
return {
DOM: domStream,
HTTP: requestStream
};
}
As you can see, while the flow of the data is still strictly from the sources to the sinks, the code is awkward to read for the HTTP portion. First, we listen to a response with some tag (myRequest
in this case) and then only later we see the code that actually sent it. And they are not directly connected, they are completely independent, so you have to use the tag to find which request belongs to which response. What we really wanted, was an API similar to this:
function main(sources) {
const domStream = sources.DOM.select(".requestButton")
.events("click")
.map(() => sources.HTTP.get(myUrl))
.flatten()
.startWith(initialData)
.map(view);
return {
DOM: domStream
};
}
This code does exactly the same as the one before, but it is a lot easier to read because you can just start at the top and work your way down. It clearly says: "Listen to all 'click' events on the request button and for each of the clicks make a get request to myUrl
. Starting with some initial data, render every response with the view function onto the DOM".
But if we would implement this like that, we would loose one of the benefits of using stream: The ability to inspect and modify every command that happens. As you can see there is nothing returned through the sinks for HTTP, so we can not intercept this request anywhere, not even at the top.
The solution
The solution we have settled on now is to split the drivers that interpret the commands and provide the events. At the moment a driver take a stream of commands as input and returns either a stream of events or, for more complex drivers like HTTP and DOM, an object that provides methods that return streams. For example the DOM driver returns the DOMSource
object that provides the methods select()
and events()
where the latter one returns a stream of events.
A very simplified example of this would look like this:
class DOMSource {
events(type) {
return fromEvent(type);
}
}
function domDriver(commands) {
commands.subscribe({
next: renderDOM
});
return new DOMSource();
}
In this example fromEvent
would attach an event listener and emit a new event every time the event listener gets activated.
The new solution changes this to require drivers to take a stream as input and return a stream as output. If a more complex driver wants to offer a nicer API it can provide it separately. The job of such an API is to convert the calls from the user into commands that will be sent to the driver and to take the events from the driver and filter them for the user. For our DOM example this might look like this:
class DomApi {
constructor(subject, driverEvents, idGenerator) {
this.subject = subject;
this.driverEvents = driverEvents;
this.idGenerator = idGenerator;
}
events(type) {
const id = this.idGenerator();
this.subject.send({
commandType: "attachEventListener",
type,
id
});
return this.driverEvents.filter(event => event.id === id);
}
}
function domDriver(commands) {
const subject = makeSubject();
commands.subscribe({
next: command => {
if (command.commandType === "attachEventListener") {
document.addEventListener(command.type, event => {
subject.send({ ...event, id: command.id });
});
} else {
renderDOM();
}
}
});
return subject;
}
As you can see, the driver is completely independent from the API, you could also not use the API and send the commands to the driver directly. The API on the other hand does not interact with the outside world at all, it only sends a command to the driver and filters the events for the ones the user is actually interested in. In case you are wondering, a subject is like the beginning of a stream where you can manually put events into the stream via send()
.
The whole picture
With the new design, Cycle.js exports a function makeMasterMain()
that takes your application and the APIs of the drivers and returns a new main function that just expects streams of events as inputs and returns streams of commands. The APIs of the drivers take care of sending out the right commands and reading the right events. You can now wrap that new main function with code that inspects for example the HTTP requests. But now such code could also intercept and log the addition of event listeners to the DOM! This was not possible before. After adding as many layers of wrapping code to the master main as you like, you can give it to run()
that takes the main function and the drivers and connects the two. Remember that the main function now only works with plain streams, no APIs any more.
So, coming back to the code from earlier:
function main(sources) {
const domStream = sources.DOM.select(".requestButton")
.events("click")
.map(() => sourcs.HTTP.get(myUrl))
.flatten()
.startWith(initialData)
.map(view);
return {
DOM: domStream
};
}
This is how the code will actually look like in the next major version of Cycle.js! All while you will still be able to intercept/modify/log all requests that leave your application even though they are not explicitly returned from your application code (ie no HTTP: requestStream
). Getting to this point took some time, but I am very happy with the final architecture. The user code is easier to read and the framework code also got quite a bit simpler.
In the next part I will talk about the run()
and the makeMasterMain()
functions and how to prevent race conditions with synchronous stream code. Thanks for reading, feel free to voice any questions you might have.
Top comments (10)
After looking into Cycle.js docs and watching Andre's animated presentation about Cycle.js with react-native... so, would it be fair to say (cutting through all the terminology of drivers, etc.) Cycle.js is an application level event driven system based on streams, where there are composable event producers, event consumers, and maybe some intermediate event ingestors that act as the main way of communicating within a Cycle.js app? And this event based system can configure and "load"/connect groups of event producers and consumers as "plugins" at the application level? Essentially it's a localized microservices application architecture that can create and listen to app events on various streams and act (perform code effects) accordingly? Apologies in advance for any misunderstanding, my picture of Cycle.js might be a bit cloudy still.
The gist sounds about right, but one of the main benefits of this is purity. Side effects (writing to the DOM, making HTTP requests, even time based functions like requestAnimationFrame) are only in the producers/consumers (the two sides of the driver). Your whole app only reads events and issues commands, but never does any side effects itself. This makes tracing bugs and also testing much easier
Is it ready to test on some branch at github, or is it an idea for now? Happy to check it, never used cyclejs before but after reading this article the new proposed solution seems straight forward to me which I couldn't say about the current one which feels odd.
Hi, dev.to did not notify me about replies, so this one is a bit late:
Yes, all the development is happening in the open on this pull request: github.com/cyclejs/cyclejs/pull/929
That's very neat Jan!
I'd like to see details of the HTTP driver as well. After all, the big issue is the seperate handling of request and response and how it breaks the flow. How is the HTTP.get() implemented? Does the approach work for other "pulls" like Random.get()?
Hi Steve,
did not get a notification for the answers, so I am a bit late, but anyways: All of the code is in this PR including the HTTP driver! And yes the same will work for stuff like Random.get()
sourcs.HTTP.get(myUrl))
->sources.HTTP.get(myUrl))
Thank you, fixed
Is this implement in the meantime? I can't find anything about in the Documentation (Guide, API, Release Notes). Thank you for this nice framework. Although the concept sometimes gives me really headaches solving trivial problems. :)
Thank you for the insight on where Cycle.js is going. Keep up the great work!