npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

react-cycle-connect

v0.0.2

Published

Bring Cycle.js dataflows and side effect control to React.

Downloads

4

Readme

react-cycle-connect

An experimental library that allows using Cycle.js as a reactive dataflow framework for React. Written in TypeScript.

This little thing allows to define your app's view layer using regular React components, but implement the actual logic behind it using pure functional reactive approach, taking advantage of Cycle.js' Model-View-Intent pattern and its exceptional side effect control.

This isn't a custom implementation of Cycle pattern for React but a link to real Cycle.js instead, so any Cycle.js drivers and wrappers can be applied (e.g., cycle-onionify for state management). Also, for this reason, some knowledge about how Cycle.js works is assumed.

Note: This is an experimental lib and currently works with React and xstream only. If all goes good, the support for preact/inferno (instead of React) and for RxJS/most (instead of xstream) is anticipated. Nothing prevents from having react-native covered too.

Any feedback is highly appreciated!

Table of Contents

Shup up and show me the code

Cycle.js's counter example ported over:

import React from 'react';
import ReactDOM from 'react-dom';
import xs from 'xstream';
import { run } from '@cycle/run';
import { cycleConnect } from 'react-cycle-connect';

// Your Cycle.js `main` function

function main(sources) {
  const action$ = xs.merge(
    sources.interactions.decrement.map(ev => -1),
    sources.interactions.increment.map(ev => +1)
  );
  const count$ = action$.fold((acc, x) => acc + x, 0);

  return {
    props: count$.map(count => ({ count }))
  };
}

// Your React component

function App({ interactions, count }) {
  return (
    <div>
      <button onClick={interactions.decrement}>Decrement</button>
      <button onClick={interactions.increment}>Increment</button>
      <p>Counter: {count}</p>
    </div>
  );
}

// Some connection magic

const ConnectedApp = cycleConnect(main, {
  root: true,
  runFn: run
})(App);

// Render normally as it's a regular React app

ReactDOM.render(
  <ConnectedApp />,
  document.getElementById('root')
);

Also - there's official TodoMVC example with cycle-onionify for state management.

Installation

Using npm:

$ npm install --save react-cycle-connect

Using yarn:

$ yarn add react-cycle-connect

Motivation

The motivation to create this lib was based off the need to retain the React-style composability of the view layer using regular React components, reuse existing React components, make it (the lib) easily/incrementally adoptable in an ongoing React project, but also take advantage of the Cycle.js' MVI pattern and side effect control.

At the same time, while the most valuable thing from Cycle is the pattern itself, there are numerous available libs/plugins for Cycle.js to take advantage of, thus taking the best of both worlds.

This way, you can still use anything from React stack (e.g., react-router to compose your routed app screens), but describe the interactions as a dataflow, with pure functions and reactive streams. Most of React components are going to be "dumb" (and also stateless functional aka SFC) since all the backing logic and cross-component communication would be handled by a tree of Cycle.js programs.

Why not just use Cycle.js alone?

That is what I'd recommend considering in the first place. Its fast, its cool, the snabbdom (virtual DOM rendering engine behind Cycle's DOM driver) is goddamn fast and reliable. If you don't intend to use React components in any way, don't even get bothered. The react-cycle-connect is more about "introducing Cycle.js to React", not the other way around.

Is it a "Cycle.js in React" or "React in Cycle.js"?

More of a "Cycle.js in React". The app is bootstrapped by rendering a React component to the DOM, view is composed the React way. The root Cycle.js program is being run by rendering a root cycleConnect()ed component. Though, it doesn't prescribe how you write your Cycle.js programs, and aside from the DOM generation part, its just Cycle.js all the way.

How it works (architecture)

Every cycleConnect()ed component got a real Cycle.js program "attached" to it. The boundary between the two is props. From your Cycle.js program standpoint, props is a view. Cycle.js program can update props that React component will then render.

A bunch of nested React components in your app represent a "tree of React components". react-cycle-connect maintains a "tree of Cycle.js programs" that map to cycleConnect()ed components and takes advantage of React's context feature to provide links between the nodes. Each connected component provides scoped links to the children nodes .

New "Cycle nodes" are attached lazily, upon associated React component initialization (this is where React controls how Cycle programs are run). The above-mentioned "link" is just an object on React context that brings couple properties:

  • sources for the child connected component's main function; and
  • sinkProxies to subscribe to that component's main function sinks.

This is where something unusual for the Cycle.js happens - we subscribe to and unsubscribe from connected component's main function sinks based on React component's lifetime hooks. In Cycle.js, you normally just run the app at startup and let it run as an ongoing process. But this is where react-cycle-connect performs the sync between the two worlds.

Overall, aside from using React context (which is just an implementation detail) and some intense wrapping on the react-cycle-connect side to make laziness possible, react-cycle-connect tries to represent the entire tree of Cycle.js programs as close as possible to what you'd end up with by writing idiomatic Cycle.js with explicit calling of nested component's main functions from the parent component main function. The result of children components is always merged.

Aside from passing the sources to children components, react-cycle-connect also explicitly applies some internal wrappers to integrate with React component: lifecycle, props and interactions, that are scoped to a connected component's Cycle.js program only. It also explicitly runs the internal fnCallEffect driver for the root nodes by default - this is necessary for the interactions sink to work (because its supposed to run callbacks passed via props and that might be an effectful function).

Note that the entire internal "tree" of Cycle.js programs is maintained pure, including internal component-scoped wrappers, just as you would do with regular Cycle.js program.

Differences from idiomatic Cycle.js usage

Note: there's a difference in how'd you write the react/react-cycle-connect app vs pure Cycle.js app. There's no misuse of Cycle.js per se, we just don't use it for rendering with react-cycle-connect - and that applies certain implications to the approach.

React and Cycle.js, while both allow to create user interfaces, imply different approaches to the app architecture. The most important part here is that they are conceptually different when it comes to generating the VDOM.

In order to combine the two approaches nicely, there are certain deviations from how'd you write the Cycle.js-only app to allow for more optimal React DX.

  • The main difference from a pure Cycle.js way is that its not a Cycle program that technically generates VDOM - instead, there's React component to represent a piece of UI. Cycle.js approach to view/DOM event handling is rather purist - view only defines the markup and nothing else (i.e., user interaction/event handlers are defined inside Cycle program itself and not as some view component onClick/etc attribute). That is not a crazy random opinion but instead a very well thought and weighted out decision by Cycle.js team. react-cycle-connect takes a different approach, which isn't that strict and is more in line with React, with callbacks amongst props and all that stuff. It enables the events/component callbacks handling via interactions.

  • The view composition is obviously completely different. In Cycle.js you run each component's main function that (possibly) generates a VDOM node and then explicitly combine the pieces of markup into a single VDOM stream that is then given to a DOM driver. With react-cycle-connect you compose the view layer with React components normally. You can just connect those to Cycle.js program to "outsource" the actual logic to it.

Guide

This assumes the preliminary knowledge of both React and Cycle.js (and xstream). It doesn't provide much of the insight on how these work, but is instead focused on how react-cycle-connect combines the two.

Basics

The result of cycleConnect() call is a HOC that optionally takes a React component (similar to that connect() function of react-redux) and returns a component that is augmented with backing Cycle.js program. This Cycle.js program is internally wrapped into a "Cycle Node" that, when initialized, is connected to a tree of Cycle.js programs. "Cycle Nodes" of every cycleConnect()ed components are linked together via React context.

Note, though, that there are no direct references from one component's Cycle program to another component's Cycle program, i.e., you're not invoking one main function from the other (this is perfectly possible though, but since we're not using DOM driver here, it would only handle non-DOM stuff). Instead, react-cycle-connect handles the linking behind the scenes, so the "sibling" Cycle programs are fed with the same sources and subscribe their sinks to the same sinkProxies.

There's essential concept of isolation in Cycle.js, so every cycleConnect()ed component accepts an isolate prop, that is directly passed to isolate function from @cycle/isolate package. Read more about Cycle.js isolation here.

Root Cycle

Every cycleConnect()ed component provides a cycleNodeLink object to its children through React context and also expects that cycleNodeLink from some parent component (not necessarily immediate parent).

That link object has the following shape:

{
  // Sources to provide to our Cycle.js program's `main` function as an argument
  sources: { ... },

  // sinkProxies to subscribe to our Cycle program's `main` function sinks
  sinkProxies: { ... }
}

You shouldn't be working with that directly but its important that something needs to provide the top-level sources and sinks in the first place. This is what root option of cycle connect for.

Also, the cycleConnect()ed components' programs (main functions) are lazily attached to already running upper-level Cycle.js program, so the root program has to be run.

So, root cycleConnect, aside from { root: true } option, also needs runFn defined, so that it knows how to run your top-level Cycle.js program. This would normally be just a run function from @cycle/run package (or @cycle/rxjs-run, or @cycle/most-run, depending on the reactive streams library in use).

You could just cycleConnect() your top-level App component like we did in the first example:

import { run } from '@cycle/run';
import { cycleConnect } from 'react-cycle-connect';
...

function main(sources) {
  ...
}

function App() {
  return (
    // Some JSX
    ...
  );
}

const ConnectedApp = cycleConnect(main, {
  root: true,
  runFn: run
})(App);

Though this will work, its advisable to create a specialized RootCycle component just for this purpose:

// RootCycle.js

import { run } from '@cycle/run';
import { cycleConnect } from 'react-cycle-connect';

function main(sources) {
  ...
}

export const RootCycle = cycleConnect(main, {
  root: true,
  runFn: run
})(); // No component to wrap? Its okay, read ahead

export default RootCycle;

The top-level Cycle sources are actually drivers and wrappers, so root Cycle is also in charge of providing these drivers and wrappers to any Cycle.js program down the tree, as sources.

drivers option is the object with keys defining properties of the sources object, identical to how you'd do that in a regular Cycle.js program.

wrappers option accepts an array of wrappers (order might matter there, so its a user-land option).

Also, the main function is optional (because you might need a cycleConnect to be a root or for isolation only), so you can skip it, unless you have some logic to implement there.

Note that the wrappers can be passed to any cycleConnect, even non-root, but drivers option is only valid for root Cycle and is ignored otherwise.

// RootCycle.js

import { run } from '@cycle/run';
import { makeHTTPDriver } from '@cycle/http';
import onionify from 'cycle-onionify';
import { cycleConnect } from 'react-cycle-connect';

const drivers = { HTTP: makeHTTPDriver() };
const wrappers = [onionify];

export const RootCycle = cycleConnect({
  root: true,
  runFn: run,
  drivers,
  wrappers
})();

export default RootCycle;

RootCycle component is basically in charge of bootstrapping the "Cycle" part of your app here.

Then just wrap your App with it and e.g., render with ReactDOM:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import RootCycle from './RootCycle';

ReactDOM.render(
  (
    <RootCycle>
      <App />
    </RootCycle>
  ),
  document.getElementById('root')
);

render option

You might have noticed in axamples above that we're not providing any component to a cycleConnect HOC, leaving empty braces instead:

const ConnectedComponent = cycleConnect({
  root: true,
  runFn: run,
  drivers,
  wrappers
})();

Well, that is not a mistake - the wrapped component is optional. When a "component to wrap" is provided to cycleConnect(...)(ComponentToWrap), then if we render the connected component, the cycleConnect will also render ComponentToWrap internally.

Thats all good and works (and what you'll be using most of the time anyway), but sometimes you just want to keep your React Element tree a bit cleaner when the wrapped component would be a kind of noop anyway (just forwarding children and nothing else), or component is some technical/lower-level like RootCycle, or have some special rendering behavior.

So, we want to avoid seeing this in React Developer Tools:

 <cycleConnect(SomeComponent)>
   <SomeComponent>
     ...
   </SomeComponent>
 </cycleConnect(SomeComponent)>

You can use render option for that. It accepts the function with this signature:

(props) => ReactNode | null

If no component is provided for wrapping, nor render option is defined, the render option defaults to (props) => props.children || null function, so its either forwarding children if any or renders nothing.

If both render option is provided and component-to-wrap is passed, then render takes the precedence.

Note: Why not use the render option in place of wrapped component all the time? Well, its technically possible, espeically if your React component is a stateless functional component anyway.

But regular HOC wrapping is just common technique in React world and is nicely composable when you have other HOCs than cycleConnect.

And is cleaner too - you might be prototyping your app as a set of React components first, without even involving react-cycle-connect, then "connect" them all at once, or introduce react-cycle-connect incrementally. Whatever :)

displayName option

Your component's displayName is by default automatically assigned to the following (depending on whether the wrapped component is provided, or render option, or neither:

  • cycleConnect(WrappedComponentNameHere) when WrappedComponent is passed
  • cycleConnect(customRenderFn) when render prop is defined (and is truthy)
  • cycleConnect(defaultRenderFn) otherwise

You can override that like with any regular React component:

const ConnectedComponent = cycleConnect({
  ...
)(Component);

ConnectedComponent.displayName = 'ConnectedComponent';

But there's also a displayName option to set the generated (connected) component displayName, which looks a bit cleaner:

const ConnectedComponent = cycleConnect({
  ...
  displayName: 'ConnectedComponent'
)(Component);

Inside the Cycle

react-cycle-connect really assumes that you know how to use Cycle.js. Its really a very simple yet very elegant pattern, so make sure to familiarize yourself with it.

Quick recap on what Cycle.js main function is. Its basically a pure function with the signature:

main :: (Sources) => Sinks

It accepts sources as an argument and returns sinks and describes a dataflow using composition of reactive streams and their transformations with pure functions.

Please note that the main function is only run once per React component lifecycle. It doesn't run again on rerendering or something. This is because the actual call to main(...) is to just build up a dataflow, not to get some rendering data.

The data flows through reactive streams, from sources to sinks of the main, until its sinks are unsubscribed from parent Cycle program's sinkProxies (this happens when React component is unmounted).

Drivers

The driver is supposed to run side effects for a Cycle.js program. Its outside the program itself, supposed to be impure and connects your Cycle app with the "outer world". This is an essential concept and is described in a great detail in Cycle.js docs on drivers.

There are no differences from idiomatic Cycle.js in how react-cycle-connect handles drivers - it just hands it over to @cycle/run directly. There is only one react-cycle-connect driver (an fnCallEffect driver) that is always added internally by the cycleConnect({ root: true })ed component - it allows to invoke functions outside the main that is to be maintained pure.

Wrappers

The wrapper is a rather simple thing - its just a function that takes main function that wraps it with the other main function that might in turn add some logic in between:

function someWrapper(originalMain) {
  return wrappedMain(sources) {
    // Can do something here, like transforming `sources` somehow
    // ...

    // Invoke the original `main` internally to obtain its sinks
    const sinks = originalMain(sources);

    // Can do something to `originalMain`s sinks here
    // ...

    return {
      ...sinks,
      someOtherSink: xs.of({ ... }) // Can add a new sink
    };
  }
}

It is important that the wrapping main is maintained pure, so that your entire program is pure. If you need some effectful logic to run in a wrapping main, you should consider using driver for that. Wrappers, just like any regular main function, can internally use sources that are created from drivers.

Wrappers can also add arbitrary sources and could expect certain sinks to be returned from the Cycle program, depending on particular wrapper implementation.

Wrappers are usually created to encapsulate some non-business-logic stuff, you'd be normally describe anything related to the app itself in the "original" main function.

Most of react-cycle-connect (aside from cycleConnect(...) HOC itself) is implemented with wrappers, that are created for each cycleConnect()ed component automatically:

  • reactPropsWrapper (to mess around with React component props)
  • reactLifecycleWrapper (a gateway to React component lifecycle streams)
  • interactionsWrapper (to route the view events back from your wrapped React component aka view, and pass the events upstream, to parent components)

Sources

This is a Cycle.js program's input. main function shouldn't care whether something on the sources is a driver or some custom thing provided by one of the wrappers.

For root connected component, the sources are drivers directly plus whatever wrappers add to them.

Sinks

This is a Cycle.js program's output.

react-cycle-connect invokes your cycleConnect()ed component's main function, takes its sinks and subscribes the sinkProxies passed from the parent component to these sinks. Its how react-cycle-component is "attaching" the new component to a tree of Cycle programs.

If there's no parent component (thus, no sinkProxies) and your component is a "Root Cycle", then the sinks are handled to @cycle/run to "run" as with a regular Cycle.js app.

React props

The cycleConnect()ed React component is essentially a view for the associated Cycle.js main function. And react-cycle-connect encourages the view to be a "dumb" stateless function that takes props as an argument ad returns something of ReactNode type (i.e., ReactElements, null, etc, whatever React component renders).

Note: The wrapped React shouldn't necessarily be a stateless functional component (SFC), you could have any React component wrapped, but its advisable to use functions for the most part as the whole point of react-cycle-connect is to use Cycle.js for anything that is not a view.

So, the props is the only communication channel from our Cycle.js part (the connected component's main function) to connected React component itself. We therefore need to pass the props from our Cycle program to the view (a React component) somehow.

This is where reactPropsWrapper comes into play and provides props source and sink for a main function. Its local to a component, so no other components will share the same props source or sink.

props sink

The props sink on cycleConnect()ed is there to pass the props to the view:

function main(sources) {
  return {
    props: xs.of({ greetable: 'World' })
  };
}

// Use the passed `greetable` prop

function MyComponent(props) {
  return (
    <div>
      Hello {props.greetable}!
    </div>
  );
}

const MyConnectedComponent = cycleConnect(main)(MyComponent);

Remember, that the value you map to props sink should be an object of new props and that updated props will be merged with existing component props like:

const updatedProps = Object.assign({}, prevProps, newProps);

This means that you can easily overwrite any props you want and leave the others intact.

Also, note above that we're passing the props through an Observable stream and that might be synchronous (like in our example with a stream of static data that is sent immediately) but could also be asynchronous. In that case the value of some prop might be not yet available on the first render:

function main(sources) {
  return {
    props: xs.of({ greetable: 'World' }).compose(delay(3000))
  };
}

// Trying to use the passed `greetable` prop
// But whooops, no `greetable` yet :(

function MyComponent(props) {
  return (
    <div>
      Hello {props.greetable}!
    </div>
  );
}

const MyConnectedComponent = cycleConnect(main)(MyComponent);

So either provide a default value for that async prop's Observable or perform additional checks in the view, when rendering.

Note: When messing with the props in your main, remember that any props that are passed from outside as an input to a React component, are also there (including callback functions, if there are any) and are also merged upon updates from outside (on componentDidUpdate).

Lets summarize: the props from the props sink are passed from the main function to the view (React component).

props source

Not only you'll need to provide the props to the view for rendering, but oftentimes you'd need your props to take part in transformations/calculations in the main function itself. For that reason, reactPropsWrapper provides the props source as well.

It allows you to have an Observable stream of your component's input props. Note that the sources.props is of type ReactPropsSource and to take the actual stream of props, use its props$ attribute:

function main(sources) {
  const name$ = sources.props.props$.map(props => props.name);

  return {
    props: name$.map(name => ({ name: name.toUpperCase() }))
  };
}

function MyComponent(props) {
  return (
    <div>
      Hello, {props.name}! Your father is Mr. {props.father}.
    </div>
  );
}

const MyConnectedComponent = cycleConnect(main)(MyComponent);

...

// Somewhere else:
<MyConnectedComponent name="Luke Skywalker" father="Darth Vader" />

// renders:
//
// <div>
//   Hello, LUKE SKYWALKER! Your father is Mr. Darth Vader.
// </div>

The new value on props$ is emitted every time props change. This could be overkill for the most cases, as you would only need just few props for your dataflow.

This is what ReactPropsSource.pluck() method for, it accepts the name of the nested prop (will return undefined if the stream value is not an object) and returns a stream of that prop only that is only got new value once this particular prop changes:

function main(sources) {
  const name$ = sources.props.pluck('name');

  return {
    props: name$.map(name => ({ name: name.toUpperCase() }))
  };
}

The pluck()ed stream also performs shallow comparison and does dropRepeats() behind the scenes, so identical values don't cause a new value emitted on an Observable.

Note that you don't need to always pull all props from your main function's sources.props just for the sake of it to just forward to the props sink directly, you better only pick those you need in a dataflow with ReactPropsSource.pluck(). The props passed from outside will be available on the view normally. You can overwrite them from inside your main though.

There's also ReactPropsSource.select() method that also accepts a nested prop name. The difference is that the result is not an Observable stream of prop values but the ReactPropsSource instance itself that is scoped to that piece of props:

function main(sources) {
  const name$ = sources.props.pluck('name'); // Stream of strings
  const nameSource = sources.props.select('someNestedObject'); // Scoped ReactPropsSource

  const someNestedObjProperty$ = nameSource.pluck('someNestedObjectProperty');
  ...
}

This is useful when you want to pluck() a stream of some sub-prop from deeper nested objects.

Note: Taking deep property stream this way isn't really nice currently as requires quite a bit of boilerplate - this part of API is already being thought out further and reconsidered.

Interactions

So far so good, but rendering the props isn't the only exciting thing we want to do with React, right? We also want to handle events coming from the view(e.g., the DOM).

The way react-cycle-connect handles events and nested components' callbacks is the biggest deviation from an idiomatic Cycle.js app using DOM driver. It refrains from a holistic "no handlers in the view" approach for a few reasons:

  • We need something more in line with React as we're using React here
  • We're not always handling DOM events but also need to handle some children components data/events passed back. We can't pass streams like in regular Cycle.js app, in React we do it via callbacks that are provided amongst children components' props and we need cycleConnect()ed components to be compatible React components at all levels.

This is what "interactions" feature of react-cycle-connect is for. It provides the unified interface for parent-child components communication and event handling that is aligned with a regular event/callback handling model on React side but also allows to access those events in form of Observable streams inside the main function.

Events triggering by the view

Every cycleConnect()ed component has some additional react-cycle-connect-specific props injected, so they are available on your view.

These are interactFn which is a function that takes the interactionType (a string) and optional predefinedValue (of any type) and returns a function that will be a callback for your event or some child component prop function.

So, the signature is:

(interactionType, predefinedValue?) => (value) => void

Its supposed to be called imperatively in your view and is available as props.interact:

function MyComponent({ interact }) {
  return (
    <div>
      <button type="button" onClick={interact('buttonClick')}>
        Click me!
      </button>
    </div>
  );
}

For the sake of being cleaner (and for better TypeScript support), react-cycle-connect also leverages the ES2015 Proxy feature to provide the object with dynamic properties, each representing an interact with interactionType dynamically defined after the function property name. Its available on props.interactions so we can rewrite the above snippet as:

function MyComponent({ interactions }) {
  return (
    <div>
      <button type="button" onClick={interactions.buttonClick}>
        Click me!
      </button>
    </div>
  );
}

In the example above, the interactions.buttonClick function receives the click event as an argument and that is what you'll get in your main (next section).

While cleaner, it doesn't allow for the predefinedValue to be set, so if you need some particular value to be emitted by the associated stream in your main, you can either use interact or create an inline function:

// Assuming you have `id` available, e.g.
// when looping/mapping over the collection of items

<button
  type="button"
  className="delete-btn"
  onClick={interact('delete', id)}
>
  Click me!
</button>

// OR

<button
  type="button"
  className="delete-btn"
  onClick={() => interactions.delete(id)}
>
  Click me!
</button>

View events handling with interactions source

There's internal interactionsWrapper in react-cycle-connect and it provides the interactions source for our main function. The interactions source is of type InteractionsSource, which has the InteractionsSource.select() method, which we can use to obtain interaction streams:

function main(sources) {
  const buttonClick$ = sources.interactions.select('buttonClick');

  // Do something with a buttonClick$, e.g.
  // map it to the click coordinates props

  return {
    props: buttonClick$.map(
      event => ({
        clickX: event.clientX,
        clickY: event.clientY,
      })
    )
  };
}

function MyComponent({ interact, clickX, clickY }) {
  return (
    <div>
      <span>
        Last clicked at: X - {clickX}, Y - {clickY}
      </span>

      <button type="button" onClick={interact('buttonClick')}>
        Click me!
      </button>
    </div>
  );
}

const ConnectedComponent = cycleConnect(main)(MyComponent);

The streams are select()ed by the same interactionType string you provided to the interact() function on your view.

Similar to how interactions object with dynamic function-props is available on your view, InteractionsSource is a ES2015 Proxy and it maintains the dynamic streams right on itself (just don't use select as its reserved method name here here):

function main(sources) {
  const buttonClick$ = sources.interactions.buttonClick;

  return {
    props: buttonClick$.map(
      event => ({
        clickX: event.clientX,
        clickY: event.clientY,
      })
    )
  };
}

function MyComponent({ interactions, clickX, clickY }) {
  return (
    <div>
      <span>
        Last clicked at: X - {clickX}, Y - {clickY}
      </span>

      <button type="button" onClick={interactions.buttonClick}>
        Click me!
      </button>
    </div>
  );
}

const ConnectedComponent = cycleConnect(main)(MyComponent);

Note: If you try to get the non-existing stream, it will still work, this thing is dynamic, so the stream will be created nevertheless but will just never emit values. Be careful and look out for typos! Using TypeScript is highly recommended to avoid typos here.

Upstream interactions with interactions sink (calling callbacks on props).

Now that we can handle the streams of view events and values from the children components, we would also need to somehow call the callbacks that are passed among our props.

First, nothing prevents from following the common React way and call those directly from the view:

function MyComponent({ onButtonClick }) {
  return (
    <button type="button" onClick={onButtonClick}>
      Click me!
    </button>
  );
}

// Somewhere else:
doSomething = (event) => { console.log('clicked!'); };

...

<MyComponent onButtonClick={doSomething} />

This is fine if you don't need that value's stream in your dataflow. This approach bypasses your Cycle.js program's main completely and doesn't make it impure by otherwise possibly passing an effectful function as onButtonClick. There's even no need to cycleConnect() your component in the above example.

Keep in mind that if onButtonClick is optional there, you will also need to check whether that is defined before providing that as a callback (use PropTypes or TypeScript to mark it as either required or optional).

Oftentimes you will need to call some callback that lives on the props though, and this is what interactions sink provided by interactionsWrapper does:

function main(sources) {
  const buttonClick$ = sources.interactions.buttonClick;
  const coords$ = buttonClick$.map(
    event => ({
      clickX: event.clientX,
      clickY: event.clientY,
    })
  );

  // We both update the `props` (to render it in this component)
  // and pass upstream
  return {
    props: coords$,
    interactions: xs.of({
      onCoordsChange: coords$
    })
  };
}

function MyComponent({ interactions }) {
  return (
    <div>
      <span>
        Last clicked at: X - {clickX}, Y - {clickY}
      </span>

      <button type="button" onClick={interactions.buttonClick}>
        Click me!
      </button>
    </div>
  );
}

const ConnectedComponent = cycleConnect(main)(MyComponent);

// Somewhere else:
handleCoordsChange = (coords) => {
  console.log('New coordinates received: ', coords);
};

...

<ConnectedComponent onCoordsChange={handleCoordsChange} />

// The new coords will be rendered by the component
// and the console will show the "New coordinates received"
// with new coords too.

Once the appropriate stream emits a value, that callback is called. It will also check if the callback was passed in the first place to skip the call attempt if no callback with a given name is passed among the props.

Important: Note that interactions sink expects a stream of object whose property names correspond to callback names on props and property values are themselves streams too - whatever values those streams emit, will be passed as an argument to the callback.

So far so good, but we know that our interactionsWrapper is a wrapper and not a driver, so what if one passed the effectful function as a callback, e.g., handleCoordsChange passed as onCoordsChange above does a console.log() internally - that is a side effect, right?

Well, this is why the function is not invoked directly, or inside the wrapper (since the wrapper is the part of the pure land of our mains), its using the fnCallEffect driver internally. More on this below in a section about Side-effect control, just keep in mind that wrappers and main should be pure and react-cycle-connect follows that requirement.

React component lifecycle streams

You're probably not going to need React component lifecycle hooks often inside your main, but still - they are provided via special reactLifecycleWrapper. It provides the lifecycle source that is available on each cycleConnect()ed main sources.

The lifecycle events are provided as Observable streams and their names mostly mirror the React lifecycle methods, but shortened to remove some redundant component prefixes:

| React lifecycle method | lifecycle source property | Stream value | |-----------------------------|-----------------------------|------------------| | componentWillMount | willMount$ | null | | componentDidMount | didMount$ | null | | componentWillReceiveProps | willReceiveProps$ | nextProps | | componentWillUpdate | willUpdate$ | nextProps | | componentDidUpdate | didUpdate$ | prevProps | | componentWillUnmount | willUnmount$ | null | | componentDidCatch | didCatch$ | Error data |

The "error data" on the didCatch$ is an object of this shape:

{
  error: Error;
  errorInfo: ErrorInfo;
}

These are basically React componentDidCatch(error, errorInfo) arguments squashed into a single object.

Also, note that shouldComponentUpdate hook is not present there, since that is not really an event and affects how your component is rendered. react-cycle-connect would be providing its own custom internal shouldComponentUpdate instead for optimisation purposes and you could implement it on your wrapped component anyway.

Note: As well, no lifecycle events are passing anything related to React component state, only taking props into account instead. This is because the state is meant to be managed differently when Cycle.js is in use. You can still use that for wrapped component but its out of play on the component generated by cycleConnect(...).

A quick example:

function main(sources) {
  const willUnmount$ = { sources.lifecycle };

  // Do something on unmounting, e.g. report that to a console
  // via some hypothetical `logger` driver
  return {
    logger: willUnmount$.mapTo(`I'll be unmounted soon!`)
  };
}

Side-effect control and fnCallEffect driver

Side-effect control is where Cycle.js really shines (not only implementation, it applies to the "cycle pattern" behind Cycle.js in the first place).

In order to keep the cycle part of cycleConnect()ed component pure, we need to make sure that any potentially effectful function is not run inside our main directly.

This is what fnCallEffect driver is responsible for. It allows to only describe what function you want to call and with what arguments, then delegate that to a driver which is supposed to run any impure functions outside of the app itself.

The way it works is very similar to that of HTTP driver from @cycle/http package where you don't actually run the request inside your main but just describe it and pass to HTTP driver to handle. fnCallEffect driver is just a bit more generic and operates on functions.

react-cycle-connect uses fnCallEfffect driver internally (to run effects for upstream interactions), but it is also available for you on any connected main as fnCallEffect sink automatically (i.e., without explicit configuration on a root cycle):

function log() {
  console.log(`I'm effectful!`);
}

function saveToLocalStorage(message) {
  localStorage.setItem('message', message);
}

function main(sources) {
  const someCallbackFn$ = sources.props.pluck('someCallbackFunction');

  return {
    fnCallEffect: xs.merge(
      xs.of({ fn: log }),
      xs.of({ fn: saveToLocalStorage, args: ['some message to save'] }),
      // Call the `props` callback function directly, this is how
      // `interactions` sink works behind the scenes:
      someCallbackFn$.map(fn => ({ fn }))
    )
  };
}

Isolation

Isolation is Cycle.js way of making the component's sources and sinks isolated from influence of other components.

Make sure to carefully read more about Cycle.js isolation here. It might take a bit to get used to an idea.

To reiterate: the concept of isolation is important for Cycle.js apps. Its a little bit different for react-cycle-connect because most things are scoped to a particular cycleConnect()ed component already, like components have "local" interactions, props and lifecycle sources and sinks and React components are also self-contained already.

There are though drivers/wrappers that will need the isolation feature, like HTTP driver or cycle-onionify wrapper (a state management solution for Cycle.js apps). So, isolation makes sense for shared drivers/wrappers, those you pass to @cycle/run function (by defining the drivers/wrappers options on root cycleConnect(...)) and that will be available across the entire tree of Cycle programs.

Note: The isolation works by scoping the source streams to particular component and a kind of unscoping of the sink streams before connecting to parent's sinks.

Also note, that each driver/wrapper decides on its own how its sources/sinks should be isolated.

In Cycle.js, you use the isolate() function from @cycle/isolate package directly, as you're just invoking the children main functions from your current component's main function:

function main(sources) {
  const childSinks = isolate(childMain, { DOM: 'foo', HTTP: 'bar' })(sources);

  // Do something with childSinks, combine somehow, etc

  return {
    ...
  };
}

isolate function has this simple signature:

isolate :: (MainFn, scope) => MainFn

It takes the main function and an optional scope and returns an isolated main function.

react-cycle-connect does also use the @cycle/isolate package internally, but you can't call isolate() directly on some childMain function. You'd never call one component's main from the other component's main directly with react-cycle-connect.

Naive explicit isolation (conceptually incorrect)

Just to illustrate the issue, there's an attempt to isolate the component's HTTP sources, so that HTTP requests initiated from that components are scoped to it (and are unavailable to siblings).

Since we know that isolate() returns a main function and cycleConnect() takes the main function as an argument, lets try to feed it an isolated version of it:

function main(sources) {
  ...
}

function MyComponent() {
  ...
}

const isolatedMain = isolate(main, { HTTP: 'myComponent' });
const ConnectedComponent = cycleConnect(isolatedMain)(MyComponent);

While this will won't raise any errors (because this is valid code after all), this approach brings a subtle issue.

This will isolate things for this particular components but not their children components, so in this case, children (connected childred React components) will have no myComponent isolation scope for HTTP channel.

This might seems like a no-issue, because hey, you can define different scopes for the children components right? Well, while this is technically possible, its conceptually incorrect and it will be completely broken in some cases, e.g., with cycle-onionify where the deeper you go - the "more filtered" source streams you get.

Important! The above note about it being "conceptually incorrect" refers to the way react-cycle-connect maintains the tree of Cycle.js programs internally. It tries to make the entire app structure look the same way as you'd manually write a Cycle.js app. So, when you isolate(childMain) at some level, then if inside your childMain you also call some grandChildMain(...) happens, then grandChildMain gets isolated sources no matter what.

isolate option for cycleConnect()

The first way to correctly isolate cycleConnect()ed component is to use the isolate option for cycleConnect() itself, so the above can be rewritten as:

function main(sources) {
  ...
}

function MyComponent() {
  ...
}

const ConnectedComponent = cycleConnect(main, {
  isolate: { HTTP: 'myComponent' }
})(MyComponent);

This will work perfectly, with all children cycleConnect()ed components having myComponent namespace applied to HTTP sources/sinks in their main functions.

The isolate option accepts a string or the object and in that case its passed directly to @cycle/isolate package's isolate(main, scope) as a scope argument. So, check out the isolate function API reference.

react-cycle-connect also makes it accept a function that takes component props as an argument and should return the scope of string or object type that is then also passed to @cycle/isolate package's isolate(main, scope). This allows for some dynamic isolation scopes to be set based on the React component's initial props.

Important: Despite the isolate is provided as an option to cycleConnect() when defining the connected component, the actual isolation will only happen when component instance is created. This is why its possible to dynamically resolve the scope based on the component props in react-cycle-connect.

isolate prop

Despite the isolate option will work nicely for some cases, there's a pattern in how it is utilized in pure Cycle.js programs. Lets take a look at pure Cycle.js example again:

function main(sources) {
  const childSinks = isolate(childMain, { DOM: 'foo', HTTP: 'bar' })(sources);

  // ...

  return {
    ...
  };
}

The important part is that in Cycle.js app its not a child who decides how to isolate itself but scope is provided from the parent instead.

Most of the time, we want to maintain the same approach with react-cycle-connect. The advantage is that component in this case is totally agnostic from the provided scope, it doesn't even care whether its isolated or not at all - all it knows is that it expects certain sources and returns certain sinks. This makes the component reusable.

This is especially important with cycle-onionify, where you want to control where your child components "attach" to the state.

In other words, the parent decides how to compose children components, not the other way around, because otherwise the child would need to have an intimate knowledge of something that really belongs to parent and thus not being reusable and breaking encapsulation.

For this reason, every cycleConnected React component accepts an isolate prop, so you can use that just like any other prop:

function main(sources) {
  ...
}

function MyComponent() {
  ...
}

const ConnectedComponent = cycleConnect(main)(MyComponent);

// Somewhere else:

// Isolate everything
<ConnectedComponent isolate="myComponent" />

// Isolate HTTP only
<ConnectedComponent isolate={{ HTTP: 'myComponent' }} />
<ConnectedComponent isolate={{ HTTP: 'myComponent_2' }} />

In contrast with isolate option, the isolate prop can only accept string or object that is directly passed to isolate() function from @cycle/isolate, thus maintaining full parity with pure Cycle.js approach. Indeed, having a function that accepts initial props wouldn't make much sense because you're anyway providing props that could be different for different component instances when rendering that component.

<Isolator> helper component

Since isolation in react-cycle-connect applies to both "current" cycleConnect()ed component and all its cycleConnect()ed children, we might sometimes need to define some connected component to only be a common parent for some children, which must provide isolation but wouldn't contain any logic.

This is what a helper Isolator component for. It accepts the scope prop (that is directly passed as a scope argument to isolate(main, scope) function of @cycle/isolate):

<Isolator scope={{ HTTP: 'someNamespace' }}>
  <ConnectedComponent someProps="1" />
  <ConnectedComponent someProps="2" />
  <ConnectedComponent someProps="3" />
</Isolator>

This assists the isolation composition at view level.

Extra: State management with cycle-onionify

cycle-onionify is the idiomatic Cycle.js fractal state management solution. It is tailored for Cycle.js apps but since react-cycle-connect is using the real Cycle.js, it stays fully compatible here as well.

Using cycle-onionify with react-cycle-connect is optional, since you might be using different lib (e.g., redux) for that.

To start, just read the onionify docs and install the package:

$ npm install --save cycle-onionify

and pass that with wrappers array to a root connected component (e.g., Root Cycle):

// RootCycle.js

import { run } from '@cycle/run';
import onionify from 'cycle-onionify';
import { cycleConnect } from 'react-cycle-connect';

const wrappers = [onionify];

export const RootCycle = cycleConnect({
  root: true,
  runFn: run,
  wrappers
})();

export default RootCycle;

Now every cycleConnect()ed component that is a child of the above-mentioned RootCycle in React component tree got the onion source and sink.

Important: Notice that you're all set - its purely a Cycle.js land. react-cycle-connect doesn't provide any additional functionality for cycle-onionify. Instead, it provides few helpers for isolation and working with collections on the React side.

All the exportables related to onionify reside in the extra package: react-cycle-connect/lib/extra/onionify. It is installed along with the main lib but is not require()d/imported by default, so won't be bundled (by Webpack, Rollup, etc) unless you're using it.

This extra sub-package contains few things:

  • <StateIsolator> helper component
  • <Collection> helper component
  • makeFilteredListLens() helper factory function

Read ahead for details on each.

In cycle-onionify, the state/reducers layer composition is done via isolation, so the first thing here is <StateIsolator>:

<StateIsolator> helper component

This is pretty much like <Isolator> but is here to specifically isolate onion sources/sinks only.

So, the:

import { StateIsolator } from 'react-cycle-connect/lib/extra/onionify';

// ...

<StateIsolator lens="someStateProperty">
  <NestedComponent />
</StateIsolator>

is somewhat a convenient shortcut to:

import { Isolator } from 'react-cycle-connect';

// ...

<Isolator scope={{ onion: 'someStateProperty', '*': null }}>
  <NestedComponent />
</Isolator>

Notice how StateIsolator's prop is explicitly named lens. It just implies that oftentimes you'll be using "lens" to isolate the state. Consider a string just a shortcut to a more elaborate lens object.

In the example above, the <NestedComponent>'s main function sources.onion.state$ will represent only that "someStateProperty" piece of state. Pretty much how you'd isolate() the onion channel in an idiomatic Cycle.js app.

There's also a channelName property, so that you can change the name of the source/sink inside your main to something else. Thats to be on par with cycle-onionify features, where you can also change that name.

Handling collection state manually

Managing collections of components in pure Cycle.js with onionify is a kind of a recognized challenge. This is why cycle-onionify ended up providing makeCollection() helper function - see their docs for more detail.

While react-cycle-connect also has a <Collection> helper component that is very similar to cycle-onionify makeCollection() function, lets first try to pretend we don't have it and imagine how'd we handle the state without it.

Note: Nothing prevents you from skipping right to a description of how <Collection> works, but it might be useful to understand the rationale behind it, so reading this section is recommended.

Handling collections is different with React than in Cycle.js, e.g., you can always just render React components in a loop or .map() an array of data to an array of components and React will render that.

When you want to render some Array data from your state, its easy without any helpers:

/*
  Assume you have this value on your `List` component `state$`:

  {
    list: [
      { id: 1, name: 'Obi-Wan Kenobi', kills: 100 },
      { id: 2, name: 'Luke Skywalker', kills: 25 },
      { id: 3, name: 'Darth Vader', kills: 9000 }
    ]
  }
*/

// ListItem.jsx

function ListItem({ itemData }) {
  return (
    <li>
      {itemData.name} (ID: {itemData.id}, kills: {itemData.kills})
    </li>
  );
}

// List.jsx

function main(sources) {
  // Just pass as `props` directly
  return {
    props: sources.onion.state$
  };
}

function List({ list }) {
  return (
    <ul>
      {list.map(item => (
        <ListItem key={item.id} itemData={item} />
      ))}
    </ul>
  );
}

const ConnectedList = cycleConnect(main)(List);

This keeps your ListItem component just a "dumb" view, there's no even need of connecting it to the Cycle tree. So far so good.

Then you want to add more item-level capabilities, like editing an item (incrementing hero kills in our case, for simplicity) or deleting an item. You can still manage the state in the List component, act upon some callbacks for particular item and only stream reducers from inside List component's main:

/*
  Assume you have this value on your `List` component `state$`:

  {
    list: [
      { id: 1, name: 'Obi-Wan Kenobi', kills: 100 },
      { id: 2, name: 'Luke Skywalker', kills: 25 },
      { id: 3, name: 'Darth Vader', kills: 9000 }
    ]
  }
*/

// ListItem.jsx

function ListItem({ itemData, onIncrementClick, onDeleteClick }) {
  return (
    <li>
      {itemData.name} (ID: {itemData.id}, kills: {itemData.kills}) -
      <button type="button" onClick={onIncrementClick}>
        Increment "kills"
      </button>

      <button type="button" onClick={onDeleteClick}>
        Delete
      </button>
    </li>
  );
}

// List.jsx

function intent(interactions) {
  return {
    itemIncrement$: interactions.itemIncrementClick,
    itemDelete$: interactions.itemDeleteClick
  };
}

function model(actions) {
  const defaultReducer$ = xs.of(function defaultReducer(state) {
    if (typeof state !== 'undefined') return state;
    return {
      list: []
    };
  });

  const incrementItemReducer$ = actions.itemIncrement$.map(
    (id) => function incrementItemReducer(state) {
      return {
        ...state,
        list: state.list.map(listItem => {
          if (listItem.id !== id) return listItem;
          return {
            ...listItem,
            kills: listItem.kills + 1
          };
        })
      };
    }
  );

  const deleteItemReducer$ = actions.itemDelete$.map(
    (id) => function deleteItemReducer(state) {
      return {
        ...state,
        list: state.list.filter(listItem => listItem.id !== id)
      };
    }
  );

  return xs.merge(
    defaultReducer$,
    incrementItemReducer$,
    deleteItemReducer$
  );
}

function main(sources) {
  const actions = intent(sources.interactions);
  const reducer$ = model(actions);

  return {
    onion: reducer$,
    props: sources.onion.state$
  };
}

function List({ interactions, list }) {
  return (
    <ul>
      {list.map(item => (
        <ListItem
          key={item.id}
          itemData={item}
          onIncrementClick={() => interactions.itemIncrementClick(item.id)}
          onDeleteClick={() => interactions.itemDeleteClick(item.id)}
        />
      ))}
    </ul>
  );
}

const ConnectedList = cycleConnect(main)(List);

There is already some noise in reducers, especially on updates. Also, what if there's another piece of state sibling to list property? List gets overburdened quickly.

You'd also quickly realize that this makes the collection item state inherently bound to List, so while you could be reusing ListItem somewhere else, you couldn't reuse its state management part.

This is unfortunate because certain actions ("edit" and "delete") are related to a single item and can be managed at that item's level. This is totally possible with cycle-onionify because its a fractal state management thing, so we could be taking advantage of that fractal aspect with react-cycle-connect too.

So, we'd better be managing internal item state at the ListItem component level. To do that, let's cycleConnect the ListItem component and make it handle its stuff. We need to carefully let each item's component know which piece of state does belong to it, and we'll use listLens for that:

/*
  Assume you have this value on your `List` component `state$`:

  {
    list: [
      { id: 1, name: 'Obi-Wan Kenobi', kills: 100 },
      { id: 2, name: 'Luke Skywalker', kills: 25 },
      { id: 3, name: 'Darth Vader', kills: 9000 }
    ]
  }
*/

// ListItem.jsx

function intent(interactions) {
  return {
    increment$: interactions.incrementClick,
    delete$: interactions.deleteClick
  };
}

function model(actions) {
  // Note: default reducer assumes that we always have a valid
  // state from the parent component. It just makes it undefined
  // explicitly otherwise.
  const defaultReducer$ = xs.of(function defaultReducer(state) {
    if (state) return state;
    return void 0;
  });

  const incrementReducer$ = actions.increment$.map(
    () => function incrementReducer(state) {
      return {
        ...state,
        kills: state.kills + 1
      };
    }
  );

  const deleteReducer$ = actions.delete$.map(
    () => function deleteReducer(state) {
      return void 0;
    }
  );

  return xs.merge(
    defaultReducer$,
    incrementReducer$,
    deleteReducer$
  );
}

function main(sources) {
  const actions = intent(sources.interactions);
  const reducer$ = model(actions);

  /*
  The initial `state$` value is something like this,
  due to isolation on the `List`.

    {
      id: 1,
      name: 'Obi-Wan Kenobi',
      kills: 100
    }

  */

  return {
    onion: reducer$,
    props: sources.onion.state$.map(state => ({ itemData: state }))
  };
}

function ListItem({ interactions, itemData }) {
  return (
    <li>
      {itemData.name} (ID: {itemData.id}, kills: {itemData.kills}) -
      <button type="button" onClick={interactions.incrementClick}>
        Increment "kills"
      </button>

      <button type="button" onClick={interactions.deleteClick}>
        Delete
      </button>
    </li>
  );
}

const ConnectedListItem = cycleConnect(main)(ListItem);


// List.jsx

function model() {
  const defaultReducer$ = xs.of(function defaultReducer(state) {
    if (typeof state !== 'undefined') return state;
    return {
      list: []
    };
  });

  // const addItemReducer$ = ...

  return xs.merge(
    defaultReducer$,
    // addItemReducer$, etc
  );
}

function main(sources) {
  const reducer$ = model();

  return {
    onion: reducer$,
    props: sources.onion.state$ // Merge state into props
  };
}

// A lens for particular item, to extract/update its state correctly
function itemLens(id) {
  return {
    get(arr) {
      if (typeof arr === 'undefined') return void 0;
      for (let i = 0, n = arr.length; i < n; ++i) {
        if (arr[i].id === id) {
          return arr[i];
        }
      }
      return void 0;
    },

    set(arr, updatedItemState) {
      if (typeof arr === 'undefined') {
        return [item];
      } else if (typeof item === 'undefined') {
        return arr.filter((itemState) => itemState.id !== id);
      }

      return arr.map((itemState) => {
        if (itemState.id === id) return updatedItemState;
        return itemState;
      });
    }
  };
}

// Note that we no longer need to pass the data to list items,
// they get it from `onion` state directly, on their own, but we still
// need to provide the instructions on how to `isolate` each item state,
// so that each component knows what item from collection it renders.
function List({ interactions, list }) {
  return (
    <ul>
      {list.map(item => (
        <ConnectedListItem
          key={item.id}
          isolate={{ onion: itemLens(item.id) }}
        />
      ))}
    </ul>
  );
}

const ConnectedList = cycleConnect(main)(List);

There's another issue with this. Now our listLens is intrinsically bound to a list property on a component state and , because we just know that the ConnectedList component state is of the shape:

{
  list: []
}

and item state has an id attribute. But what if List state is:

{
  someOtherListProp: []
}

or something else, or has other properties along the way, which we'd need to take into account then:

{
  someOtherListProp: [],
  somethingElse: 'some string',
  someOtherProp: 123,
  someNestedObject: { ... }
}

or (unlikely but still possible), the actual collection is an item in some other collection:

{
  [
    [], // list 1
    [], // list 2
    [], // list 3
    [] // etc
  ]
}

Also, what if there's another key for collection item and not an id attribute`?

To make item lens reusable, we need it to assume that the collection state is always an Array (or undefined) and pass a function to calculate the key.

Lets refactor this a bit, by extracting the use-case-specific property names out and explicitly isolate our list rendering with <StateIsolator> (unrelated code is omitted):

// ...

// A lens for particular item, to extract/update its state correctly
// Note that we rep