Throughout this documentation site we have extensively used drivers. The DOM Driver has been the most common one, but also the HTTP driver was used.
What are drivers and when should you use them? When should you create your own driver, and how do they work? These are a few questions we will address in this chapter.
Drivers are functions that listen to sink streams (their input), perform imperative side effects, and may return source streams (their output).
doSomething() which returns nothing, it should be contained in a driver.
Let’s study what drivers do by analyzing the most common one: the DOM Driver.
Why the name "driver"?
In Haskell 1.0 Stream I/O, similar in nature to Cycle.js, there is a cyclic interaction between the program’s
mainfunction and Haskell’s
osfunction. In operating systems, drivers are software interfaces to use some hardware devices, which incur side effects in the external world.
The DOM Driver is the most important and most common driver in Cycle.js. When building interactive web apps, it is probably the most important tool in Cycle.js. In fact, while Cycle Run function is only about 200 lines of code, Cycle DOM is at least 4 times larger.
It’s main purpose is to be a proxy to the user using the browser. Conceptually we would like to work assuming the existence of a
human() function, as this diagram reminds us:
However, in practice, we write our
main() function targeted at a
domDriver(). For a user interacting with a browser, we only need to make our
main() interact with the DOM. Whenever we need to show something to the user, we instead show that to the DOM, and the DOM together with the browser shows that to our user. When we need to detect the user’s interaction events, we attach event listeners on the DOM, and the DOM will notify us when the user interacts with the browser on the computer.
Notice there are two directions of interaction with the external world through the DOM. The write effect is the renderization of our Snabbdom VNodes to DOM elements which can be shown on the user’s screen. The read effect is the detection of DOM events generated by the user manipulating the computer.
domDriver() manages these two effects while allowing them to be interfaced with the
main(). The input to
domDriver() captures instructions for the write effect, and the read effect is exposed as the output of
domDriver(). The anatomy of the
domDriver() function is roughly the following:
vdom$ is the output from
main(), and the output of
domDriver() is the input to
As a recap:
main(): takes sources as input, returns sinks
domDriver(): takes sinks as input, performs write and read effects, returns sources.
Isolating side effects
Drivers should always be associated with some side effect. As we saw, even though the DOM Driver’s main purpose is to represent the user, it has write and read effects.
main() function with side effects. A simple
console.log() is already a side effect. However, to keep
main() pure and reap its benefits like testability and predictability, it is better to encapsulate all side effects in drivers.
Imagine, for instance, a driver for network requests. By isolating the network request side effect, your application’s
main() function can focus on business logic related to the app’s behavior, and not on lower-level instructions to interface with external resources. This also allows a simple method for testing network requests: you can replace the actual network driver with a fake network driver. It just needs to be a function that mimics the network driver function, and makes assertions.
Avoid making drivers if they do not have effects to the external world somehow. Especially do not create drivers to contain business logic. This is most likely a code smell.
Drivers should focus solely on being an interface for effects, and usually are libraries that simply enable your Cycle.js app to perform different effects. Sometimes, though, a one-liner driver can be created on the fly instead of being a library, for instance this simple logging driver:
Read-only and write-only drivers
Most drivers, like the DOM Driver, take sinks (to describe a write) and return sources (to catch reads). However, we might have valid cases for write-only drivers and read-only drivers.
For instance, the one-liner
log driver we just saw above is a write-only driver. Notice how it is a function that does not return any stream, it simply consumes the sink
msg$ it receives.
Other drivers only create source streams that emit events to the
main(), but don’t take in any
main(). An example of such would be a read-only Web Socket driver, drafted below:
How to make drivers
You should only be reading this section if you have clear intentions to make a driver and expose it as a library (unless it’s a one-liner driver). Typically, when writing a Cycle.js app, you do not need to create your own drivers.
Consider first carefully which side effects your driver is responsible for. And can it have both read and write effects?
Once you map out the read/write effects, consider how diverse those can be. Create an empathic API which covers the common cases elegantly.
The input to the driver function is expected to be a single stream. This is a practical API for the app developer to use when returning the
sinks object in
main(). Notice how the DOM Driver takes a single
As a second, optional, argument to the driver function, you can expect
runStreamAdapter. A “Stream Adapter” is a library that knows how to convert between one specific stream library (like xstream or RxJS) to a generic adapter interface used internally in Cycle.js. This is needed for those cases your driver function needs to return a sophisticated source object which can create arbitrary streams using the same stream library that your application uses. If your app uses
@cycle/xstream-run, then it uses under the hood
@cycle/xstream-adapter, which is the argument
runStreamAdapter given to drivers. If your app uses
@cycle/rxjs-adapter, and so forth. Hence the driver function signature is:
The output of the driver function can either be a single stream or a queryable collection of streams.
In the case of a single stream as output source, depending on how diverse the values emitted by this stream are, you might want to make those values easily filterable (using the xstream or RxJS
filter() operator). Design an API which makes it easy to filter the stream, keeping in mind what was provided as the sink stream to the driver function.
The DOM Driver, for instance, outputs a queryable collection of streams. The collection is in fact lazy: none of the streams outputted by
select(selector).events(eventType) existed prior to the call of
events(). This is because we cannot afford creating streams for all possible events on all elements on the DOM. Take inspiration from the lazy queryable collection of streams from the DOM Driver whenever the output source contains a large (possibly infinite) amount of streams.
You most likely will need
runStreamAdapter only for queryable collections of streams and if you want your driver usable by any other stream library. If you are building your driver only for your own use, then just write the driver using the same stream library that your application uses.
Example driver implementation
Suppose you have a fake real-time channel API called
Sock. It is able to connect to a remote peer, send messages, and receive push-based messages. The API for
How do we build a driver for
Sock? We start by identifying the effects. The write effect is
sock.send(msg) and the read effect is the listener for received messages. Our
sockDriver(sink) should take
sink as instructions to perform the
send(msg) calls. The output from
sockDriver() should be
source, containing all received messages.
Since both input and output should be streams, it’s easy to see
sockDriver(sink) should be an stream of outgoing messages to the peer. And conversely, the source should be an stream of incoming messages. This is a draft of our driver function:
The listener of
outgoing$ performs the
send() side effect, and the stream returned based on
sock.onReceive takes data from the external world. However,
sockDriver is assuming
sock to be available in the closure. As we saw,
sock needs to be created with a constructor
new Sock(). To solve this dependency, we need to create a factory that makes
makeSockDriver(peerId) creates the
sock instance, and returns the
sockDriver() function. We use this in a Cycle.js app as such:
Notice we have the
peerId specified when the driver is created in
makeSockDriver(peerId). If the
main() needs to dynamically connect to different peers according to some logic, then we shouldn’t use this API anymore. Instead, we need the driver function to take instructions as input, such as “connect to peerId”, or “send message to peerId”. This is one example of the considerations you should take when designing a driver API.
Drivers make Cycle.js extensible
Cycle Core is a very small framework, and Cycle DOM’s Driver is available as an optional plugin for your app. This means it is simple to replace the DOM Driver with any other driver function providing interaction with the user.
You can for instance fork the DOM Driver, adapt it to your preferences, and use it in a Cycle.js app. You can create a driver to interface with sockets. Drivers to perform network requests. Drivers meant for Node.js. Drivers that target other UI trees, such as
<canvas> or even native mobile UI.
As a framework, it cannot be compared to monoliths which have ruled web development in the recent years. Cycle.js itself is after all just a small tool and a convention to create reactive dialogues with the external world using reactive streams.