cyclejs/cycle-core

View on GitHub
docs/content/documentation/components.md

Summary

Maintainability
Test Coverage
# Components

## Automatically reusable

User interfaces are usually made up of many reusable pieces: buttons, charts, sliders, hoverable avatars, smart form fields, etc. In many frameworks, including Cycle.js, these are called components. However, in this framework they have a special property.

**Any Cycle.js app can be reused as a component in a larger Cycle.js app.**

How is that so? In any framework you can build a program that just makes one slider. By now, we know how to make a Cycle.js `main()` that makes a smart slider widget. Then, since `main()` is just a function taking inputs from the external world and generating outputs in return, we can just call that function inside a larger Cycle.js app.

Each of these "small Cycle.js `main()` functions" are called **dataflow components**. The sources which a dataflow component receives are streams provided by its parent, and sinks are streams given back to the parent. All along, we have been building dataflow components, because the `main()` given to `run(main, drivers)` is also a dataflow component. Its parent are the drivers, because that is where its sources come from and where its sinks go to.

![dataflow component](img/dataflow-component.svg)

To learn by doing, let's just make a dataflow component for a single labeled slider. It should take user events as input, and generate a virtual DOM stream of a slider element. Besides the virtual DOM, it might also output a value: the stream of slider values. It might also take attributes (from its parent) as input to customize some behavior or looks. These are sometimes called *props* ("properties") in other frameworks.

## A labeled slider component

A labeled slider has two parts: a label and slider, side by side, where the label always displays the current dynamic value of the slider.

<a class="jsbin-embed" href="//jsbin.com/napoke/embed?output">JS Bin on jsbin.com</a>

Every labeled slider has some properties:

 - Label text (`'Weight'`, `'Height'`, etc)
 - Unit text (`'kg'`, `'cm'`, etc)
 - Min value
 - Max value
 - Initial value

These props can be encoded as an object, wrapped in a stream, and passed to our `main()` function as a *source* input:

```javascript
function main(sources) {
  const props$ = sources.props;
  // ...
  return sinks;
}
```

To use this main function, we call `run`:

```javascript
run(main, {
  props: () => xs.of({
    label: 'Weight', unit: 'kg', min: 40, value: 70, max: 140
  }),
  DOM: makeDOMDriver('#app')
});
```

Remember that even though we are building a component, we are assuming our labeled slider to be our main program. Then, because `props` are an input given to the labeled slider from its parent, the only parent of `main()` in this case is `run`. That is why we need to configure `props` as a fake driver.

The other input to this labeled slider program is the DOM source representing user events:

```diff
 function main(sources) {
+  const domSource = sources.DOM;
   const props$ = sources.props;
   // ...
   return sinks;
 }
```

The remainder of the program is rather easy given that we've written the same labeled slider in the two previous chapters. However, this time we take props as the initial value.

```javascript
function main(sources) {
  const domSource = sources.DOM;
  const props$ = sources.props;

  const newValue$ = domSource
    .select('.slider')
    .events('input')
    .map(ev => ev.target.value);

  const state$ = props$
    .map(props => newValue$
      .map(val => ({
        label: props.label,
        unit: props.unit,
        min: props.min,
        value: val,
        max: props.max
      }))
      .startWith(props)
    )
    .flatten()
    .remember();

  const vdom$ = state$
    .map(state =>
      div('.labeled-slider', [
        span('.label',
          state.label + ' ' + state.value + state.unit
        ),
        input('.slider', {
          attrs: {type: 'range', min: state.min, max: state.max, value: state.value}
        })
      ])
    );

  const sinks = {
    DOM: vdom$,
    value: state$.map(state => state.value),
  };
  return sinks;
}
```

You might have noticed that besides the virtual DOM output, we also return the `value$` stream as a sink:

```diff
   // ...
   const sinks = {
     DOM: vdom$,
+    value: value$,
   };
   return sinks;
 }
```

This value stream is important as a sink if the parent wishes to use the numeric value for some calculations, such as that of BMI. In the program we wrote above, the parent of `main()` are the drivers. The drivers don't need to use `value$`, that's why we don't need a driver named `value`. However, when the parent of the slider component is another dataflow component, like in the next section, then `value$` will be important.

> How to name sources/sinks?
>
> You may have noticed that we chose the name `value` as a sink, not `value$`. Does this contradict our convention that streams should always be suffixed with `$`? Not particularly.
>
> Sources and sinks are an exception because they are special sockets that connect the internals of your component with the external world. Their names are just "keys" used to put or get streams. In the case of `main`, those keys need to match the same keys you gave to the `drivers` object in `run(main, drivers)`. Notice how each driver indexed by a key in the `drivers` object *is not* a stream. They are functions, because drivers are functions.
>
> This is why we shouldn't give the name `DOM$`, because in the `drivers` object, the value behind that key is a function (the DOM Driver), and in the `main` function, `sources.DOM` is the DOM Source object with methods like `select()` and `events()`.
>
> Try to maintain the convention that source and sink names are just *keys* in the sources object and sinks object. You may then "pick" your stream from the sources, like we did with `const props$ = sources.props;` for instance.

## Using a component

Now that our dataflow component for a labeled slider is ready, we can use it in the context of a larger application. First, we will rename our component to `LabeledSlider`, and `main()` will refer to our larger application.

```diff
-function main(sources) {
+function LabeledSlider(sources) {
   const domSource = sources.DOM;
   const props$ = sources.props;

   // ...

   return sinks;
 }

+function main(sources) {
+  // Call LabeledSlider() here...
+}
```

Since `LabeledSlider` is just a function, we can call it with some sources to get its sinks as output.

```javascript
function main(sources) {
  const props$ = xs.of({
    label: 'Radius', unit: '', min: 10, value: 30, max: 100
  });
  const childSources = {DOM: sources.DOM, props: props$};
  const labeledSlider = LabeledSlider(childSources);
  const childVDom$ = labeledSlider.DOM;
  const childValue$ = labeledSlider.value;

  // ...
}
```

> Why name components with CapitalCase?
>
> You probably noticed we named the dataflow component as `LabeledSlider`. Usually in JavaScript, capitalized names are used for classes and constructor functions. Since Cycle.js uses functional programming techniques heavily, Object-oriented programming conventions are irrelevant, there are rarely (or never) classes in Cycle.js apps.
>
> For this reason, capitalized names become available in the functional programming flavor of JavaScript. We will follow the convention of capitalized names such as `FooButton` for dataflow components (in other words, small Cycle.js apps). Their camel-case counterpart such as `fooButton` will refer to the output of `FooButton` function when called, i.e., the sinks object.

Now we have `childVDom$` and `childValue$` as sinks from the labeled slider, available for use as regular streams in the context of the `main()` parent. We use `childValue$` to render a circle with radius equal to the slider's value, and use `childVDom$` to embed the slider's virtual DOM in the parent's virtual DOM:

```javascript
function main(sources) {
  // ...

  const childVDom$ = labeledSlider.DOM;
  const childValue$ = labeledSlider.value;

  const vdom$ = xs.combine(childValue$, childVDom$)
    .map(([value, childVDom]) =>
      div([
        childVDom,
        div({style: {
          backgroundColor: '#58D3D8',
          width: String(2 * value) + 'px',
          height: String(2 * value) + 'px',
          borderRadius: String(value) + 'px'
        }})
      ])
    );

  return {
    DOM: vdom$
  };
}
```

As a result, we get a Cycle.js program where the labeled slider controls the size of a rendered circle.

<a class="jsbin-embed" href="//jsbin.com/yojoho/embed?output">JS Bin on jsbin.com</a>

## Isolating multiple instances

Our labeled sliders were originally built for the BMI example, so we should see how the component we just built can be used in the BMI example.

The naïve approach is to simply call `LabeledSlider()` twice, once with props for weight, and again with props for height:

<a class="jsbin-embed" href="//jsbin.com/lagegax/embed?output">JS Bin on jsbin.com</a>

```javascript
function main(sources) {
  const weightProps$ = xs.of({
    label: 'Weight', unit: 'kg', min: 40, value: 70, max: 150
  });
  const heightProps$ = xs.of({
    label: 'Height', unit: 'cm', min: 140, value: 170, max: 210
  });

  const weightSources = {DOM: sources.DOM, props: weightProps$};
  const heightSources = {DOM: sources.DOM, props: heightProps$};

  const weightSlider = LabeledSlider(weightSources);
  const heightSlider = LabeledSlider(heightSources);

  const weightVDom$ = weightSlider.DOM;
  const weightValue$ = weightSlider.value;

  const heightVDom$ = heightSlider.DOM;
  const heightValue$ = heightSlider.value;

  const bmi$ = xs.combine(weightValue$, heightValue$)
    .map(([weight, height]) => {
      const heightMeters = height * 0.01;
      const bmi = Math.round(weight / (heightMeters * heightMeters));
      return bmi;
    })
    .remember();

  const vdom$ = xs.combine(bmi$, weightVDom$, heightVDom$)
    .map(([bmi, weightVDom, heightVDom]) =>
      div([
        weightVDom,
        heightVDom,
        h2('BMI is ' + bmi)
      ])
    );

  return {
    DOM: vdom$
  };
}
```

However, this creates a bug. Both labeled sliders change when any slider is moved. Can you see why? Pay attention to the implementation of `LabeledSlider` with this piece of code:

```javascript
function LabeledSlider(sources) {
  // ...

  const newValue$ = domSource
    .select('.slider')
    .events('input')
    .map(ev => ev.target.value);

  // ...
}
```

Suppose we just ran this function for the weight labeled slider. The line `sources.DOM.select('.slider')` **will attempt to select all** `.slider` **elements on the entire DOM tree managed by this app**. This means both the `.slider` in the weight component and the `.slider` in the height component. As a result, the weight component will detect changes to both the height slider and the weight slider, which is a bug.

A component should not leak its output to other components, and it should not be able to detect outputs from other sibling components. In order to keep the nice property of "a component is just a Cycle.js app", we want two properties:

- A component's **sources** are not affected by other components' sources.
- A component's **sinks** do not affect other components' sinks.

In order to achieve these properties, we need to modify the sources when they enter the component, and also modify the sinks when they are returned from the component. To make sources and sinks isolated from influence of other components, we need to introduce a scope for the current component.

For the DOM source and DOM sink, we can use a unique identifier string as namespace for the virtual DOM element. First, we patch the DOM sink, adding a className to the VNodes it emits.

```diff
 function main(sources) {
   // ...

   const weightSlider = LabeledSlider(weightSources);
   const heightSlider = LabeledSlider(heightSources);

   const weightVDom$ = weightSlider.DOM
+    .map(vnode => {
+      vnode.sel += '.weight';
+      return vnode;
+    });
   const weightValue$ = weightSlider.value;

   const heightVDom$ = heightSlider.DOM
+    .map(vnode => {
+      vnode.sel += '.height';
+      return vnode;
+    });
   const heightValue$ = heightSlider.value;

   // ...
 }
```

This will result in the following rendered HTML:

```html
<div class="labeled-slider weight">
  <span class="label">Weight 70kg</span>
  <input class="slider" type="range" min="40" max="150">
</div>
```

For querying user events on these rendered sliders, the `weightSlider` dataflow component should detect user events *only* from the `<div class="labeled-slider weight">` element and its descendants when the stream `sources.DOM.select('.slider').events('input')` is used.

In the context of the labeled slider component, **`sources.DOM.select()` should refer only to the elements that were created by the corresponding DOM sink in that component**.

We can achieve that by narrowing down the DOM source before it is given to the component, using the same className we patched on the sink, like this:

```diff
 function main(sources) {
   // ...
   const weightSources = {
-    DOM: sources.DOM,
+    DOM: sources.DOM.select('.weight'),
     props: weightProps$
   };
   const heightSources = {
-    DOM: sources.DOM,
+    DOM: sources.DOM.select('.height'),
     props: heightProps$
   };

   const weightSlider = LabeledSlider(weightSources);
   const heightSlider = LabeledSlider(heightSources);
   // ...
 }
```

> ### What does `select()` do?
>
> We have used `.select(selector).events(eventType)` many times previously to get a stream emitting DOM events of type `eventType` happening on the `selector` element(s).
>
> In the code above, `sources.DOM` is a so-called "DOM Source", an object with some functions attached that help us query for the correct event stream. We also called `sources.DOM.select(selector)` without `.events(eventType)`, which returns a **new** DOM source, on which we can call again `select()` or `events()`.
>
> `select('.foo').select('.bar').events('click')` returns a stream of click events happening on `'.foo .bar'` elements. In other words, these are all clicks happening on `'.bar'` elements descendants of `'.foo'` elements. The first call, `select('.foo')`, allows us to "narrow down" the scope of the DOM source.

The code we wrote for isolating sources and sinks looks like boilerplate. Ideally we want to avoid manually managing scopes for each component instance using classNames:

```javascript
function main(sources) {
  // ...
  const weightSources = {
    DOM: sources.DOM.select('.weight'), props: weightProps$
  };
  const heightSources = {
    DOM: sources.DOM.select('.height'), props: heightProps$
  };
  // ...
  const weightVDom$ = weightSlider.DOM
    .map(vnode => {
      vnode.sel += '.weight';
      return vnode;
    });
  // ...
  const heightVDom$ = heightSlider.DOM
    .map(vnode => {
      vnode.sel += '.height';
      return vnode;
    });
  // ...
}
```

To avoid repeating code, such as the `.map(vnode => ...)` which patches the VNode, we could extract the functionality into functions: `isolateDOMSink()` and `isolateDOMSource()`.

```diff
 function main(sources) {
   // ...
   const weightSources = {
-    DOM: sources.DOM.select('.weight'), props: weightProps$
+    DOM: isolateDOMSource(sources.DOM, 'weight'), props: weightProps$
   };
   const heightSources = {
-    DOM: sources.DOM.select('.height'), props: heightProps$
+    DOM: isolateDOMSource(sources.DOM, 'height'), props: heightProps$
   };
   // ...
-  const weightVDom$ = weightSlider.DOM
-    .map(vnode => {
-      vnode.sel += '.weight';
-      return vnode;
-    });
+  const weightVDom$ = isolateDOMSink(weightSlider.DOM, 'weight');
   // ...
-  const heightVDom$ = heightSlider.DOM
-    .map(vnode => {
-      vnode.sel += '.height';
-      return vnode;
-    });
+  const heightVDom$ = isolateDOMSink(heightSlider.DOM, 'height');
   // ...
 }
```

Since these are very useful helper functions, they are packaged in Cycle DOM. They are available as static functions under the DOM source: `sources.DOM.isolateSource` and `sources.DOM.isolateSink`. This is how the `main()` function looks like when we use those functions:

```javascript
function main(sources) {
  const weightProps$ = xs.of({
    label: 'Weight', unit: 'kg', min: 40, value: 70, max: 150
  });
  const heightProps$ = xs.of({
    label: 'Height', unit: 'cm', min: 140, value: 170, max: 210
  });

  const {isolateSource, isolateSink} = sources.DOM;

  const weightSources = {
    DOM: isolateSource(sources.DOM, 'weight'), props: weightProps$
  };
  const heightSources = {
    DOM: isolateSource(sources.DOM, 'height'), props: heightProps$
  };

  const weightSlider = LabeledSlider(weightSources);
  const heightSlider = LabeledSlider(heightSources);

  const weightVDom$ = isolateSink(weightSlider.DOM, 'weight');
  const weightValue$ = weightSlider.value;

  const heightVDom$ = isolateSink(heightSlider.DOM, 'height');
  const heightValue$ = heightSlider.value;

  // ...
}
```

The code above shows how we need to manually process the sources and sinks of a child component to make sure each child is run in an isolated context. It would be better, however, if we could just "isolate" a component and make source and sink isolation happen under the hood.

Such is the purpose of [`isolate()`](https://github.com/cyclejs/cyclejs/tree/master/isolate) (`npm install @cycle/isolate`), a helper function which handles calls to `isolateSource` and `isolateSink` for us. `isolate(Component, scope)` takes a dataflow component function `Component` as input, and outputs a dataflow component function which isolates the sources to `scope`, runs `Component`, then isolates its sinks to `scope` as well. Here is a heavily simplified implementation of `isolate()`:

```javascript
function isolate(Component, scope) {
  return function IsolatedComponent(sources) {
    const {isolateSource, isolateSink} = sources.DOM;
    const isolatedDOMSource = isolateSource(sources.DOM, scope);
    const sinks = Component({DOM: isolatedDOMSource});
    const isolatedDOMSink = isolateSink(sinks.DOM, scope);
    return {
      DOM: isolatedDOMSink
    };
  };
}
```

This allows us to simplify the `main()` function with two labeled slider components:

```diff
 function main(sources) {
   const weightProps$ = xs.of({
     label: 'Weight', unit: 'kg', min: 40, value: 70, max: 150
   });
   const heightProps$ = xs.of({
     label: 'Height', unit: 'cm', min: 140, value: 170, max: 210
   });

-  const {isolateSource, isolateSink} = sources.DOM;
   const weightSources = {
-    DOM: isolateSource(sources.DOM, 'weight'), props: weightProps$
+    DOM: sources.DOM, props: weightProps$
   };
   const heightSources = {
-    DOM: isolateSource(sources.DOM, 'height'), props: heightProps$
+    DOM: sources.DOM, props: heightProps$
   };

+  const WeightSlider = isolate(LabeledSlider, 'weight');
+  const HeightSlider = isolate(LabeledSlider, 'height');

-  const weightSlider = LabeledSlider(weightSources);
+  const weightSlider = WeightSlider(weightSources);
-  const heightSlider = LabeledSlider(heightSources);
+  const heightSlider = HeightSlider(heightSources);

-  const weightVDom$ = isolateSink(weightSlider.DOM, 'weight');
+  const weightVDom$ = weightSlider.DOM;
   const weightValue$ = weightSlider.value;

-  const heightVDom$ = isolateSink(heightSlider.DOM, 'height');
+  const heightVDom$ = heightSlider.DOM;
   const heightValue$ = heightSlider.value;

   // ...
 }
```

Notice the line which creates the `WeightSlider` component:

```javascript
const WeightSlider = isolate(LabeledSlider, 'weight');
```

`isolate()` takes a non-isolated component `LabeledSlider` and restricts it to the `'weight'` scope, creating `WeightSlider`. The scope `'weight'` is only used in this line of code, and nowhere else. We can simplify this code a bit more, by making the scope parameter implicit:

```javascript
const WeightSlider = isolate(LabeledSlider);
```

This does the same as previously, except the scope parameter is unique and autogenerated. The scope string itself was irrelevant to us, so we let `isolate()` generate some scope string for us.

> ### Is `isolate()` pure?
>
> If we leave the scope parameter implicit for both weight and height sliders, then the code becomes
>
> `const WeightSlider = isolate(LabeledSlider);`<br />
> `const HeightSlider = isolate(LabeledSlider);`
>
> Because the right-hand side is the same, does this mean `WeightSlider` and `HeightSlider` are the same component? **Certainly not.**
>
> `isolate()` with an implicit scope parameter is **not** referentially transparent. In other words, calling `isolate()` with an implicit scope is "impure". `WeightSlider` and `HeightSlider` are not the same components. Each one has its own unique scope parameter.
>
> On the other hand, when using an explicit scope parameter, then `isolate()` is referentially transparent. In other words, `Foo` and `Fuu` are the same here:
>
> `const Foo = isolate(LabeledSlider, 'myScope');`<br />
> `const Fuu = isolate(LabeledSlider, 'myScope');`
>
> Since Cycle.js follows functional programming techniques, usually most of its API is referentially transparent. `isolate()` is an exception, for convenience. If you want referential transparency everwhere, then provide explicit scope parameters. If you want convenience and you know how `isolate()` works, then use implicit scope parameters.

If we compare our last code with the code we initially started out naïvely for `main()` to make the BMI calculator, the only difference is the use of `isolate()` on child components:

```diff
 function main(sources) {
   const weightProps$ = xs.of({
     label: 'Weight', unit: 'kg', min: 40, value: 70, max: 150
   });
   const heightProps$ = xs.of({
     label: 'Height', unit: 'cm', min: 140, value: 170, max: 210
   });

   const weightSources = {DOM: sources.DOM, props: weightProps$};
   const heightSources = {DOM: sources.DOM, props: heightProps$};

-  const weightSlider =         LabeledSlider(weightSources);
+  const weightSlider = isolate(LabeledSlider)(weightSources);
-  const heightSlider =         LabeledSlider(heightSources);
+  const heightSlider = isolate(LabeledSlider)(heightSources);

   const weightVDom$ = weightSlider.DOM;
   const weightValue$ = weightSlider.value;

   const heightVDom$ = heightSlider.DOM;
   const heightValue$ = heightSlider.value;

   const bmi$ = xs.combine(weightValue$, heightValue$)
     .map(([weight, height]) => {
       const heightMeters = height * 0.01;
       const bmi = Math.round(weight / (heightMeters * heightMeters));
       return bmi;
     })
     .remember();

   const vdom$ = xs.combine(bmi$, weightVDom$, heightVDom$)
    .map(([bmi, weightVDom, heightVDom]) =>
      div([
        weightVDom,
        heightVDom,
        h2('BMI is ' + bmi)
      ])
    );

   return {
     DOM: vdom$
   };
 }
```

The takeaway is: **when creating multiple instances of the same type of component, just remember to `isolate` each.**

<a class="jsbin-embed" href="//jsbin.com/seqehat/embed?output">JS Bin on jsbin.com</a>

## Recap

To achieve reusability, **any Cycle.js app is simply a function that can be reused as a component in larger Cycle.js app**. Sources and sinks are the interface between the application and the drivers, but they are also the interface between a child component and its parent.

![nested components](img/nested-components.svg)

From a component's perspective, it should make no assumption on what the parent is. The parent could either be the drivers if the component is used as the `main()`, or the parent could be any other component. For this reason, a component should assume its sources contain only data related to itself. Therefore, the sources and sinks of a component must be *isolated*.

Use `isolateSource` and `isolateSink` to separate the execution contexts of sibling components or unrelated components. Use `isolate` to create a component that automatically applies `isolateSource` and `isolateSink`. This way your codebase will be safe against [*collisions*](https://en.wikipedia.org/wiki/Collision_%28computer_science%29), and each component can work as if it would be the only one in the application.

Each driver should define static functions `isolateSource` and `isolateSink`. We only saw those functions implemented for the DOM Driver, but there are other use cases with other drivers where it makes sense to apply the same isolation techniques. To learn more, read about [Drivers](drivers.html).