I have been experimenting with RxJS for two weeks now, and although I love it in principle I just cannot seem to find and implement the correct pattern for managing state. All articles and questions appear to agree:
Subject
should be avoided where possible in favor of just pushing state through via transformations;.getValue()
should be deprecated entirely; and.do
should perhaps be avoided except for DOM manipulation?
The problem with all such suggestions is that none of the literature appears to directly say what you should be using instead, besides "you'll learn the Rx way and stop using Subject".
But I cannot find a direct example anywhere that specifically indicates the correct way to perform both additions and removals to a single stream/object, as the consequence of multiple other stream inputs, in a stateless and functional manner.
Before I get pointed in the same directions again, problems with uncovered literature are:
- The Introduction to Reactive Programming You've been missing: great starting text, but does not specifically address these questions.
- The TODO example for RxJS comes with React and involves explicit manipulation of
Subject
s as proxies for React Stores. - http://blog.edanschwartz.com/2015/09/18/dead-simple-rxjs-todo-list/ : explicitly uses a
state
object for addition and removal of items.
My perhaps 10th rewrite of the standard TODO follows - My prior iterations covered include:
- starting with a mutable 'items' array - bad as state is explicit and imperatively managed
- using
scan
to concatenate new items to anaddedItems$
stream, then branching another stream where the removed items were deleted - bad as theaddedItems$
stream would grow indefinitely. - discovering
BehaviorSubject
and using that - seemed bad since for each newupdatedList$.next()
emission, it requires the previous value to iterate, meaning thatSubject.getValue()
is essential. - trying to stream the result of the
inputEnter$
addition events into filtered removal events - but then every new stream creates a new list, and then feeding that into thetoggleItem$
andtoggleAll$
streams means that each new stream is dependent on the previous, and so causing one of the 4 actions (add, remove, toggle item or toggle all) requires the whole chain to be unnecessarily run through again.
Now I have come full circle, where I am back to using both Subject
(and just how is it supposed to be successively iterated upon in any way without using getValue()
?) and do
, as show below. Myself and my colleague agree this is the clearest way, yet it of course seems the least reactive and most imperative. Any clear suggestions on the correct way for this would be much appreciated!
import Rx from 'rxjs/Rx';
import h from 'virtual-dom/h';
import diff from 'virtual-dom/diff';
import patch from 'virtual-dom/patch';
const todoListContainer = document.querySelector('#todo-items-container');
const newTodoInput = document.querySelector('#new-todo');
const todoMain = document.querySelector('#main');
const todoFooter = document.querySelector('#footer');
const inputToggleAll = document.querySelector('#toggle-all');
const ENTER_KEY = 13;
// INTENTS
const inputEnter$ = Rx.Observable.fromEvent(newTodoInput, 'keyup')
.filter(event => event.keyCode === ENTER_KEY)
.map(event => event.target.value)
.filter(value => value.trim().length)
.map(value => {
return { label: value, completed: false };
});
const inputItemClick$ = Rx.Observable.fromEvent(todoListContainer, 'click');
const inputToggleAll$ = Rx.Observable.fromEvent(inputToggleAll, 'click')
.map(event => event.target.checked);
const inputToggleItem$ = inputItemClick$
.filter(event => event.target.classList.contains('toggle'))
.map((event) => {
return {
label: event.target.nextElementSibling.innerText.trim(),
completed: event.target.checked,
};
})
const inputDoubleClick$ = Rx.Observable.fromEvent(todoListContainer, 'dblclick')
.filter(event => event.target.tagName === 'LABEL')
.do((event) => {
event.target.parentElement.classList.toggle('editing');
})
.map(event => event.target.innerText.trim());
const inputClickDelete$ = inputItemClick$
.filter(event => event.target.classList.contains('destroy'))
.map((event) => {
return { label: event.target.previousElementSibling.innerText.trim(), completed: false };
});
const list$ = new Rx.BehaviorSubject([]);
// MODEL / OPERATIONS
const addItem$ = inputEnter$
.do((item) => {
inputToggleAll.checked = false;
list$.next(list$.getValue().concat(item));
});
const removeItem$ = inputClickDelete$
.do((removeItem) => {
list$.next(list$.getValue().filter(item => item.label !== removeItem.label));
});
const toggleAll$ = inputToggleAll$
.do((allComplete) => {
list$.next(toggleAllComplete(list$.getValue(), allComplete));
});
function toggleAllComplete(arr, allComplete) {
inputToggleAll.checked = allComplete;
return arr.map((item) =>
({ label: item.label, completed: allComplete }));
}
const toggleItem$ = inputToggleItem$
.do((toggleItem) => {
let allComplete = toggleItem.completed;
let noneComplete = !toggleItem.completed;
const list = list$.getValue().map(item => {
if (item.label === toggleItem.label) {
item.completed = toggleItem.completed;
}
if (allComplete && !item.completed) {
allComplete = false;
}
if (noneComplete && item.completed) {
noneComplete = false;
}
return item;
});
if (allComplete) {
list$.next(toggleAllComplete(list, true));
return;
}
if (noneComplete) {
list$.next(toggleAllComplete(list, false));
return;
}
list$.next(list);
});
// subscribe to all the events that cause the proxy list$ subject array to be updated
Rx.Observable.merge(addItem$, removeItem$, toggleAll$, toggleItem$).subscribe();
list$.subscribe((list) => {
// DOM side-effects based on list size
todoFooter.style.visibility = todoMain.style.visibility =
(list.length) ? 'visible' : 'hidden';
newTodoInput.value = '';
});
// RENDERING
const tree$ = list$
.map(newList => renderList(newList));
const patches$ = tree$
.bufferCount(2, 1)
.map(([oldTree, newTree]) => diff(oldTree, newTree));
const todoList$ = patches$.startWith(document.querySelector('#todo-list'))
.scan((rootNode, patches) => patch(rootNode, patches));
todoList$.subscribe();
function renderList(arr, allComplete) {
return h('ul#todo-list', arr.map(val =>
h('li', {
className: (val.completed) ? 'completed' : null,
}, [h('input', {
className: 'toggle',
type: 'checkbox',
checked: val.completed,
}), h('label', val.label),
h('button', { className: 'destroy' }),
])));
}
Edit
In relation to @user3743222 very helpful answer, I can see how representing state as an additional input can make a function pure and thus scan
is the best way to represent a collection evolving over time, with a snapshot of its previous state up to that point as an additional function parameter.
However, this was already how I approached my second attempt, with addedItems$
being a scanned stream of inputs:
// this list will now grow infinitely, because nothing is ever removed from it at the same time as concatenation?
const listWithItemsAdded$ = inputEnter$
.startWith([])
.scan((list, addItem) => list.concat(addItem));
const listWithItemsAddedAndRemoved$ = inputClickDelete$.withLatestFrom(listWithItemsAdded$)
.scan((list, removeItem) => list.filter(item => item !== removeItem));
// Now I have to always work from the previous list, to get the incorporated amendments...
const listWithItemsAddedAndRemovedAndToggled$ = inputToggleItem$.withLatestFrom(listWithItemsAddedAndRemoved$)
.map((item, list) => {
if (item.checked === true) {
//etc
}
})
// ... and have the event triggering a bunch of previous inputs it may have nothing to do with.
// and so if I have 400 inputs it appears at this stage to still run all the previous functions every time -any- input
// changes, even if I just want to change one small part of state
const n$ = nminus1$.scan...
The obvious solution would be to just have items = []
, and manipulate it directly, or const items = new BehaviorSubject([])
- but then the only way to iterate on it appears to be using getValue
to expose the previous state, which Andre Stalz (CycleJS) has commented on in the RxJS issues as something that shouldn't really be exposed (but again, if not, then how is it usable?).
I guess I just had an idea that with streams, you weren't supposed to use Subjects or represent anything via a state 'meatball', and in the first answer I'm not sure how this doesn't introduce mass chained streams which are orphaned/grow infinitely/have to build on each other in exact sequence.
I think you already found a good example with : http://jsbin.com/redeko/edit?js,output.
You take issue with the fact that this implementation
However, thas is exactly the good practice you are looking for. If you rename that state object
viewModel
for example, it might be more apparent to you.So what is state?
There will be other definitions but I like to think of state as follows:
f
an impure function, i.e.output = f(input)
, such that you can have different outputs for the same input, the state associated to that function (when it exists) is the extra variable such thatf(input) = output = g(input, state)
holds and g is a pure function.So if the function here is to match an object representing a user input, to an array of todo, and if I click
add
on a todo list with already have 2 todos, the output will be 3 todos. If I do the same (same input) on a todo list with only one todo, the output will be 2 todos. So same input, different outputs.The state here that allows to transform that function into a pure function is the current value of the todo array. So my input becomes an
add
click, AND the current todo array, passed through a functiong
which give a new todo array with a new todo list. That function g is pure. Sof
is implemented in a stateless way by making its previously hidden state explicit ing
.And that fits well with functional programming which revolves around composing pure functions.
Rxjs operators
So when it comes to state management, with RxJS or else, a good practice is to make state explicit to manipulate it.
If you turn the
output = g(input, state)
into a stream, you getOn+1 = g(In+1, Sn)
and that's exactly what thescan
operator does.Another operator which generalizes
scan
isexpand
, but so far I had very little use of that operator.scan
generally does the trick.Sorry for the long and mathy answer. It took me a while to get around those concepts and that's the way I made them understandable for me. Hopefully it works for you too.