Drivers

Edit on GitHub

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).

They are meant for encapsulating imperative side effects in JavaScript. The rule of thumb is: whenever you have a JavaScript function such as 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 main function and Haskell’s os function. In operating systems, drivers are software interfaces to use some hardware devices, which incur side effects in the external world.

In Cycle.js, one can consider the “operating system” to be the execution environment surrounding your application. Roughly speaking, the DOM, the console, JavaScript and JS APIs assume the role of the operating system for the web. We need software adapters to interface with the browser and other environments such as Node.js. Cycle.js drivers are there as adapters between the external world (including the user and the JavaScript execution environment) and the application world built with Cycle.js tools.

DOM Driver

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:

human-computer-diagram2 Created with Sketch. computer() app side a function Cycle.js Sink Streams Source Streams a function framework side human()

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.

main-domdriver-side-effects Created with Sketch. main() app side VDOM to DOM WRITE DOM events READ Cycle.js Sink Streams Source Streams framework side domDriver() DOM

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.

The 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:

function domDriver(vdom$) {
  // Use vdom$ as instructions to create DOM elements
  // ...
  return {
    select: function select(selector) {
      // returns an object with two functions: `events()`
      // and `elements()`. Function `events(eventType)`
      // returns the stream of `eventType` DOM events
      // happening on the elements matched by `selector`.
      // Function `elements()` is the stream of DOM
      // elements matching the given `selector`.
    }
  };
}

The input vdom$ is the output from main(), and the output of domDriver() is the input to main():

function main(sources) {
  // Use sources.DOM.select(selector).events(eventType)
  // ...
  // Create vdom$ somehow
  // ...
  return {
    DOM: vdom$
  };
}

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.

In JavaScript, nothing stops you from writing your 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:

run(main, {
  log: msg$ => { msg$.subscribe(msg => console.log(msg)) }
});

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 sink from main(). An example of such would be a read-only Web Socket driver, drafted below:

function WSDriver(/* no sinks */) {
  return xs.create({
    start: listener => {
      this.connection = new WebSocket('ws://localhost:4000');
      connection.onerror = (err) => {
        listener.error(err)
      }
      connection.onmessage = (msg) => {
        listener.next(msg)
      }
    },
    stop: () => {
      this.connection.close();
    },
  });
}

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 vdom$ stream as input, and how sophisticated and expressive VNodes from Snabbdom can be. On the other hand, don’t always choose JavaScript objects as the values emitted in the Observable. Use objects when they make sense, and remember to keep the API simple rather than overly-generic. Don’t over-engineer.

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-run, then runStreamAdapter is @cycle/rxjs-adapter, and so forth. Hence the driver function signature is:

function myDriver(sink$, runStreamAdapter /* optional */)

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.

In some cases it is necessary to output a queryable collection of Observables, instead of a single one. A queryable collection of Observables is essentially a JavaScript object with a function used to choose a particular stream based on a parameter, e.g. get(param).

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 Sock is:

// Establish a connection to the peer
let sock = new Sock('unique-identifier-of-the-peer');

// Subscribe to messages received from the peer
sock.onReceive(function (msg) {
  console.log('Received message: ' + msg);
});

// Send a single message to the peer
sock.send('Hello world');

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 sink in 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:

function sockDriver(outgoing$) {
  outgoing$.addListener({
    next: outgoing => {
      sock.send(outgoing));
    },
    error: () => {},
    complete: () => {},
  });

  return xs.create({
    start: listener => {
      sock.onReceive(function (msg) {
        listener.next(msg);
      });
    },
    stop: () => {},
  });
}

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 sockDriver() functions.

function makeSockDriver(peerId) {
  let sock = new Sock(peerId);

  function sockDriver(outgoing$) {
    outgoing$.addListener({
      next: outgoing => {
        sock.send(outgoing));
      },
      error: () => {},
      complete: () => {},
    });

    return xs.create({
      start: listener => {
        sock.onReceive(function (msg) {
          listener.next(msg);
        });
      },
      stop: () => {},
    });
  }
}

makeSockDriver(peerId) creates the sock instance, and returns the sockDriver() function. We use this in a Cycle.js app as such:

function main(sources) {
  const incoming$ = sources.sock;
  // Create outgoing$ (stream of string messages)
  // ...
  return {
    sock: outgoing$
  };
}

run(main, {
  sock: makeSockDriver('B23A79D5-some-unique-id-F2930')
});

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.