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 componentimport 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 appimport 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'),
});
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
.
How exactly can I use this Counter
onion-ready component inside my non-onionified main
?
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).
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.