Git Product home page Git Product logo

cycle-onionify's Introduction

Cycle.js onionify

DEPRECATED. This package still works as is, but it has been "officialized" into Cycle.js monorepo as @cycle/state

Augments your Cycle.js main function with onion-shaped state management and a single state atom.

  • Simple: all state lives in one place only
  • Predictable: use the same pattern to build any component
  • Reusable: you can move any onionify-based component to any other Cycle.js codebase

Quick example:

npm install cycle-onionify
import onionify from 'cycle-onionify';
// ...

function main(sources) {
  const state$ = sources.onion.state$;
  const vdom$ = state$.map(state => /* render virtual DOM here */);

  const initialReducer$ = xs.of(function initialReducer() { return 0; });
  const addOneReducer$ = xs.periodic(1000)
    .mapTo(function addOneReducer(prev) { return prev + 1; });
  const reducer$ = xs.merge(initialReducer$, addOneReducer$);

  return {
    DOM: vdom$,
    onion: reducer$,
  };
}

const wrappedMain = onionify(main);

Cycle.run(wrappedMain, {
  DOM: makeDOMDriver('#app'),
});

What is onionify

A fractal state management tool for Cycle.js applications. onionify creates a wrapped main function, where the wrapped result will have a top-level state stream, and will pass that down to the actual main function. Onionify is a component wrapper, not a driver. This way, your application state won't live in a driver, because the wrapped main is still just a Cycle.js app that can be given to Cycle.run.

State stream as source, reducer stream as sink. Your main function can expect a StateSource object under sources.onion, and is supposed to return a stream of reducer functions under sinks.onion.

One large state tree for your entire application. All the state in your application should live in the state stream managed internally by onionify. Smaller components in your application can access and update pieces of state by interfacing with its parent component with the help of @cycle/isolate. The parent gives an isolated onion source to its child, and receives an isolated onion sink from its child. The parent component interfaces with the grandparent component in the same style. This makes state management fractal.

Diagram

stateA$ // Emits `{visitors: {count: 300}}}`
stateB$ // Emits `{count: 300}`
stateC$ // Emits `300`

reducerC$ // Emits `function reducerC(count) { return count + 1; }`
reducerB$ // Emits `function reducerB(visitors) { return {count: reducerC(visitors.count)}; }`
reducerA$ // Emits `function reducerA(appState) { return {visitors: reducerB(appState.visitors)}; }`

"Fractal" means that every component in the hierarchy is built in the same way as the top-level main function is built. As a consequence, there is no absolute "global" state, since every component treats its state management relative to its parent. The top-most component will have onionify as its parent.

As a consequence, state management is layered like an onion. State streams (sources) will be "peeled off" one layer each time they cross a component input boundary. Reducer streams (sinks) will be stacked one layer each time they cross a component output boundary.

Why use onionify

Simpler parent-child state coordination. Traditional Cycle.js state management with props$ and state$ is hard to grok and easy to shoot yourself in the foot. With onion-layered state, there is no distinction between props and state because both are encoded in the large state tree. If the parent needs to send "props" to a child, it can just directly update the child's state.

Eliminates most circular dependencies of streams. Cycle.js and xstream apps support building circularly dependent streams with imitate(), and this technique was often utilized for state management. It was easily a footgun. Because onionify internally uses a circular dependency of streams, it eliminates the need for applications to have any circular dependency related to state.

Familiar technique: reducer functions and a single state atom. Like Redux and the Elm architecture, onionify allows you to contain all of your application state in one large tree of objects and arrays. To update any part in that state tree, you write reducer functions: pure functions that take previous state as argument and return new state.

Fractal, like most Cycle.js apps should be. Unlike Redux, there is no global entity in the onion state architecture, except for the usage of the onionify function itself, which is one line of code. The onion state architecture is similar to the Elm architecture in this regard, where any component is written in the same way without expecting any global entity to exist. As a result, you gain reusability: you can take any component and run it anywhere else because it's not tied to any global entity. You can reuse the component in another Cycle.js onionified app, or you can run the component in isolation for testing purposes without having to mock any external dependency for state management (such as a Flux Dispatcher).

How to use onionify

How to set up

npm install --save cycle-onionify

Import and call onionify on your main function (the top-most component in your app):

import onionify from 'cycle-onionify';

function main(sources) {
  // ...
  return sinks;
}

const wrappedMain = onionify(main);

Cycle.run(wrappedMain, drivers);

How to read state and update state

If you have onionified your main function, it can now expect to have sources.onion. This is not a simple stream, it is a "StateSource" object, which is necessary to support isolation. The most important thing to know about this object is that it has the state$ property. Then, your main function can return a stream of reducer functions under sinks.onion:

function main(sources) {
  // Stream of the state object changing over time
  const state$ = sources.onion.state$;

  // Use state$ somehow, for instance, to create vdom$ for the DOM.

  // Stream of reducer. Each emission is a function that describes how
  // state should change.
  const reducer$ = xs.periodic(1000)
    .mapTo(function reducer(prevState) {
      // return new state
    });

  const sinks = {
    onion: reducer$, // send these reducers back up
  }

  return sinks;
}

How to initialize state

State is initialized also with a reducer. This is different to Redux and Elm where the initial state is a separate entity. With onionify, just create an initReducer$ and send that to sinks.onion.

const initReducer$ = xs.of(function initReducer(prevState) {
  // Note that we ignore the prevState argument given,
  // since it's probably undefined anyway
  return {count: 0}; // this is the initial state
});

const reducer$ = xs.merge(initReducer$, someOtherReducer$);

const sinks = {
  onion: reducer$,
};

How to compose nested components

To use a child component in another component, where both use onionified state, you should use the isolate() helper function from @cycle/isolate. Suppose the shape of state in the parent component is:

{
  foo: string,
  bar: number,
  child: {
    count: number,
  },
}

The property child will host the state for the child component. The parent component needs to isolate the child component under the scope 'child', then the StateSource and isolate will know to pick that property from the parent state object when providing sources.onion to the child. Then, for any reducer emitted by the child's onion sink, isolate will wrap that child reducer in a parent reducer that works on the child property.

function Parent(sources) {
  const state$ = sources.onion.state$; // emits { foo, bar, child }
  const childSinks = isolate(Child, 'child')(sources);

  // ...

  // All these reducers operate on { foo, bar, child } state objects
  const parentReducer$ = xs.merge(initReducer$, someOtherReducer$);
  const childReducer$ = childSinks.onion; // even this one
  const reducer$ = xs.merge(parentReducer$, childReducer$);

  return {
    onion: reducer$,
    // ...
  }
}

Where the child component is:

function Child(sources) {
  const state$ = sources.onion.state$; // emits { count }

  // ...

  // These reducers operate on { count } state objects
  const reducer$ = xs.merge(initReducer$, someOtherReducer$);

  return {
    onion: reducer$,
    // ...
  }
}

When state source crosses the isolation boundary from parent into child, we "peel off" the state object using the isolation scope. Then, when crossing the isolation boundary from child back to the parent, we "wrap" the reducer function using the isolation scope. This layered structure justifies the "onion" name.

How to provide default state for a nested component

Sometimes the state for the child is given from the parent (what is usually described as "props"), but other times the parent does not pass any state for the child, and the child must initialize its own state.

To accomplish that, we can modify the initReducer$ of the child component, and turn it into a defaultReducer$:

const defaultReducer$ = xs.of(function defaultReducer(prevState) {
  if (typeof prevState === 'undefined') {
    return {count: 0}; // Parent didn't provide state for the child, so initialize it.
  } else {
    return prevState; // Let's just use the state given from the parent.
  }
});

It is a good idea to use a defaultReducer$ instead of an initialReducer$, as a rule of thumb.

How to handle a dynamic list of nested components

The state object tree can have nested state objects, but it can also have nested state arrays. This becomes useful when you are building a list of child components.

Suppose your parent component's state is an array:

function Parent(sources) {
  const array$ = sources.onion.state$; // emits [{ count: 0 }, { count: 1 }, ... ]

  // ...

  // This reducer will concat an object every second
  const reducer$ = xs.periodic(1000).map(i => function reducer(prevArray) {
    return prevArray.concat({count: i})
  });

  return {
    onion: reducer$,
    // ...
  }
}

Each object { count: i } in the array can become the state object for a child component. Onionify comes with a helper function called makeCollection which will utilize the array state stream to infer which children instances should be created, updated, or removed.

makeCollection takes a couple of options and returns a normal Cycle.js component (function from sources to sinks). You should specify the child component, a unique identifier for each array element (optional), an isolation scope (optional), and how to combine all children sinks together.

const List = makeCollection({
  item: Child,
  itemKey: (childState, index) => String(index), // or, e.g., childState.key
  itemScope: key => key, // use `key` string as the isolation scope
  collectSinks: instances => {
    return {
      onion: instances.pickMerge('onion'),
      // ...
    }
  }
})

In collectSinks, we are given an instances object, it is an object that represents all sinks for all children components, and has two helpers to handle them: pickMerge and pickCombine. These work like the xstream operators merge and combine, respectively, but operate on a dynamic (growing or shrinking) collection of children instances.

Suppose you want to get all reducers from all children and merge them together. You use pickMerge that first "picks" the onion sink from each child sink (this is similar to lodash get or pick), and then merges all those onion sinks together, so the output is a simple stream of reducers.

Then, you can merge the children reducers (listSinks.onion) with the parent reducers (if there are any), and return those from the parent:

function Parent(sources) {
  const array$ = sources.onion.state$;

  const List = makeCollection({
    item: Child,
    itemKey: (childState, index) => String(index),
    itemScope: key => key,
    collectSinks: instances => {
      return {
        onion: instances.pickMerge('onion'),
        // ...
      }
    }
  });

  const listSinks = List(sources);

  // ...

  const reducer$ = xs.merge(listSinks.onion, parentReducer$);

  return {
    onion: reducer$,
    // ...
  }
}

As pickMerge is similar to merge, pickCombine is similar to combine and is useful when combining all children DOM sinks together as one array:

const List = makeCollection({
  item: Child,
  itemKey: (childState, index) => String(index),
  itemScope: key => key,
  collectSinks: instances => {
    return {
      onion: instances.pickMerge('onion'),
      DOM: instances.pickCombine('DOM')
        .map(itemVNodes => ul(itemVNodes))
    }
  }
});

Depending on the type of sink, you may want to use the merge strategy or the combine strategy. Usually merge is used for reducers and combine for Virtual DOM streams. In the more general case, merge is for events and combine is for values-over-time ("signals").

To add a new child instance, the parent component just needs to concatenate the state array, like we did with this reducer in the parent:

const reducer$ = xs.periodic(1000).map(i => function reducer(prevArray) {
  return prevArray.concat({count: i})
});

To delete a child instance, the child component to be deleted can send a reducer which returns undefined. This will tell the onionify internals to remove that piece of state from the array, and ultimately delete the child instance and its sinks too.

function Child(sources) {
  // ...

  const deleteReducer$ = deleteAction$.mapTo(function deleteReducer(prevState) {
    return undefined;
  });

  const reducer$ = xs.merge(deleteReducer$, someOtherReducer$);

  return {
    onion: reducer$,
    // ...
  };
}

See the example code at examples/advanced for more details.

How to share data among components, or compute derived data

There are cases when you need more control over the way the state is passed from parent to child components. The standard mechanism of "peeling off" the state object is not flexible enough in situations such as:

  • a component needs access to the same state object as its parent
  • a component needs a combination of several pieces of the state object
  • you need to manipulate a piece of data before passing it to a component

In such cases you can use lenses. The idea of lenses is simple: they provide a view over a data structure, so that the user can see and modify the data through it.

The standard mechanism is already implementing a simple form of lens:

const fooSinks = isolate(Foo, 'foo')(sources);

By isolating the component with 'foo' we are focusing on that specific piece of the state object. The same thing can be achieved more explicitly as follows:

const fooLens = {
  get: state => state.foo,
  set: (state, childState) => ({...state, foo: childState})
};

const fooSinks = isolate(Foo, {onion: fooLens})(sources);

The fooLens is composed of a get function that extracts the .foo sub-state, and a set function that returns the updated state whenever the sub-state is modified by the child component. Lenses can be used as scopes in isolate thanks to flexible isolation.

A common use case for lenses is sharing data among components. The following lenses give components read/write access to the same status value:

// state in the parent: { foo: 3, bar: 8, status: 'ready' }

const fooLens = { //    { val: 3, status: 'ready' }
  get: state => ({val: state.foo, status: state.status}),
  set: (state, childState) => ({...state, foo: childState.val, status: childState.status})
};

const barLens = { //    { val: 8, status: 'ready' }
  get: state => ({val: state.bar, status: state.status}),
  set: (state, childState) => ({...state, bar: childState.val, status: childState.status})
};

const fooSinks = isolate(Child, {onion: fooLens})(sources);
const barSinks = isolate(Child, {onion: barLens})(sources);

Another use case is computing derived data, for example the average of an array of numbers:

// state in the parent: { xs: [23, 12, 25] }

const averageLens = {// { avg: 20 }
  get: state => ({avg: state.xs.reduce((a, b) => a + b, 0) / state.xs.length}),
  set: (state, childState) => state // ignore updates
}

How to choose a different key other than onion

If you want to choose what key to use in sources and sinks (the default is onion), pass it as the second argument to onionify:

function main(sources) {
  // sources.stuff is the StateSource

  return {
    stuff: reducer$, // stream of reducer functions
  };
}

const wrappedMain = onionify(main, 'stuff');

Cycle.run(wrappedMain, drivers);

How to use it with TypeScript

We recommend that you export the type State for every component. Below is an example of what this usually looks like:

export interface State {
  count: number;
  age: number;
  title: string;
}

export interface Sources {
  DOM: DOMSource;
  onion: StateSource<State>;
}

export interface Sinks {
  DOM: Stream<VNode>;
  onion: Stream<Reducer>;
}

function MyComponent(sources: Sources): Sinks {
  // ...
}

The StateSource type comes from onionify and you can import it as such:

import {StateSource} from 'cycle-onionify';

Then, you can compose nested state types in the parent component file:

import {State as ChildState} from './Child';

export interface State {
  list: Array<ChildState>;
}

See some example code at examples/advanced for more details.

FAQ

Are there any caveats?

There are some caveats. Removing the distinction state vs props means that these two concepts are conflated, and both parent and child have direct access to modify the state for the child. This may lead to cases where you read the source code for the child component, but cannot be sure how does that state behave over time, since the parent may be modifying it dynamically and concurrently. Even though we use immutability, this is a type of shared-memory concurrency. It also loses some reactive programming properties, since according to reactive programming, the entire behavior of state$ should be declared in one line of code.

The state vs props distinction makes the parent-child boundary explicit, making it possible for the child component to regulate which props from the parent does it allow or reject. With onionify, the child cannot protect its state from being tampered by the parent in possibly undesireable ways.

That said, state vs props management is too hard to master with Cycle.js (and also in vanilla React). Most of the times state vs props explicit distinction is unnecessary. This caveat in Onionify also happens with Elm nested update functions and Om through cursors. In practice, the developer should just be careful to avoid invasive child state updates from the parent. Try to contain all child state updates in the child component, while occasionally allowing the parent to update it too.

Does it support RxJS or most.js?

Yes, as long as you are using Cycle.js and the RxJS Run package (or Most.js Run).

Does it support Immutable.js?

No, not yet. It only supports JavaScript objects and arrays. However, supporting Immutable.js is very important to us and we want to do it, either directly supporting it in this library, or building another onionify library just for Immutable.js. Let's see.

How does this fit with Model-View-Intent (MVI)?

Very well. Model would hold the definitions for the reducer streams, and return that as one reducer$ stream. Model would not return state$, as it would traditionally. Overall, onionify works well with the MVI pattern:

  • Intent: maps DOM source to an action stream.
  • Model: maps action streams to a reducer stream.
  • View: maps state stream to virtual DOM stream.

Example:

function main(sources) {
  const state$ = sources.onion.state$;
  const action$ = intent(sources.DOM);
  const reducer$ = model(action$);
  const vdom$ = view(state$);

  const sinks = {
    DOM: vdom$,
    onion: reducer$,
  };
  return sinks;
}

Why is this not official in Cycle.js?

If all goes well, eventually this will be an official Cycle.js practice. For now, we want to experiment in the open, collect feedback, and make sure that this is a solid pattern. There are other approaches to state management in Cycle.js and we want to make sure the most popular one ends up being the official one.

How does this compare to Redux?

  • Redux is not fractal (and has a visible global entity, the Store). Onionify is fractal (and has an invisible global entity).
  • Redux defines initial state in the argument for a reducer. Onionify defines initial state as a reducer itself.
  • Redux reducers have two arguments (previousState, action) => newState. Onionify reducers have one argument (previousState) => newState (the action is given from the closure).

How does this compare to Stanga?

  • Stanga is a "driver". Onionify is a component wrapper function.
  • Stanga defines initial state as a separate argument. Onionify defines initial state as a reducer.
  • Stanga uses helper functions and lenses for sub-states. Onionify leverages @cycle/isolate.

How does this compare to the Elm architecture?

  • Both are fractal.
  • Elm reducers have two arguments Msg -> Model -> ( Model, Cmd Msg ). Onionify reducers have one argument (previousState) => newState.
  • Elm child reducers are explicitly composed and nested in parent reducers. Onionify child reducers are isolated with @cycle/isolate and merged with parent reducers.
  • Elm child actions are nested in parent actions. In onionify, actions in child components are unrelated to parent actions.

How does this compare to ClojureScript Om?

  • Om Cursors are very similar in purpose to onionify + isolate.
  • Om cursors are updated with imperative transact!. Onionify state is updated with declarative reducer functions.

cycle-onionify's People

Contributors

abaco avatar bloodyknuckles avatar cluelessjoe avatar davidskuza avatar goodmind avatar jvanbruegge avatar ntilwalli avatar staltz avatar stevealee avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

cycle-onionify's Issues

onionify typings assume "onion" as key

onionify expects you to use the onion key from its typings, even though you can set it yourself. Not sure if it is work to try to type the transformation without proper row types.

Emitting undefined automatically when a component's onion stream is unsubscribed

I understand that currently the only way to remove an unused child component's property from the onion state is to have the parent set it's state value to undefined. This seems not convenient or ideal. Ideally a child component can be configured to automatically/implicitly emit an UndefinedReducer from it's onion stream (state => undefined) when it's onion stream is unsubscribed. Is that possible?

List: repetative recreating of child components may be a problem

background: cyclejs/todomvc-cycle@065c304#commitcomment-19533736

const taskSinks$ = array$.map(array =>
    array.map((item, i) => isolate(Task, i)(sources))

here, the Task component function is called again and again for each item whenever another item is added, removed or updated. So instead of usual workflow, where component or main function is used only for initial setup and then the stream library handles the changes, we run this setup over and over again. In fact, sources.onion.state$ inside the child component never emits twice, so it's no better then just plain value.

Andrรฉ suggested, that memoization could help here, and it definitely will, but it doesn't look possible without introducing ids

Add mock-onionify

It would be helpful, if you could inject an initial state into onionify.
So basicly:

onionify(main, { count: 40 })(sources);

This would make testing with default reducers using prevState easier

RangeError: Maximum call stack size exceeded

Hi guys,

I am diving into onionify but I am getting "RangeError: Maximum call stack size exceeded".
What am I doing wrong?

Webpackbin not working atm so pasting code here.

My index.js:

import { run }                                  from '@cycle/xstream-run';
import { button, div,
         makeDOMDriver, pre }                   from '@cycle/dom';
import xs                                       from 'xstream';
import isolate                                  from '@cycle/isolate';
import Foo                                      from './foo';
import onionify, {pick, mix}                    from 'cycle-onionify';


function intent(sources) {

    return xs.merge(
        sources.DOM.select('.decrement').events('click').map(ev => -1),
        sources.DOM.select('.increment').events('click').map(ev => +1)
    );

}


function model(action$) {

    const fnInit$   = xs.of(() => []);
    const fnAdd$    = action$.filter(n => n > 0).map(n => state => state.concat({ text : 'abc'}));
    return xs.merge(fnInit$, fnAdd$);

}

function view(state$) {

    return state$.map(state =>
        div([
            button('.decrement', 'Remove'),
            button('.increment', 'Add'),
            pre(JSON.stringify(state, null, 4))
        ])
    );

}


function main(sources) {

    const array$            = sources.onion.state$;

    const foos$             = array$.map(array =>
                                array.map((item, index) => isolate(Foo, index)(sources))
                            );

    const fooReducers$      = foos$.compose(pick('onion'));
    const fooReducer$       = fooReducers$.compose(mix(xs.merge));

    const action$           = intent(sources);
    const parentReducer$    = model(action$);
    const vdom$             = view(array$);

    const reducer$          = xs.merge(parentReducer$, fooReducer$);

    return {
        DOM: vdom$,
        onion: reducer$,
    };
}


const wrappedMain   = onionify(main);

run(wrappedMain, {
    DOM: makeDOMDriver('#app')
});

My foo.js:

import { div, input }                       from '@cycle/dom';
import xs                                   from 'xstream';
import { spy }                              from '../../src/ent/vdom/utils';



function intent(sources) {

    return sources.DOM
        .select('.text')
        .events('input')
        .map(ev => ev.target.value);

}

function model(action$) {

    const fnInit$   = xs.of(state => state ? state : ({ text : 'bar' }));
    const fnUpdate$ = action$.map(text => state => ({ text }));

    return xs.merge(fnInit$, fnUpdate$)

}

function view(state$) {

    const fnVtree = state =>
        div([
            input('.text'),
            div(state.text),
            spy(state)
        ]);

    return state$.map(fnVtree);


}

function main(sources) {

    const state$        = sources.onion.state$;
    const action$       = intent(sources);
    const reducer$      = model(action$);
    const vtree$        = view(state$);


    return {
        DOM : vtree$,
        onion : reducer$
    }
}

export default main;

README doesn't mention state persistence

What are the good patterns for persistence when using onionify

I guess we can hydrate in an initial/default reducer, either in in the top level component or individual components according to your preferences?

How about persisting though?

Or do you think there's no point being prescriptive? Even if this is your view examples would be helpful.

BTW I thought this was a very interesting series on state - https://getpocket.com/a/read/1657730920

order of drivers has impact

In this code example, the log driver only receives the second 'b' event:

import {run} from '@cycle/run'
import {makeDOMDriver} from '@cycle/dom'
import onionify from 'cycle-onionify';
import {div} from '@cycle/dom'
import xs from 'xstream'

function App(sources) {
	const state$ = sources.onion.state$;

	const init_reducer$ = xs.of(s => 'a');

	const change_a_to_b_reducer$ = state$
		.filter(s => s === 'a')
		.map(() => s => 'b');

	return {
		DOM: state$.map(s => div(s)),
		onion: xs.merge(init_reducer$, change_a_to_b_reducer$),
		log: state$,
	}
}


const main = App;

const wrappedMain = onionify(main);

const drivers = {
	DOM: makeDOMDriver('#app'),
	log: msg$ => {
		msg$.addListener({next: i => console.log('passthrough', i)})
	},
};

run(wrappedMain, drivers);

But when I change the order of the drivers, both 'a' and 'b' event are logged:

	return {
		log: state$,
		DOM: state$.map(s => div(s)),
		onion: xs.merge(init_reducer$, change_a_to_b_reducer$),
	}

So apparently the order of the drivers has impact. I have the same problem when using the HTTP driver.
Thank you for your help!

How to share state between sibling components

Say I have an online store, and I want to make a price range selector with a double slider and two inputs for setting exact range limits, like so:

0.....|_______|......1000

 ---------      ----------
| 250     |    |   700    |
 ---------      ----------

So the container RangePicker component may have three children: Slider, From, and To. The state of RangePicker is defined by two integers: {from: 250, to: 700}. It's easy to pass them to the inputs: just isolate by from and to correspondingly. But how do I access those values from Slider component, if I still want to isolate its DOM sink?

Immutable.js version

Wouldn't it be relatively simple to make an immutable.js version? I am using immutable.js for anything state-related so a cycle-onionify-immutable would be fantastic! ๐Ÿ˜ƒ

pickMerge seems to swallow events

Not entirely sure why, but I have a reproducing case:
Live demo at webpackbin

Expected behavior:
App logs events on button click

Actual behavior:
Nothing happens

import xs from 'xstream';
import { run } from '@cycle/run';
import { div, button, makeDOMDriver } from '@cycle/dom';
import onionify, { makeCollection } from 'cycle-onionify';

function Item(sources) {
  //works
  /*sources.DOM.select('.tab').events('click')
    .addListener({ next: console.log });*/
  
  return {
    DOM: xs.of(div([button('.tab', {}, ['Test'])])),
    log: sources.DOM.select('.tab').events('click')
  }
}

const List = makeCollection({
  item: Item,
  itemKey: x => x.id, // does not matter
  itemScope: x => '._' + x, // does not matter
  collectSinks: instances => ({
    DOM: instances.pickCombine('DOM').map(div),
    log: instances.pickMerge('log')
  })
});

function Parent(sources) {
  const sinks = List(sources);
  
  return {
    ...sinks,
    onion: xs.of(() => [{ id: 0 }, { id: 1 }, { id: 2 }])
  };
}

run(onionify(Parent), {
  DOM: makeDOMDriver('#app'),
  log: sink$ => {
    sink$.addListener({
      next: console.log
    })
  }
});

type MakeScopesFn does not exist but imported

import {Scope, Reducer, MakeScopesFn} from './lib/types';

MakeScopesFn does not exist in ./lib/types but imported by rxjs-typings.d.ts and most-typings.d.ts

im guessing it was because of the collection/onionify merge and the typing for rxjs and most weren't updated.

would love to help but my typescript knowledge is still a little behind.

Vocabulary: action vs intent

Hi

Reading the README and others examples I was surprised at the use of "Action" when describing the intents.

Indeed, it introduces a new term whereas we speak of MVI, aka Model View Intent, where the term action is completely out of the picture.

Furthermore, what's called actions here are some intents: it's not because the user "asks for it" that it'll happen. Some business logic might contradict the user's will here. For example the chosen login might be already given to someone else.

So, why not call these actions intents?

The model would then get a stream of Intent, which is way more in sync with MVI, and all would be simpler.

I suspect I'm missing something but I don't see what so...

++

pickCombine fails when re-adding item with same key

Code to reproduce the issue:

https://www.webpackbin.com/bins/-Kn5YFWZpUDpC81ugNXC

import xs, {Stream} from 'xstream';
import {run} from '@cycle/run';
import {makeDOMDriver, DOMSource, VNode, div} from '@cycle/dom';
import onionify, {StateSource} from 'cycle-onionify';
import isolate from '@cycle/isolate';
import sampleCombine from 'xstream/extra/sampleCombine';

type State = {
  id: string,
  val: number,
  children: Array<State>,
}

type Reducer = (prevState: State) => State;

type Sources = {
  DOM: DOMSource,
  onion: StateSource<State>,
}

type Sinks = {
  DOM: Stream<VNode>;
  onion: Stream<Reducer>;
}

type ChildrenSources = {
  DOM: DOMSource,
  onion: StateSource<Array<State>>,
}

function Item(sources: Sources): Sinks {
  const reducers = [
    (prevState: State): State => ({id: prevState.id, val: prevState.val, children: [{id: 'b', val: 5, children: []}]}),
    (prevState: State): State => ({id: prevState.id, val: prevState.val, children: []}),
    (prevState: State): State => ({id: prevState.id, val: prevState.val, children: [{id: 'b', val: 5, children: []}]}),
  ];

  const updateReducer$ = sources.onion.state$
    .filter(state => state.id === 'a')
    .mapTo(xs.periodic(1000).take(3))
    .flatten()
    .map(n => reducers[n]);

  const childrenSinks = isolate((srcs: ChildrenSources): Sinks => {
    const children = srcs.onion.toCollection(Item)
      .uniqueBy((s: State) => s.id)
      .build(srcs);
    return {
      DOM: children.pickCombine('DOM').map(div),
      onion: children.pickMerge('onion'),
    };
  }, 'children')(sources);

  const vdom$ = sources.onion.state$.compose(sampleCombine(childrenSinks.DOM))
    .map(([state, childrenVdom]) =>
      div([String(state.val), childrenVdom])
    );

  const reducer$ = xs.merge(updateReducer$, childrenSinks.onion as Stream<Reducer>);
  
  return {
    DOM: vdom$,
    onion: reducer$,
  };
}

function App(sources: Sources): Sinks {
  const itemSinks = Item(sources);

  const vdom$ = itemSinks.DOM;

  const initReducer$ = xs.of((): State => ({id: 'a', val: 1, children: [] }))

  const reducer$ = xs.merge(initReducer$, itemSinks.onion);

  return {
    DOM: vdom$,
    onion: reducer$,
  }
}

const main = onionify(App);

run(main, {
  DOM: makeDOMDriver('#main-container')
});

Expected behavior:

The child item {id: 'b', val: 5} is added, removed, and added again to the parent item {id: 'a', val: 1}.

Actual behavior:

The app crashes upon re-adding the item {id: 'b', val: 5}. To reproduce this bug it seems necessary that:

  • an item with the same key is added, removed, and added again

  • there is a nesting of items

Versions of packages used:

@cycle/dom 17.4.0
@cycle/isolate 3.0.0
@cycle/run 3.1.0
cycle-onionify 4.0.0-rc.10
xstream 10.8.0

Possible to not emit until default reducer gets run?

It would be nice if state$ emissions for a component get filtered until the default reducer for that component gets run... Is that possible?

Below is an explanation of one of the reasons that would be useful...

In general, one of the things I've noticed with onionify and RxJS (and I assume this applies to xstream as well) is the importance of onion stream merge ordering. For example if I have a component Foo which has children A and B, then the full set of reducers coming from Foo is the merge of local reducers from Foo and the reducers coming from children A and B. If I write that as:

return {
  ...
  onion: O.merge(A.onion, B.onion, local_reducer$)
}

Then the "merged" reducers stream, (which conceptually has no ordering), will get subscribed in the order given. So A.onion will get subscribed first, then B.onion, then local_reducer$. This means that very likely the local default_reducer will not be run before the childrens' default reducer streams are processed which temporarily causes odd state$ emissions for Foo (...and to it's children if the parent state information gets lensed down). This invalid state persists until all the reducers from the merge have been subscribed and the state$ catches up, i.e. all the default_reducer streams associated with the merge have been processed.

In general being able to assume that the default reducer for a component has been run before the state$ for a component emits is a convenient property since it means I don't need to write defensive code within the parts of my component which read the state$. Of course it is possible to add a filtering flag to the state indicating onion_valid: true, but that's hacky and ugly.

It should be noted, and strongly, that this issue can be avoided by simply placing local_reducer$ as the first entry in the merge. So O.merge(local_reducer$, A.onion, B.onion) does not have this same issue since the parent's default reducer will be subscribed before the default reducers from the children. Easy enough, but it's not intuitive...

The non-intuitiveness is why I'm raising this as an issue.

Onionify not working with RxJS

Currently the StateSource assumes this.state$ has a compose method, but that is not the case when adapt gets called with a non-xstream library.

https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L111

this.state$ = adapt(stream.compose(dropRepeats()).remember());

The above line returns a stream in the app's chosen stream lib, so when select gets called on the StreamSource, this line fails since this.state$ is not an xstream stream.

https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L121

    return new StateSource<R>(
      this.state$.map(get).filter(s => typeof s !== 'undefined'),  
      null,
    );

I could create a PR with this change which I think will fix the problem...

export class StateSource<T> {
  public state$: MemoryStream<T>;
  private _name: string | null;

  constructor(stream: Stream<any>, name: string | null) {
    this._name = name;
    this._state$ = stream.compose(dropRepeats()).remember()
    this.state$ = adapt(this._state$);
    if (!name) {
      return;
    }
    (this._state$ as MemoryStream<T> & DevToolEnabledSource)._isCycleSource = name;
  }

  public select<R>(scope: Scope<T, R>): StateSource<R> {
    const get = makeGetter(scope);
    return new StateSource<R>(
      this._state$.map(get).filter(s => typeof s !== 'undefined'),
      null,
    );
  }

  public isolateSource = isolateSource;
  public isolateSink = isolateSink;
}

Proposal for dealing with shared/derived data

--- UPDATE: SEE COMMENTS BELOW FOR A BETTER PROPOSAL ---

I've created a branch to experiment with lensed (or, more precisely, optic'd) state. It uses partial.lenses. The idea is to retain the scope-based isolation of the state object, while allowing to associate an optic (lens, traversal, or isomorphism) to a scope name, so that children components can see the state through said optics.

For example one can have the following state:

const reducer$ = xs.of({kelvin: 283});

and compute derived values through:

const optics$ = xs.of({
  // this component sees the state as it is
  self: L.identity,
  children: {
    kelvin: {
      // a child isolated with 'kelvin' sees 283 as state
      self: L.prop('kelvin')
    },
    celsius: {
      // a child isolated with 'celsius' sees 9.85 as state
      self: L.compose(
        L.prop('kelvin'),
        L.iso(a => a - 273.15, b => b + 273.15)
      )
    },
    fahrenheit: {
      // a child isolated with 'fahrenheit' sees 49.73 as state
      self: L.compose(
        L.prop('kelvin'),
        L.iso(a => a * 9/5 - 459.67, b => (b + 459.67) * 5/9)}
      )
    }
  }
});

The component must then return {..., onion: {optics$, reducer$}} instead of the usual {..., onion: reducer$} (which however is still supported). The children components access the state through the optics (in this case isomorphisms) in a transparent way: they don't need to know about partial.lenses and they can update the value.

I've added two examples, "mixed-optics" and "mixed-optics-nested", which use slightly different approaches. The differences with the original "mixed" example are minimal.

This code is just a proof of concept. It's probably buggy and I haven't thought of performance at all. I'd just like to know what the community thinks of such an approach.

I think the pros of this solution are:

  • Better separation of concerns and reusability of components. In the new "mixed" examples, each list item sees the counter value as if it owned it.
  • Backwards compatibility.
  • Power. This is the first time I use partial.lenses, and so far I'm really impressed. It seems extremely powerful.

Rename lens getter/setter

Don't understand this as an action I will do soon, I want to just collect feedback about an idea I just had:

https://twitter.com/andrestaltz/status/885185030405992452

I'd really like to rename getter / setter to zoomIn / zoomOut to avoid confusion with the OO getter () => T and setter T => void

I know () => T is essentially effectful and not present in FP, but most programmers learning FP will read "getter" and think () => T.

The actual change in onionify would be

 export type Lens<P, C> = {
-  get: Getter<P, C>;
-  set: Setter<P, C>;
+  zoomIn: ZoomIn<P, C>;
+  zoomOut: ZoomOut<P, C>;
 };

and would be a breaking change.

Isolation scope name problems

Problem 1

How to be when scope named as state property is not enough:

// this needs 'child' from onion state
isolate(Child, 'child')({onion, HTTP)

// and this needs 'child' from onion state, 
// but HTTP source needs different scope not to mess with `Child`
isolate(AnotherChild, 'child')({onion, HTTP) 

Problem 2

What if one need to pass more nested property to child like "some.nested.child"

// with mapping I would pass it like:
onion.state$.map(state => state.some.nested.child)

pickMerge throws error if child is not using sink

If you have a child that is e.g. not using HTTP, and you try

collectSinks: instances => ({
    HTTP: instances.pickMerge('HTTP')
})

onionify makeCollection just throws an error. It would make sense to just return xs.never()

Define Omit<T, K> properly?

Currently Omit<T, K> type is defined as a mere alias of any, which results in the situation where the core onionify function is quite loosely typed.

export type Omit<T, K extends keyof T> = any;

As you know, however, we already have conditional types in TypeScript 2.8+, so we can define the type properly with Pick and Exclude.

It also has some drawbacks:

  • The change requires the dependency of TypeScript 2.8+
    • currently depending on 2.5.x
  • It will possibly be a breaking change toward codebases with improperly typed onionified components
    • thus it'll require a major version bump

Conventional place for non-onion reduced state?

From reading many words around cycle-onionify, I have a sense it is possibly on a path to become the "in the box" state mechanism for Cycle. Hence this question/suggestion is here rather than in the main cycle repo. Feel free to send me elsewhere if this is a poor assumption. :-)

cycle-onionify provides a convenient and conventional place to put component-scoped state, accessible as needed from parent components yet isolated by default. There is a mechanism coming along here shortly from @staltz to handle collections efficiently and conveniently also. There is a mechanism to fit "global" application-wide state, in onionify - with tedious layers of lenses.

My question and suggestion therefore, is that one of these pieces (Cycle core, or onionify, or another library?) provide a conventional mechanism for non-onion state. For application wide-state. Perhaps this could be a sister project to onionify; or perhaps onionify could include the functionality. I think what I'm asking for is about 10% of what onionify already does, mostly the notion of providing a conventionally named source and sink throughout, with reducers and fold(). Is easy to re-create this on a per-project basis, but easier to explain to other people if it is a convention in the box.

(Of course one could use https://github.com/cyclejs-community/redux-cycles for application wide state; I'm hoping for something for applications where the action layer of Redux is unnecessary complexity. redux-cycles has claimed the source key STATE, maybe something different for non-redux application-wide state?)

Get current state inside model and make a http request

Hey I'm new to reactive programming and I want to learn it with a cyclejs project. Currently i'm testing onionify. I want to send a http request inside a child component, but I dont get to manage to get the current state inside the model.

My model look like this:

export function model(http, intent, prevState) {

    const default$: Stream<Reducer> = xs.of(function defaultReducer(): any {
        if (typeof prevState === 'undefined') {
            return Object.assign({}, {
                title: '',
                description: '',
                tags: '',
            });
        } else {
            return prevState;
        }
    });

    const titleChange$: Stream<Reducer> = intent.inputTitle$
        .map(ev => (ev.target as any).value)
        .map(title => function titleReducer(prevState){
            return Object.assign({}, prevState, {
                title: title
            });
        });
... 

I tried something like this to send a http request:

const submitReducer$: Stream<Reducer> = intent.submit$
        .map(ev => "")
        .map(submit => function submitReducer(prevState){
            return Object.assign({}, prevState, {});
        })
const submitForm$ = submitReducer$
// At this map the state is the submitReducer()  function.
--> .map(state => ({
            url: 'http://localhost:8080/api/notecard',
            method: 'POST',
            category: 'post-notecard',
            send: {
                'title': state.title,
                'description': state.description,
                'tags': state.tags,
            }
        }));

At this map the state is the submitReducer() function. But I would like to have the current state at this point to make a POST request

const reducer$ = xs.merge(
        default$,
        titleChange$,
        descChange$,
        tagsChange$,
        visibilityChange$,
        submitReducer$
    );
...

const sinks = {
        HTTP: submitForm$
        onion: reducer$
 };

Onion state resets when there are no current state subscribers

cycle-onionify (with RxJS) unsubscribes from the sink when there are no subscribers to the state$ and then restarts when a component in the tree uses onionify again. This restarts the state$. Shouldn't the onion state never restart, or is this the proper behavior?

Shouldn't state emissions be microtask queued?

On multiple occasions I've triggered a stack overflow when a state emission causes multiple reducers to be emitted on the same turn of the event loop, which can cascade. In the example I just dealt with an HTTP response (with 125 results) causes a reducer emission to store the array of results in the state, which synchronously causes a collection to be created with 125 components each of which has a few startup reducers... The stack blew up on Chrome because there was some circular state issues... The exact details I haven't tracked down, but when I delayed the initial reducers from the each collection component, the issue went away. Similarly when I hacked in a delay into onionify.js the issue goes away... like so...

sources[name] = new StateSource_1.StateSource(state$.compose(delay_1.default(1)), name);

Shouldn't state updates be async?

Add ES6 module build

onionify acutally causes a scope hoisting bailout for @cycle/isolate that would be fixed with a ES6 build

cannot compile typescript examples

steps to reproduce:

  • git clone https://github.com/staltz/cycle-onionify.git
  • npm i
  • npm run lib
  • cd examples/lenses/
  • npm start

node 8.1.4
npm 6.4.1

alex@alexs-mbp : ~/dev/learn/cyclejs
[0] % git clone https://github.com/staltz/cycle-onionify.git
Cloning into 'cycle-onionify'...
[...]

alex@alexs-mbp : ~/dev/learn/cyclejs
[0] % cd cycle-onionify

alex@alexs-mbp โ€น master โ€บ : ~/dev/learn/cyclejs/cycle-onionify
[0] % npm i

> [email protected] install /Users/alex/dev/learn/cyclejs/cycle-onionify/node_modules/fsevents
> node install

[fsevents] Success: "/Users/alex/dev/learn/cyclejs/cycle-onionify/node_modules/fsevents/lib/binding/Release/node-v57-darwin-x64/fse.node" already installed
Pass --update-binary to reinstall or --build-from-source to recompile
npm notice created a lockfile as package-lock.json. You should commit this file.
added 580 packages from 203 contributors and audited 2610 packages in 20.678s
found 0 vulnerabilities


alex@alexs-mbp โ€น master โ— โ€บ : ~/dev/learn/cyclejs/cycle-onionify
[0] % npm run lib

> [email protected] prelib /Users/alex/dev/learn/cyclejs/cycle-onionify
> mkdir -p lib


> [email protected] lib /Users/alex/dev/learn/cyclejs/cycle-onionify
> tsc


alex@alexs-mbp โ€น master โ— โ€บ : ~/dev/learn/cyclejs/cycle-onionify
[0] % cd examples/lenses/

alex@alexs-mbp โ€น master โ— โ€บ : ~/dev/learn/cyclejs/cycle-onionify/examples/lenses
[0] % l
total 24
-rw-r--r--  1 alex  staff  366 Oct  6 12:17 index.html
-rw-r--r--  1 alex  staff  695 Oct  6 12:17 package.json
drwxr-xr-x  6 alex  staff  204 Oct  6 12:17 src
-rw-r--r--  1 alex  staff  385 Oct  6 12:17 tsconfig.json

alex@alexs-mbp โ€น master โ— โ€บ : ~/dev/learn/cyclejs/cycle-onionify/examples/lenses
[0] % npm start

> [email protected] start /Users/alex/dev/learn/cyclejs/cycle-onionify/examples/lenses
> npm install && npm run browserify && echo 'OPEN index.html IN YOUR BROWSER'

npm notice created a lockfile as package-lock.json. You should commit this file.
added 160 packages from 194 contributors and audited 1040 packages in 15.377s
found 0 vulnerabilities


> [email protected] prebrowserify /Users/alex/dev/learn/cyclejs/cycle-onionify/examples/lenses
> mkdirp dist && tsc

src/Edit.ts(42,3): error TS2322: Type '{ DOM: MemoryStream<VNode>; onion: Stream<Reducer>; }' is not assignable to type 'Sinks'.
  Types of property 'DOM' are incompatible.
    Type 'MemoryStream<VNode>' is not assignable to type 'Stream<VNode>'.
      Property '_ils' is protected but type 'Stream<T>' is not a class derived from 'Stream<T>'.
src/Item.ts(38,3): error TS2322: Type '{ DOM: MemoryStream<VNode>; onion: Stream<(prevState: State) => State>; }' is not assignable to type 'Sinks'.
  Types of property 'DOM' are incompatible.
    Type 'MemoryStream<VNode>' is not assignable to type 'Stream<VNode>'.
src/main.ts(7,23): error TS2345: Argument of type '(sources: Sources) => Sinks' is not assignable to parameter of type 'MainFn<Sources, OSi<{}>>'.
  Type 'Sinks' is not assignable to type 'OSi<{}>'.
    Types of property 'onion' are incompatible.
      Type 'Stream<Reducer>' is not assignable to type 'Stream<Reducer<{}>>'.
        Property '_ils' is protected but type 'Stream<T>' is not a class derived from 'Stream<T>'.

npm ERR! code ELIFECYCLE
[...]

Help needed - MemoryStream.map not producing output

Hey,

I hope this is the right place to ask for help. I'm currently trying to get started with CycleJS and cycle-onionify and have troubles getting my app to produce the right output. Currently it is a simple input field, on every enter a message object is created and should then be rendered in a list. With starting to split that into multiple, smaller components, I see no output of my message list anymore (only my input field). To be more specific: I can log the list of messages (which contain the correct amount of messages) and in my Message-component I also get a log output for the sources, so the code is at least touched (means: 2 messages in the list, 2 logs in my console; 3 when I add one more message and so on).

My Message-component looks like the following:

import xs from 'xstream';
import { div, p } from '@cycle/dom';
import isolate from '@cycle/isolate';

const calculateTimeString = timestamp => {
  const date = timestamp && new Date(timestamp);
  return `${date.toLocaleDateString()}: ${date.toLocaleTimeString()}`;
};

function Message(sources) {
  const state$ = sources.onion.state$;
  console.log(state$); // --> logs a MemoryStream

  const vdom$ = state$.map(state => {
    // HELPME this does not produce any output
    console.log('Message', state);
    return div('.messageItem', [
      p(calculateTimeString(state.time)),
      p(state.message),
    ]);
  });
  return {
    DOM: vdom$,
    onion: xs.empty(),
  };
}

// (sources) => ...
export default isolate(Message);

and is integrated into the following MessageList:

import { ul } from '@cycle/dom';
import isolate from '@cycle/isolate';
import { pick, mix } from 'cycle-onionify';
import xs from 'xstream';

import Message from './../Message';

function MessageList(sources) {
  const state$ = sources.onion.state$;

  const childrenSinks$ = state$.map(messages => {
    // --> with every new message, this logs me the array of messages [{...}, {...}, ...]
    console.log('MessageList', messages);
    return messages.map((msg, i) => {
      return isolate(Message, i)(sources);
    });
  });
  const vdom$ = childrenSinks$
    .compose(pick(sinks => sinks.DOM))
    .compose(mix(xs.combine))
    .map(ul);

  const reducer$ = childrenSinks$
    .compose(pick(sinks => sinks.onion))
    .compose(mix(xs.merge));

  return {
    DOM: vdom$,
    onion: reducer$,
  };
}

export default MessageList;

For the sake of completeness, everything is integrated into this main app:

import xs from 'xstream';
import { div, p, input } from '@cycle/dom';
import isolate from '@cycle/isolate';
import { prepend } from 'ramda';

// FIXME how to strcutre components? why not automatically importing index?
import MessageList from './components/MessageList/index';

function intent(domSource) {
  return domSource
    .select('.message')
    .events('keydown')
    .filter(({ keyCode, target }) => keyCode === 13 && target.value !== '')
    .map(ev => {
      const val = ev.target.value;
      // eslint-disable-next-line no-param-reassign
      ev.target.value = '';
      return {
        time: Date.now(),
        message: val,
      };
    });
}

function model(action$) {
  const initReducer$ = xs.of(() => ({ messages: [] }));
  const updateReducer$ = action$.map(message => prevState => ({
    messages: prepend(message, prevState.messages),
  }));
  return xs.merge(initReducer$, updateReducer$);
}

export default function App(sources) {
  const messageListSinks = isolate(MessageList, 'messages')(sources);

  const action$ = intent(sources.DOM);
  const parentReducer$ = model(action$);
  const messageListReducer$ = messageListSinks.onion;
  const reducer$ = xs.merge(parentReducer$, messageListReducer$);

  const vtree$ = messageListSinks.DOM.map(listNode =>
    div([
      p('Eingabe einer neuen Nachricht:'),
      input('.message', { attrs: { type: 'text', autofocus: true } }),
      listNode,
    ])
  );

  return {
    DOM: vtree$,
    onion: reducer$,
  };
}

Am I missing something? Am I doing something wrong? I tried to stick to the example here in the repo, but cannot find my error.

Hope one of you can help me out, in case you need more infos or the whole source-code, please let me know!

"Reducer" term is not correct

"Reducer" is, by definition, a binary function of the form (a, b) => a (left fold) or (a, b) => b (right fold). In this project "reducer" term is used for state => state2 like transformations, so I believe it's misleading.

I suppose the name was chosen as "familiar" after Redux, but I think that being technically precise is more important. The real reducer is this guy:

(state, fn) => fn(state)

The mapper term would be closer, btw.

State update to a falsy value is ignored in isolated component

It seems legal to have as state a value that is not an object, for example an integer, a string or a boolean. Hovever, when such a value is falsy it gets filtered out (https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L79).

Non-object state values should be either forbidden or fully supported (including falsy values).

Code to reproduce the issue:

import xs from 'xstream';
import Cycle from '@cycle/xstream-run';
import {div, button, p, makeDOMDriver} from '@cycle/dom';
import onionify from '../../../lib/index';
import isolate from '@cycle/isolate'

function Counter(sources) {
  const action$ = xs.merge(
    sources.DOM.select('.decrement').events('click').map(ev => -1),
    sources.DOM.select('.increment').events('click').map(ev => +1)
  );

  const state$ = sources.onion.state$;

  const vdom$ = state$.map(state =>
    div([
      button('.decrement', 'Decrement'),
      button('.increment', 'Increment'),
      p('Counter: ' + state)
    ])
  );

  const updateReducer$ = action$.map(num => function updateReducer(prevState) {
    return prevState + num;
  });

  return {
    DOM: vdom$,
    onion: updateReducer$,
  };
}

function main(sources) {
  const counterSinks = isolate(Counter, 'count')(sources);

  const vdom$ = counterSinks.DOM;

  const initReducer$ = xs.of(function initReducer() {
    return {count: 1};
  });
  const updateReducer$ = counterSinks.onion;
  const reducer$ = xs.merge(initReducer$, updateReducer$);

  return {
    DOM: vdom$,
    onion: reducer$,
  };
}

const wrappedMain = onionify(main);

Cycle.run(wrappedMain, {
  DOM: makeDOMDriver('#main-container')
});

Expected behavior:
Clicking "Decrement" once should change the counter value to 0.

Actual behavior:
The counter value is in internally updated, but the isolated component won't see the update. Thus the counter can be set to any value except 0.

Versions of packages used:
cycle-onionify: 2.3.0
@cycle/base: 4.1
@cycle/isolate: 1.4

Note that the following (without isolation) works fine:

import xs from 'xstream';
import Cycle from '@cycle/xstream-run';
import {div, button, p, makeDOMDriver} from '@cycle/dom';
import onionify from '../../../lib/index';

function main(sources) {
  const action$ = xs.merge(
    sources.DOM.select('.decrement').events('click').map(ev => -1),
    sources.DOM.select('.increment').events('click').map(ev => +1)
  );

  const state$ = sources.onion.state$;

  const vdom$ = state$.map(state =>
    div([
      button('.decrement', 'Decrement'),
      button('.increment', 'Increment'),
      p('Counter: ' + state)
    ])
  );

  const initReducer$ = xs.of(function initReducer() {
    return 0;
  });
  const updateReducer$ = action$.map(num => function updateReducer(prevState) {
    return prevState + num;
  });
  const reducer$ = xs.merge(initReducer$, updateReducer$);

  return {
    DOM: vdom$,
    onion: reducer$,
  };
}

const wrappedMain = onionify(main);

Cycle.run(wrappedMain, {
  DOM: makeDOMDriver('#main-container')
});

Collection API and pickCombine/pickMerge

I pushed to the collection branch an experimental feature where we borrow an API from Cycle Collection and improve performance under the hood.

The gist is:

Create many instances of a component using collection

function MyList(sources) {
  // ...

  // Expects sources.onion.state$ to be a Stream<Array<ItemState>>
  const instances$ = collection(Item, sources, itemState => itemState.key)
  // the above is the same as
  //const instances$ = collection(Item, sources)

pickCombine('DOM') is pick('DOM') + mix(xs.combine)

const childrenvnodes$ = instances$.compose(pickCombine('DOM'))

pickMerge('onion') is pick('onion') + mix(xs.merge)

const childrenreducer$ = instances$.compose(pickMerge('onion'))

Note: this is an experiment. That's why it's in a branch.

The motivation for this was to make onionify faster for large amounts of child components from an array. The main solution for this was pickCombine and the internal data structure of "instances" which collection builds. pickCombine is a fusion of pick+mix to avoid a combine, a flatten, and a map, and does all of these together and takes shortcuts to avoid recalculating things in vain. collection was sort a necessity from two perspectives: (1) easier than creating and isolating item components by hand, (2) with this performance improvement it would have become even harder to do it by hand.

Check the advanced example.

We're looking for feedback in order to find the most suitable API that feels quick to learn for the average programmer, while solving the problems below too. In other words, the challenge here is API design for developer experience. The challenge is not "how" to solve the technical problem.

Open problems/questions:

  • Solved thanks to a PR by @abaco. A child component may get double updates since it is onionified as well as the parent is. When the child component sends out a reducer to the onion sink, it will update its onion source state, but also the parent onion source state. We need to find a way to make the child avoid the second update since its redundant.
  • Update or replace the test "should work with an isolated list child with a default reducer"
  • How do we allow lenses on individual items, like we can do e.g. in example mixed?
  • collection() does a lot under the hood (creates the instances data structure, calls onionify, picks the key from each item state, creates item state lenses, onionifies each child component, etc). Do we want to have heavy configuration with many arguments, or do we want to break that down into other helper functions? And how?
  • How to avoid fragility to object equality issues? (read the thread)
  • How to keep the feature where we can pass a channel name instead of "onion"
  • This change expects item state to have a unique key. Do we still want to allow keyless items in a list?

Checklist before final release:

  • Rewrite official cycle examples
  • Rewrite todomvc
  • Rewrite matrixmultiplication
  • Write JSDocs for asCollection, pickCombine, pickMerge
  • asCollection to support state$ when it's a stream of objects
  • Implement CollectionSource
  • Rename asCollection to toCollection
  • Gather more feedback
  • Fix bug described by ntilwalli
  • Add tests for Cycle Unified
  • Check issue ntilwalli raised about isolateEach
  • Fix issue ntilwalli raised about RxJS support
  • Check issue ntilwalli raised about using toCollection without wrapper List component
  • Check issue atomrc raised about toCollection()
  • Check issue jvanbruegge raised about initial state
  • Update readme.md docs
  • Publish

Composability of Cycle add-ons as wrapper functions

This is not quite a bug report, more like a token of something to figure out when things are more mature.

I'm wondering about the composability of Cycle add-ons which arrive as a function which wraps the application main function. Once an application uses several of those, it needs to choose what order to wrap them. Wrapping does not seem to compose as well as adding drivers.

The specific case I hit was with onionify and cyclejs-modal:

https://github.com/cyclejs-community/cyclejs-modal

both of which arrive as a function to wrap around the application main function.

I ended up with this wrapping order:

const main = modalify(onionify(App));

... which yields both libraries working, with the caveat that onion state is not available within a component instantiated by modalify. This requires a workaround for state, carrying state needed for the modal by some means other than onion. (I briefly dabbled with a global variable, the allure of the dark side is sometimes strong...)

If I wrap them the other way, onion state is available inside the component instantiated by modalify - but modalify fails to work, for reasons I did not debug.

I would guess the fundamental challenge is: I'm using two libraries both of which expect to be the "outermost" wrapper function. Staring at the source code of both things, it's not clear to me what could be done to either to make their functionality mutually available to each other.

emitting `xs.never` with pickCombine might be wrong

I thought about it, and basicly this will block the combine from emitting at all, so it is basicly a silent failure. For merge never is the right thing to do, but the other half of my PR might be better to roll back

[Question] How to share data?

As the state becomes fractal I wonder how sharing data across application not in a parent-child component hierarchy is possible. Its easy to imagine that this is super common scenario. I guess its possible to communicate the closest common ancestor so it could store this shared state, but what about refactoring? From what I've seen you need to go 'manually' through each layer so if that's the solution then you need to refactor the code so you will reference i.e. to the 4th and not 3rd ancestor. Maybe I'm missing something here, not a Cycle expert anyway, but im rly interested in the topic.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.