cujojs / most

Ultra-high performance reactive programming
MIT License
3.49k stars 231 forks source link

HOC with reactive state. #133

Open izaakschroeder opened 9 years ago

izaakschroeder commented 9 years ago

Playing around with some ideas inspired loosely from https://github.com/lapanoid/react-kefir-flux.

Couple of questions/thoughts:

My pseudo-code thus far:


import { Component } from 'react';
import { combine } from 'most';

/**
 * Create a higher-order component from the given component that listens and
 * reacts to updates from some event sources.
 *
 * Use as an ES7 mixin:
 *
 * @observe([streamA, streamB, ...], (a, b) => {
 *   foo: a.foo,
 *   bar: b.bar
 * })
 *
 * When any of `streamA`, `streamB`, etc. updates, then the state properties
 * `foo` and `bar` get updated and passed down as properties to the wrapped
 * component.
 *
 * See: https://medium.com/@dan_abramov/94a0d2f9e750
 *
 */
export default function observe(observables, state) {
    // Create a combined stream that maps all the observables to a single state
    // value for the react component.
    const stream = combine(observables, state);

    // Create the higher-order component.
    return function wrap(Target) {

        return class ObservedComponent extends Component {
            getInitialState() {
                // How to get this behavior?
                return stream.current();
            }
            componentDidMount() {
                // While the component is mounted, watch for changes in state;
                // when those changes occur update our state accordingly.
                stream.observe(result => this.setState(result));
            }
            componentWillUnmount() {
                // How to get this behavior?
                stream.unobserve();
            }
            render() {
                return <Target {...this.props} {...this.state}/>;
            }
        };
    };
}

Thanks for any insight you can provide!

briancavalier commented 9 years ago

Hey @izaakschroeder, great questions. I'll try to answer.

You may also want to take a look at this most.js + virtual-dom demo for ideas.

How do I get the current value of a stream? Couldn't find anything in the API and hunting through code only seems to show it as a _private property.

In most.js, streams don't have a "current value". Streams are a conduit for events, which have a value and occur at a particular time. So streams themselves have no actual value. Some streams internally buffer a most recent value, but it's only exposed by later propagating it through the stream under certain conditions (for example, combine does this). Some streams don't even have a most recent value--for example, a stream on which no events have occurred yet.

If what you want is an initial value (I see you're using it for getInitialState), then maybe you can use startWith to provide a default initial value for the component. For example, you could require the caller of your observe function to provide an initial value.

If you can explain a bit more about why you feel you need to get the current value, maybe we can figure out how to solve that use case without the need for a current value.

Can combine (and other variadic functions for consistency) be structured to accept arrays?

The publicly exported combine is varargs only. However, the implementation of combine actually uses a combineArray function under the hood. We could certainly export that publicly if it seems useful. Would that work for you?

Best way to start/stop observing a stream (in the context of React components)? until? during?

In the context of React ... good question. Most.js is focused on being declarative rather than imperative, i.e. you declare up front, via until, when you want to stop observing a stream. The issue here is that React doesn't seem to provide a way to state that up front. If, for example, React components emitted an externally observable event, it might be possible to use most.fromEvent to observe that event as a stream, and use it with until.

I'll give this one a bit more thought!

izaakschroeder commented 9 years ago

Thanks for the thoughtful reply! I will be replying in detail momentarily, but if you want to use gitter to talk more in real time about this happy to do that :smile: https://gitter.im/ (I'm sure your project could benefit from an area to discuss things too).

izaakschroeder commented 9 years ago

If what you want is an initial value (I see you're using it for getInitialState), then maybe you can use startWith to provide a default initial value for the component. For example, you could require the caller of your observe function to provide an initial value.

If you can explain a bit more about why you feel you need to get the current value, maybe we can figure out how to solve that use case without the need for a current value.

Following a kind of fluxish pattern, you have a store that receives most updates via actions. The store uses these updates to generate its own stream of the objects its concerned about. A todo store would have a collection of todos, and a todo action "create" would have an immutable record with the todo data; the store would use scan to trigger an update with the newest state of the collection of todos, adding the one just created. Then a component using a list of those todos would subscribe to that stream and every update the component re-renders. The first time the component renders, however, it needs an initial state. If the component is mounted some time after interactions have happened in the store, the most recent value of whatever the current collection of todos happens to be makes sense. kefir distinguishes properties from streams, maybe we could have something similar (literally just a stream with a current value). This definitely makes sense for certain kinds of streams I think.

combineArray function under the hood. We could certainly export that publicly if it seems useful. Would that work for you?

Technically yes, just like combine.apply technically works fine too :wink: Having multiple method names seems overly obtuse, especially for a language like javascript where it's nice to just have the function "do the right thing". (Especially since the cost is negligible; the bulk of the work is in sending events, not creating streams).

// Only import the function itself; smaller builds are better!
var flatten = require('lodash/array/flatten');

function combine() {
  // Easy!
  var streams = flatten(arguments, true);
  // ...
}

Most.js is focused on being declarative rather than imperative.

I like this approach in general since it's easier to reason about and makes my inner purist happy, but it is a pain to deal with in some of these silly "real world" cases.

Another case where kefir has something that is handy to have: pools. Where every time I invoke a function it triggers a new event. most has most.create for which this pattern is cumbersome to emulate since add/error/end are all somewhat "trapped" in the closure.

var actions = {
  create: {
    observable: stream(),
    invoke: function() { 
      var result = someApiCallThatReturnsPromise();
      this.observable.add(streamFromPromise(result)) 
    }
  }
}

It would be possible to have react generate component events the same way if the above pattern was possible.

class SomeComponent {
   componentDidMount() { this.lifecycle.add(most.constant()) },
   componentWillUnmount() { this.lifecycle.add(most.constant()) }
}

Just some thoughts. My goal is to have some working implementation of this by the end of the weekend. Happy to help on whatever is needed! Thanks again for all the thoughtful feedback so far :smile:

nissoh commented 9 years ago

You can use ES6 arrow functions to make most.create work imperatively

this.endSignalStream = most.create(add => {
   this.endSignal = add
})

I use this as an end signal within react's componentWillUnmount callback. Feel free to check this gist https://gist.github.com/nissoh/e835e940288b990d6160

Sadly my daily work doesn't require react so this is not battle tested

izaakschroeder commented 9 years ago

@nissoh @briancavalier here is what I've come up with: https://github.com/izaakschroeder/react-observer Also not battle tested :smile:

izaakschroeder commented 9 years ago

Thoughts on https://github.com/izaakschroeder/afflux/blob/master/lib/render.js#L42 also welcome. It still suffers from the initial value problem - observe is never called on a server-side render, even though the stores are updated the initial value in the store is never propagated (since there isn't a current value).

briancavalier commented 9 years ago

Hey @izaakschroeder sorry to take a bit to get back to you. This looks really interesting! I see you reached inside and used some of the most/lib modules ... well done. I hope it wasn't too hard to figure out :)

here is what I've come up with

One quick thought is that you could use a custom observer here. You can use any object that fulfills the same interface (event(t, x), end(t, x), and error(t, e)), so you could create one that's more tailored to your needs, and avoid the bind() calls.

It still suffers from the initial value problem - observe is never called on a server-side render

Afflux looks impressive!

Unfortunately, I'm not sure I understand it well enough (yet!) to help with this issue. The only thought I have is somehow to create a thin wrapper around an event stream which allows an initial value to be sampled (ie pulled) synchronously.

One underlying issue here may be that, for now, most is focused on event streams, and not signals (aka behaviors, aka properties). So a stream really is a sequence of discrete events, and not really a "time-varying value" like a baconjs property, Yampa signal, etc. For better or worse (lately I think for worse), we've crossed that line in places like combine, sample, startWith, which make streams behave a lot like, but not entirely like, signals.

I'm not sure what to do about that right now, but I've been thinking about it a lot lately. In my ideal world, there would be a continuous, pull-based, signal companion to most's event streams ...

p.s. your last is clever, and accumulate is quite nice.

izaakschroeder commented 9 years ago

Hey @briancavalier, no worries - it's been enlightening.

I believe I have thought of a solution to the value problem: "stores" will simply be representation of state - not state itself. There will be a separate class whose only purpose is to observe the "stores" and record their values. This centralizes the signal processing and makes it straightforward to (de)hydrate state. It also means it's possible to ignore events entirely and just pass through a "current state" value.

However, the render function itself is still a nightmare. The current test case (just peek in test/spec/render.spec.js) causes it to loop infinitely. most.of() always creates things with timestamps of 0 (i.e. most.of().timestamp() is always 0, yet most.of().delay(1).timestamp() has large values) and so most.until() keeps picking up old values it seems, causing it to go on needlessly.

It seems to work correctly in the example though - so I'm not sure what I'm doing wrong or where; maybe the event has to be created on the same tick as the render call is made? But that seems terribly brittle.

I'm doing a presentation on this framework next week so any advice is incredibly appreciated. Would be extremely happy to hammer this out over hangouts or similar :beers: Your insight into most internals is pretty much invaluable at this point I think :smile:

briancavalier commented 9 years ago

I'm doing a presentation on this framework next week so any advice is incredibly appreciated. Would be extremely happy to hammer this out over hangouts

Cool, I'm happy to try to help. How about IRC? I'm in #cujojs on freenode basically all the time, so feel free to drop in and ping me.

I believe I have thought of a solution to the value problem

Cool! It sounds like a pretty logical separation.

most.of() always creates things with timestamps of 0

Yeah, deciding what the event times for for most.of should be is a tricky question. I'm not sure what the right answer is, but I'll try to explain my thinking in choosing zero. The event times in most.js are explicitly intended to represent the time at which the event occurred. So, given something like most.of(3), a question is "when does 3 occur?".

On one hand, we could say that 3 "occurs" when it came into existence. If you think about something like a DOM event (which natively has timestamp), then "time of existence" makes sense. Since 3 has always existed, a zero timestamp kind of makes sense.

On the other hand, we could pick the observation time as the timestamp for 3. That seems weird, since 3 clearly exists before anyone begins observing the stream. It also seems weird that two observers of most.of(3) might see different timestamps for 3. That would seem to imply that 3 is "occurring" at many points in time, which makes my head hurt :)

Where things get really weird, as you pointed out, is most.of(3).delay(1). You'd think it'd produce a timestamp of 1 (i.e. 0 + 1). Unfortunately, it's inconsistent because it delays from now rather than from zero, so it'll produce a timestamp of scheduler.now() + 1 instead of 0 + 1. I can see changing that for consistency's sake. At that point, it'd probably make sense to add a new stream type ... something like most.after(delay, value) which places an event at an explicit time relative to now.

I'm definitely open to other ways of thinking about most.of and timestamps, so if you have thoughts/ideas, I'd love to hear!

most.until() keeps picking up old values it seems, causing it to go on needlessly.

Hmmm, can you point me to an example of this? I'm happy to take a look. I peeked in render.js and render.spec.js, but didn't see until in there.

izaakschroeder commented 9 years ago

I'm in #cujojs on freenode basically all the time, so feel free to drop in and ping me.

Sounds good I'll be there!

I'm definitely open to other ways of thinking about most.of and timestamps, so if you have thoughts/ideas, I'd love to hear!

I think the current behaviour of of complements most's declarative nature – describing things or behaviours. However, it is confusing and probably worth clarifying in the documentation. Maybe there should be an alternative to of that is time-based; that says "this occurred at this moment", not "this value always was" - e.g. most.now(x).

And as nice as the idea is, pragmatically, is there any behaviour achieved with non-timestamp values that is preferable to timestamped ones? For example, most.during(most.of(stream), target) suddenly doesn't mean what the user thinks it would – it actually means since the beginning of time until stream emits event, not since now until stream emits event.

Random side-thought: using of is kind of annoying since it's an ES6 reserved word – maybe an alias to something else (e.g. just) would be handy; I almost always import à la { of as just } from 'most'

Hmmm, can you point me to an example of this? I'm happy to take a look.

Sorry I haven't pushed any of my experiments – I think it uses during right now, and from a cursory glance at the code most.during(most.of(stream), target) is equivalent to most.until(stream, target).

Thanks again for all the insight and help so far.

briancavalier commented 9 years ago

Maybe there should be an alternative to of that is time-based;

Yeah, I've been thinking that as well. I recently noticed that netwire has now and at. I'll do more looking around at other FRP impls to see what they offer as well. I think something like this could be the way to go, but there are definitely some questions in my mind:

In most.now(x), what should "now" actually mean? Does it mean "time of observation" or does it mean some implicit "now" at the time the stream was constructed? If it's the latter and you observe most.now(x) after "now", should you actually see x, or will the stream appear to be empty? (This same question is valid for most.of(x) in fact!)

For example, most.during(most.of(stream), target) ... actually means since the beginning of time until stream emits event

I actually find it kind of pleasing that most.during(most.of(stream), ...) is exactly equivalent to most.until(stream, ...)

Random side-thought: using of is kind of annoying since it's an ES6 reserved word

Agree! I've only used it because fantasy-land Applicative dictates it. I'm totally open to adding an ES6-friendly alias, like just.

TrySound commented 7 years ago

Same here https://github.com/cujojs/most/issues/71