Search code examples
javascripttypescriptcyclejs

Using cycle-onionify within a non-onionified Cycle.js app


I may be attempting something foolish, but I have a sufficiently-large non-onionified Cycle.js app and I’m trying to learn how onionify works, so I’d like to embed an onionified component into my original non-onion app.

So I have a simple onion-ready component, the increment/decrement example, and I have a simple non-onion Cycle app, the “Hello Last Name” example—how do I smoosh the two together so I have the incrementer component and the Hello component one after the other in the same webpage?

Counter.ts, onion-ready component

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

export default 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.count)
        ])
    );

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

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

index.ts, non-onion main app

import xs, { Stream } from 'xstream';
import { run } from '@cycle/run';
import { div, input, h2, button, p, makeDOMDriver, VNode, DOMSource } from '@cycle/dom';

import Counter from "./Counter";
import onionify from 'cycle-onionify';
const counts = onionify(Counter);

interface Sources {
    DOM: DOMSource;
}

interface Sinks {
    DOM: Stream<VNode>;
}

function main(sources: Sources): Sinks {
    const firstName$ = sources.DOM
        .select('.first')
        .events('input')
        .map(ev => (ev.target as HTMLInputElement).value)
        .startWith('');

    const lastName$ = sources.DOM
        .select('.last')
        .events('input')
        .map(ev => (ev.target as HTMLInputElement).value)
        .map(ln => ln.toUpperCase())
        .startWith('');

    const rawFullName$ = xs.combine(firstName$, lastName$)
        .remember();

    const validName$ = rawFullName$
        .filter(([fn, ln]) => fn.length > 0 && ln.length >= 3)
        .map(([fn, ln]) => `${ln.toUpperCase()}, ${fn}`);

    const invalidName$ = rawFullName$
        .filter(([fn, ln]) => fn.length === 0 || ln.length < 3)
        .mapTo('');

    const name$ = xs.merge(validName$, invalidName$);

    const vdom$ = name$.map(name =>
        div([
            p([
                'First name',
                input('.first', { attrs: { type: 'text' } }),
            ]),
            p([
                'Last name',
                input('.last', { attrs: { type: 'text' } }),
            ]),
            h2('Hello ' + name),
        ]),
    );

    return {
        DOM: vdom$,
    };
}

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

Attempts

If I replace run(main, ...) with run(counts, ...), as the cycle-onionify docs advise for a fully-onionified app, I see only the counter as expected.

But counts, as the output of onionify(Counter), is a function, so I don’t think I can “instantiate” it inside my `main().

Similarly, I don’t think I can create a counter component by calling Counter() inside main because that function requires a sources.onion input, and I’m not sure how to create that .onion field, which has type StateSource.

Question

How exactly can I use this Counter onion-ready component inside my non-onionified main?

Full example

Full example is available at https://gist.github.com/fasiha/939ddc22d5af32bd5a00f7d9946ceb39 — clone this, npm install to get the requisite packages, then make (this runs tsc and browserify to convert TypeScript→JavaScript→browserified JS).


Solution

  • That's actually pretty simple. As you said, you can call Counter() in your main, but then it does not have the StateSource.

    The solution is to replace Counter() with onionify(Counter)():

    function main(sources: Sources): Sinks {
        //other main stuff
    
        const vdom$ = //implementation here
    
        const counterSinks = onionify(Counter)(sources);
    
        const combinedVdom$ = xs.combine(vdom$, counterSinks.DOM)
            .map(div). //Enclose with div
    
        return {
            DOM: combinedVdom$,
        };
    }
    

    Note that this will only make use of the DOM sink in the Counter so if you want to use other sinks in the counter, you have to specify them as well:

    const counterSinks = onionify(Counter)(sources);
    
    const combinedVdom$ = xs.combine(vdom$, counterSinks.DOM)
        .map(div). //Enclose with div
    
    return {
        DOM: combinedVdom$,
        HTTP: xs.merge(myOwnRequest$, counterSinks.HTTP)
        //...
    };
    

    As this is a bit tedious to do this for every sink, I've created a helper in the package cyclejs-utils:

    const counterSinks = onionify(Counter)(sources);
    
    const combinedVdom$ = xs.combine(vdom$, counterSinks.DOM)
        .map(div). //Enclose with div
    
    const ownSinks = { DOM: vdom$, /* ... */ };
    
    return {
        ...mergeSinks(ownSinks, counterSinks)
        DOM: combinedVdom$
    };
    

    The reason why I specify the DOM extra, is that mergeSinks calls merge on every sink, but for the DOM, you want to use combine + map, to combine the DOM to a new parent DOM exactly how you want to use it.