staltz / cycle-onionify

MIGRATED! This was transfered to https://cycle.js.org/api/state.html
MIT License
279 stars 19 forks source link

Possible to not emit until default reducer gets run? #49

Closed ntilwalli closed 7 years ago

ntilwalli commented 7 years ago

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.

abaco commented 7 years ago

How did you notice the odd state$ emissions? I'm asking because I would expect the state$ to catch up before the screen is even drawn the first time, so that no flash can be seen.

There are many cases in which a complex network of streams can emit superfluous events, but that's fine as long as the right events are emitted at the right times, and the state of the app is always consistent, if odd at moments. Javascript is synchronous, it has no such thing as simultaneous events. We can't overcome Javascript's limitations, but by using streams, which operate at a higher level of abstraction, we should be able to ignore those limitations. The order in which streams are merged shouldn't change the overall behavior of the app. The question is then: why can't you ignore the superfluous emissions of state$?

Personally I'd like to keep the possibility to omit the default reducer in a component.

ntilwalli commented 7 years ago

The question is then: why can't you ignore the superfluous emissions of state$?

You can't literally ignore superfluous emissions of state. You can't pretend they don't exist. My view functions assume the truthiness of certain pieces of state. When those pieces of state are invalid/not-truthy, the view crashes. This is not about avoiding a flash. It's about a crash.

To "ignore" in this context means...

1) Knowing they happen (which, in a sense means you're not ignorant) 2) Using a technique to filter on them

The technique I tend to use is proper merge stream ordering and when that fails, adding truthiness guards in my view functions (and sometimes adding a state-validity flag). These are things I didn't have to do when I maintained my state local to my components.

Am I missing something? Is there a way to actually pretend invalid states don't exist without causing crashes? Maybe using requestAnimationFrame in a clever way offers an approach?

I can see how omitting a default reducer can be useful. I'm just trying to think of ways to avoid invalid states. At the very least, this issue should be documented.

abaco commented 7 years ago

By "ignore emissions" I mean "be indifferent to emissions", i.e. robust. I think it's a common situation, I run into something similar every now and then. Anyway I think your problem should be solved differently.

Your parent component assumes the state to satisfy certain requirements, which are enforced by its own default reducer, but at the same time it merges its reducers with the two children reducers in a way that doesn't determine their relative order. By using merge you can't know (in theory) in which order the reducers will emit. That's the problem. Your component needs its default reducer to emit first, but does nothing explicit for that to happen.

This should explicitly enforce that the default reducer emits first:

const defaultReducer$ = ...   // the local default reducer
const otherReducer$ = ...     // other local reducers
const subsequentReducer$ = O.merge(A.onion, B.onion, otherReducer$)
return {
  ...
  onion: O.concat(defaultReducer$, subsequentReducer$)
}
ntilwalli commented 7 years ago

Perfect. That solves the issue elegantly. concat has not been a goto operator for me, but it will be now! 👍

staltz commented 7 years ago

Cool that abaco already answered it. I would also add a .take(N) to each participating stream in the concat, just to make sure that concatenation actually happens instead of hanging forever if one of the participating streams doesn't complete.

Even though this is closed/answered, I'm still curious about the "can't ignore state emissions" part. I would also say like abaco, that state emissions can be ignored, they should be skippable. If you have some boolean flag and you are creating an event (e.g. component instantiation is an event) whenever the boolean flips, that seems to me like converting from state to event, while in the first place some event caused the boolean flip. So you could theoretically shortcut, avoid the state as an intermediate, and just have a direct dependency between first event and second event.

For example:

const click$; // event
const toggle$ = click$.startWith(false).scan(prev => !prev, false); // state
const alert$ = toggle$.filter(Boolean).map(() => 'alert!'); // event

alert$.subscribe(x => window.alert(x));

simplified to

const click$; // event
const alert$ = click$.map(() => 'alert!'); // event

alert$.subscribe(x => window.alert(x));
ntilwalli commented 7 years ago

@staltz What does it mean for a state emission to be skippable? Do you mean, it's okay to filter state emissions?

staltz commented 7 years ago

I'd answer a soft yes, not a hard yes.