facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
226.6k stars 46.22k forks source link

Implement Sideways Data Loading #3398

Closed sebmarkbage closed 6 years ago

sebmarkbage commented 9 years ago

This is a first-class API for sideways data loading of stateless (although potentially memoized) data from a global store/network/resource, potentially using props/state as input.

type RecordOfObservables = { [key:string]: Observable<mixed> };

class Foo {

  observe(): RecordOfObservables {
    return {
      myContent: xhr(this.props.url)
    };
  }

  render() {
    var myContent : ?string = this.data.myContent;
    return <div>{myContent}</div>;
  }

}

observe() executes after componentWillMount/componentWillUpdate but before render.

For each key/value in the record. Subscribe to the Observable in the value.

subscription = observable.subscribe({ onNext: handleNext });

We allow onNext to be synchronously invoked from subscribe. If it is, we set:

this.data[key] = nextValue;

Otherwise we leave it as undefined for the initial render. (Maybe we set it to null?)

Then render proceeds as usual.

Every time onNext gets invoked, we schedule a new "this.data[key]" which effectively triggers a forcedUpdate on this component. If this is the only change, then observe is not reexecuted (componentWillUpdate -> render -> componentDidUpdate).

If props / state changed (i.e. an update from recieveProps or setState), then observe() is reexecuted (during reconciliation).

At this point we loop over the new record, and subscribe to all the new Observables.

After that, unsubscribe to the previous Observables.

subscription.dispose();

This ordering is important since it allows the provider of data to do reference counting of their cache. I.e. I can cache data for as long as nobody listens to it. If I unsubscribed immediately, then the reference count would go down to zero before I subscribe to the same data again.

When a component is unmounted, we automatically unsubscribe from all the active subscriptions.

If the new subscription didn't immediately call onNext, then we will keep using the previous value.

So if my this.props.url from my example changes, and I'm subscribing to a new URL, myContent will keep showing the content of the previous url until the next url has fully loaded.

This has the same semantics as the <img /> tag. We've seen that, while this can be confusing and lead to inconsistencies it is a fairly sane default, and it is easier to make it show a spinner than it would be to have the opposite default.

Best practice might be to immediately send a "null" value if you don't have the data cached. Another alternative is for an Observable to provide both the URL (or ID) and the content in the result.

class Foo {

  observe() {
    return {
      user: loadUser(this.props.userID)
    };
  }

  render() {
    if (this.data.user.id !== this.props.userID) {
      // Ensure that we never show inconsistent userID / user.name combinations.
      return <Spinner />;
    }
    return <div>Hello, {this.data.user.name} [{this.props.userID}]!</div>;
  }

}

We should use the RxJS contract of Observable since that is more in common use and allows synchronous execution, but once @jhusain's proposal is in more common use, we'll switch to that contract instead.

var subscription = observable.subscribe({ onNext, onError, onCompleted });
subscription.dispose();

We can add more life-cycle hooks that respond to these events if necessary.

Note: This concept allows sideways data to behave like "behaviors" - just like props. This means that we don't have to overload the notion state for these things. It allows for optimizations such as throwing away the data only to resubscribe later. It is restorable.

josephsavona commented 9 years ago

undefined is probably the safest value to assign to data until the observable provides its first value via onNext. For example in Relay we assign different meanings to null (data does not exist) and undefined (not yet fetched), so our ideal default data value would be undefined. The alternative is to provide a new method, eg getInitialData, but I suspect this is unnecessary/overkill.

fdecampredon commented 9 years ago

This is pretty interesting however from the point of view of static typing I'm not so happy of key/value system, their type is pretty much impossible to express. Why not having observe return a single observable and set/merge the value resolved to this.data :

class Foo {

  observe() {
    return (
      loadUser(this.props.userID)
        .map(user => { user })
  }

  render() {
    if (this.data.user.id !== this.props.userID) {
      // Ensure that we never show inconsistent userID / user.name combinations.
      return <Spinner />;
    }
    return <div>Hello, {this.data.user.name} [{this.props.userID}]!</div>;
  }

}

And for the case of multiple fetch something like :

class Foo {

  observe() {
    return (
     combineLatest(
      loadUser(this.props.userID),
      loadSomethingElse(this.props.somethingElseId),
      (user, somethingElse) => ({ user, somethingElse})
     )
  }

  render() {
    ..
  }

}

This is perhaps a bit more verbose, but it allows to have nice static type :

interface Comp<T> {
  observe(): Observable<T>;
  data: T;
}
fdecampredon commented 9 years ago

Also instead of re executing observe when props/state change we can have access to 'props' 'state' as an observable :

class Foo {

  observe(propsStream) {
    return (
      propsStream
        .flatMap(({ userID }) => loadUser(userId))
        .map(user => { user })
    );
  }

  render() {
    if (this.data.user.id !== this.props.userID) {
      // Ensure that we never show inconsistent userID / user.name combinations.
      return <Spinner />;
    }
    return <div>Hello, {this.data.user.name} [{this.props.userID}]!</div>;
  }
}
sebmarkbage commented 9 years ago

The reason is because we don't want to require the use of combinators and understanding RxJS to be able to subscribe to (multiple) Observables. Combining two Observables in this way is quite confusing. In fact, at least for our data sources, we'll probably implement the subscription API but not even include the combinators on the Observables' prototype. That's not a requirement, but you're free to use combinators if you need to.

However, to subscribe to a simple Flux store, you shouldn't need to.

I think that Flow will probably be able to handle this static type using constraints, but I will check with those guys to make sure. I think that it'll be enough to type the data property and then the observe type can be implied.

class Foo extends React.Component {
  data : { user : User, content : string };
  observe() /* implied as { user : Observable<User>, content : Observable<string> } */ {
  }
}

This change is not about going all in on Observables as a way to describe application state. That can be implemented on top of this, like you've done before. This is explicitly not about application state since this method is idempotent. The framework is free to unsubscribe and resubscribe as needed.

vjeux commented 9 years ago

cc @ericvicenti

fdecampredon commented 9 years ago

At least in the case of typescript, there would be no way to constraint the return type of observe based on the type of data, at least until something like https://github.com/Microsoft/TypeScript/issues/1295 is implemented.

gaearon commented 9 years ago

I'd love to use this for the next version of React DnD, but obviously this requires waiting for React 0.14. I wonder if I can “polyfill” this for the time being with a higher-order component that sets this.data on a ref instance.. Might be too crazy though.

RickWong commented 9 years ago

Would it be possible to observe Promises? Then one could use a Promises tree to resolve data for the whole component tree before the first render! This would be very useful for server-side React.

aaronshaf commented 9 years ago

What are the benefits of making this a first-class API? It could essentially be accomplished using a "higher-order component."

gaearon commented 9 years ago

What are the benefits of making this a first-class API? It could essentially be accomplished using a "higher-order component."

Wrapping in 5 HOCs to get 5 subscriptions is a bit unwieldy and harder to understand for beginners. Understanding componentWillReceiveProps is also non-trivial. This fixes both.

I, for one, welcome our new observable overlords.

gaearon commented 9 years ago

I wonder if this can help bring https://github.com/chenglou/react-state-stream closer to React's vanilla API

aaronshaf commented 9 years ago

Wouldn't it just take one HOC? In the example in your Medium post, you iterate over stores and subscribe to each.

stores.forEach(store =>
  store.addChangeListener(this.handleStoresChanged)
);
gaearon commented 9 years ago

@aaronshaf Depends on use case, for sure. Sometimes it's different kinds of state sources, not just “several stores”. But I can't say on behalf of React team, let's hear what @sebmarkbage says.

nmn commented 9 years ago

Would love some sort of polyfill to play with this now. I didn't get the idea completely, yet. What is the mechanism involved with dealing with future updates. I'll spend some more time understanding it. I think it should be doable with a simple mixin.

elierotenberg commented 9 years ago

(@vjeux told me I should chime in! so here I am.)

I don't mean to promote my own work, but I think this hook is very similar to the getNexusBindings hook in React Nexus. You declare data deps at the component level via a lifecycle hook (which can depend on props).

The API looks like:

class UserDetails {
  getNexusBindings(props) {
    return {
      // binding to data in the datacenter
      posts: [this.getNexus().remote, `users/${this.props.userId}/posts`],
      // binding to data in the local flux
      mySession: [this.getNexus().local, `session`],
    }
  }
}

The binding is applied/updated during componentDidMount and componentWillReceiveProps. In the latter case, the next bindings are diffed with the previous bindings; removed bindings are unsubscribed, added bindings are subscribed. The underlying fetching/updated mechanism is described in the implementation of Nexus Flux. Basically with the same API you can either subscribe to local data (traditional local stores) or remote data (fetch using GET and receive patches via Websockets/polyfill). You could actually subscribe to data from another window (using postWindow) or a WebWorker/ServiceWorker but I still haven't found a truly useful use case for this.

Long story short, you synchronously describe data deps at the component level using a Flux abstraction and the hooks make sure your dependencies are automatically subscribed, injected on updates, and unsubscribed.

But it also comes with a nice feature: the exact same lifecycle functions are leveraged to perform data prefetching at server-side rendering time. Basically, starting from the root and recusrively from there, React Nexus pre-fetches the bindings, renders the component, and continues with the descendants until all the components are rendered.

sebmarkbage commented 9 years ago

@aaronshaf @gaearon The benefit of making it first class is:

1) It doesn't eat away at the props namespace. E.g. the higher-order component doesn't need to claim a name like data from your props object that can't use for anything else. Chaining multiple higher order components keeps eating up more names and now you have to find a way to keep those names unique. What if you're composing something that might already be composed and now you have a name conflict?

Besides, I think that best-practice for higher-order components should be to avoid changing the contract of the wrapped component. I.e. conceptually it should be the same props in as out. Otherwise it is confusing to use and debug when the consumer supplies a completely different set of props than is received.

2) We don't have to use state to store the last value. The data concept is similar to props in the sense that it is purely a memoization. We're free to throw it out at any point if we need to reclaim the memory. For example, in an infinite scroll we might automatically clean up invisible subtrees.

sebmarkbage commented 9 years ago

@RickWong Yes, it would be fairly trivial to support Promises since they're a subset of Observables. We should probably do that to be unopinionated. However, I would still probably recommend against using them. I find that they're inferior to Observables for the following reasons:

A) They can't be canceled automatically by the framework. The best we can do is ignore a late resolution. In the meantime, the Promise holds on to potentially expensive resources. It is easy to get into a thrashy situation of subscribe/cancel/subscribe/cancel... of long running timers/network requests and if you use Promises, they won't cancel at the root and therefore you have to just wait for the resources to complete or timeout. This can be detrimental to performance in large desktop pages (like facebook.com) or latency critical apps in memory constrained environments (like react-native).

B) You're locking yourself into only getting a single value. If that data changes over time, you can't invalidate your views and you end up in an inconsistent state. It is not reactive. For a single server-side render that might be fine, however, on the client you should ideally be designing it in a way that you can stream new data to the UI and automatically update to avoid stale data.

Therefore I find that Observable is the superior API to build from since it doesn't lock you in to fix these issues if you need to.

sebmarkbage commented 9 years ago

@elierotenberg Thanks for chiming in! It does seem very similar indeed. Same kind of benefits. Do you see any limitations with my proposal? I.e. there something missing, that React Nexus has, which you couldn't build on top of this? Would be nice if we didn't lock ourselves out of important use cases. :)

mridgway commented 9 years ago

From the server-rending standpoint, it is important that we're able postpone the final renderToString until the Observable/Promise has been resolved with data that could be fetched asynchronously. Otherwise, we're still in the position of having to do all asynchronous data fetching outside of React without knowing which components will be on the page yet.

I believe react-nexus does allow asynchronous loading to happen within a component before continuing down the render tree.

elierotenberg commented 9 years ago

Yes, react-nexus explicitly separates: 1) binding declaration as getNexusBindings (which is a synchronous, side-effect free lifecycle method, similar to render - actually it used to be name renderDependencies but I thought it was confusing), 2) binding subscription/update as applyNexusBindings (which is synchronous and diffs the previous nexus bindings to determine which new bindings must be subscribed and which ones must be unsubscribed) 3) binding prefetching as prefetchNexusBindings (which is asynchronous and resolves when the "initial" (whathever this means) value is ready)

ReactNexus.prefetchApp(ReactElement) returns a Promise(String html, Object serializableData). This hook mimicks the construction of the React tree (using instantiateReactComponent) and recursively constructs/prefetches/renders the components. When the whole component tree is 'ready', it finally calls React.renderToString, knowing that all the data is ready (modulo errors). Once resolved, the value of this Promise can be injected in the server response. On the client, the regular React.render() lifecycle works as usual.

gaearon commented 9 years ago

If anybody wants to play around with this kind of API, I made a really dumb polyfill for observe as a higher order component:

import React, { Component } from 'react';

export default function polyfillObserve(ComposedComponent, observe) {
  const Enhancer = class extends Component {
    constructor(props, context) {
      super(props, context);

      this.subscriptions = {};
      this.state = { data: {} };

      this.resubscribe(props, context);
    }

    componentWillReceiveProps(props, context) {
      this.resubscribe(props, context);
    }

    componentWillUnmount() {
      this.unsubscribe();
    }

    resubscribe(props, context) {
      const newObservables = observe(props, context);
      const newSubscriptions = {};

      for (let key in newObservables) {
        newSubscriptions[key] = newObservables[key].subscribe({
          onNext: (value) => {
            this.state.data[key] = value;
            this.setState({ data: this.state.data });
          },
          onError: () => {},
          onCompleted: () => {}
        });
      }

      this.unsubscribe();
      this.subscriptions = newSubscriptions;
    }

    unsubscribe() {
      for (let key in this.subscriptions) {
        if (this.subscriptions.hasOwnProperty(key)) {
          this.subscriptions[key].dispose();
        }
      }

      this.subscriptions = {};
    }

    render() {
      return <ComposedComponent {...this.props} data={this.state.data} />;
    }
  };

  Enhancer.propTypes = ComposedComponent.propTypes;
  Enhancer.contextTypes = ComposedComponent.contextTypes;

  return Enhancer;
}

Usage:

// can't put this on component but this is good enough for playing
function observe(props, context) {
  return {
    yourStuff: observeYourStuff(props)
  };
}

class YourComponent extends Component {
  render() {
    // Note: this.props.data, not this.data
    return <div>{this.props.data.yourStuff}</div>;
  }
}

export default polyfillObserve(YourComponent, observe);
jquense commented 9 years ago

Is Observable a concrete, agreed upon thing aside from library implementations? What is the contract, is it simple enough to implement without needing to use bacon or Rxjs? As nice as a first class api for sideloading data would be, it seems weird for React to add an api based on an unspeced/very-initial-specing primitive, given React's steady movement towards plain js. Would something like this ties us to a specific user land implementation?

as an aside why not Streams? I have no horse in the race, but I am honestly wondering; there is already work done on web streams, and of course there is node

aaronshaf commented 9 years ago

Another two to consider: https://github.com/cujojs/most and https://github.com/caolan/highland

sebmarkbage commented 9 years ago

@jquense There is active work on a proposal for adding Observable to ECMAScript 7(+) so ideally this would become plain JS. https://github.com/jhusain/asyncgenerator (Currently out-of-date.)

We would not take on a dependency on RxJS. The API is trivial to implement yourself without using RxJS. RxJS is the closest to the active ECMAScript proposal.

most.js seems doable too.

Bacon.js' API seems difficult to consume without taking on a dependency on Bacon because of the use of the Bacon.Event types for separating values.

The Stream APIs are too high-level and far removed from this use case.

RickWong commented 9 years ago

Is there some kind of “await before render" option? I mean on the client it’s not necessary to wait for all Observables before rendering, but on the server you’d want to wait for them to resolve so that each component's render() is full, not partial.

[parent] await observe(). full render(). -> [foreach child] await observe(). full render().

In all of my explorations I found that this is the most important lifecycle hook missing in server-side React.

elierotenberg commented 9 years ago

Following up this discussion, I've tried to sum up what React Nexus does in the following post:

Ismorphic Apps done right with React Nexus

Heres' the diagram of the core prefetching routine:

React Nexus

jquense commented 9 years ago

We would not take on a dependency on RxJS. The API is trivial to implement yourself without using RxJS. RxJS is the closest to the active ECMAScript proposal.

:+1: this is the big concern for me, thinking about say, promises where implementing your own is extremely fraught unless you know what you're doing. I think otherwise you end up with an implicit requirement on a specific lib in the ecosystem. Tangentially...one of the nice things from the promise world is the A+ test suite, so even across libraries there was at least an assurance of a common functionality of .then, which was helpful for promise interop before they were standardized.

gaearon commented 9 years ago

this is the big concern for me, thinking about say, promises where implementing your own is extremely fraught unless you know what you're doing. I think otherwise you end up with an implicit requirement on a specific lib in the ecosystem.

Completely agreed. Thankfully observables have a really simple contract, and don't even have built-in methods like then so in a way they're even simpler than promises.

sebmarkbage commented 9 years ago

They might become more complicated (and slower) if the committee insists that calling next schedules a micro-task like Promises.

fdecampredon commented 9 years ago

That would bother some a lot of pattern are based on the fact that onNext is synchronous in RxJS :/

sebmarkbage commented 9 years ago

I think a common Flux store pattern might be to keep a Map of Observables on a per-key basis so that they can be reused. Then clean them up when everyone is unsubscribed.

That way you can do things like: MyStore.get(this.props.someID) and always get back the same Observable.

RickWong commented 9 years ago

That way you can do things like: MyStore.get(this.props.someID) and always get back the same Observable.

Would using this.props.key (gone I know) make sense? In most cases you already pass such unique identifier with <... key={child.id} .. />.

elierotenberg commented 9 years ago

That way you can do things like: MyStore.get(this.props.someID) and always get back the same Observable.

That's the pattern I use for React Nexus, too. Store#observe returns a memoized, immutable observer; it is cleaned-up (including relevant backend-specific cleanup mechanism, such as sending an actual "unsubscribe" message or whatever) when all subscribers are gone for at least one tick.

jeffbski commented 9 years ago

@sebmarkbage @gaearon How would observe work on the server in v0.14? Would it be able to properly wait for all the observers to resolve before rendering to string similar to how react-nexus does (but built in to react)?

gaearon commented 9 years ago

IMO it would be great if components waited for the first observed value before being “ready” for rendering on server.

RickWong commented 9 years ago

@gaearon: IMO it would be great if components waited for the first observed value before being “ready” for rendering on server.

Yes, :+1: for asynchronous rendering. In the meantime react-async by @andreypopp is an alternative, but it requires fibers to "hack" React. It would be great if React could support asynchronous rendering out-of-the-box.

sebmarkbage commented 9 years ago

Async rendering is something we would like to support but is not part of this issue. L

Probably won't make it in 0.14 unfortunately. Many different designs to consider and refactor needed.

Feel free to create and issue describing the architectural changes to the internals needed to make that happen.

matthewwithanm commented 9 years ago

I had the same thought as @gaearon re: react-streaming-state. Given all the potential applications other than side loading, might there be a better name than data? For example, observed would more clearly associate it with the method.

Don't mean to derail with bikeshedding but wanted to throw this out there.

chicoxyzzy commented 9 years ago

can't wait for observables in React. this should make React reactive as I understand it

andreypopp commented 9 years ago

I'm experimenting with a similar idea while rewriting react-async, see README.

The notable difference is that I introduce explicit observable/process identity to reconcile processes, similar to how React does with key prop and stateful components.

When id of the named process changes React Async stops the old process instance and starts a new one.

The API looks like:

import React from 'react';
import Async from 'react-async';

function defineFetchProcess(url) {
  return {
    id: url,
    start() {
      return fetch(url)
    }
  }
}

function MyComponentProcesses(props) {
  return {
    user: defineFetchProcess(`/api/user?user${props.userID}`)
  }
}

@Async(MyComponentProcesses)
class MyComponent extends React.Component {

  render() {
    let {user} = this.props
    ...
  }
}

The process API now follows ES6 Promises API syntactically and name-wise but semantically it is not expected for process.then(onNext, onError) to be called only once per process live. It is made to accommodate the most popular (?) use case of fetching data via promises. But to be honest, now I think I need to change it to prevent confusion.

andreypopp commented 9 years ago

I maybe mistaken but I think the only thing which prevents implementing the proposed (in this issue) API in userland is the lack of the lifecycle hook which executes just before render like componentWillUpdate but with new props and state already installed on the instance.

andrewimm commented 9 years ago

One thing that hasn't yet been discussed is the handling of the onError callback. If an observable produces an error, that information should be available to the component somehow. Because React handles the actual subscribe(callbacks) call, we'd need a standardized method for it to inject into that callback object.

To provide the most flexibility for application developers, I see two approaches. The first is to have the errors placed on a top-level attribute, similar to this.data. This seems incredibly heavy handed, and further eats up the component's namespace. The second would allow developers to define their own onError callback as a lifecycle-ish function. If I wanted to have custom error handling for my observables, I could add something like

onObserveError(key, error) {
  // do something with the error
  this.state.errors[key] = error;
  this.setState({ errors: this.state.errors });
}

This is similar to what we've done in the upcoming iteration of Parse+React. We created our own error handling to produce the API we wanted. Errors are added to a private { name => error } map, and components have a top-level public method, queryErrors(), that returns a clone of the map if it's not empty, and null otherwise (allowing for a simple if (this.queryErrors()).

Of course, defining new reserved methods is also a tricky business; I won't pretend it isn't. But we need a way to implicitly or explicitly make errors available to the component when rendering.

sebmarkbage commented 9 years ago

@andrewimm The idea is to feed that into a generic error propagation system that bubbles error up the hierarchy until it is handled by an error boundary. https://github.com/facebook/react/issues/2928

This should also handle errors in methods throwing and recover gracefully. Like if the render() method throws. This is significantly more work and will take some time to implement properly but the idea was to unify error handling in this way.

kryptt commented 9 years ago

I would argue this should be left outside of react proper, and 1 or 2 key integration points should be coordinated with projects like react-async and react-nexus so this can be cleanly done on top of React proper....

pspeter3 commented 9 years ago

I agree, it seems like having a suggested way to do this is better than baking this into the framework itself

On Tue, Apr 21, 2015, 11:38 PM Rodolfo Hansen notifications@github.com wrote:

I would argue this should be left outside of react proper, and 1 or 2 key integration points should be coordinated with projects like react-async and react-nexus so this can be cleanly done on top of React proper....

— Reply to this email directly or view it on GitHub https://github.com/facebook/react/issues/3398#issuecomment-95048028.

nmn commented 9 years ago

Over the weekend, I built yet another Flux implementation, called Flexy. In this, check out the code for the store. It exposes a .getObservable method that conforms to the observable API, even though it really has no observables or other Reactive framework being used.

So, I would say that the API is easy enough to create with actual Observables.

That said, don't judge the code to harshly, it was done over a weekend for:

Side-note, a system like this and Flux actually makes server-side rendering less painful. Using a similar system as React-Nexus, we can initialize stores and pass them to a React app. We can then monitor the stores and dispatcher and keep re-rendering till no more actions are being fired (all required data is already in the stores).

sebmarkbage commented 9 years ago

I would argue that this is the smallest integration point to get the new semantics of stateless data subscriptions. What other integration points would you suggest? Not including async rendering which is a much more complex issue and deserves its own thread, and this hook could be used with async rendering regardless.

Everything else is already possible to implement on top of React on a per-component basis. Note that we're not going to do global injections of plugins since that breaks component reuse across environment so frameworks built on top of React should be contextual to individual components.

What hooks are we missing?

elierotenberg commented 9 years ago

Hey,

To be honest, while new hooks would've made implementation easier, we can certainly achieve sideways data loading without them, as we've demonstrated with react-async and react-nexus.

If anything, exposing and supporting maintaining React components instances lifecycle outside of a mounted React hierarchy would help. In react-nexus I use the internal instanciateReactComponent and call componentWillMount, componentWillUnmount, etc, myself, and I consider this approach brittle (what if instanciateReactComponents relies on internal invariants that change on the next version of React?).

As for the stateless approach, it seems to me that async data fetching is stateful, and thus storing the pending/completed/failed status in some components' state is relevant. When we use react-nexus in real-world apps, we have higher-order components which perform data fetching and inject their fetching state to their children components as props. The inner component is thus "stateless" (which is desirable), and the outer component is "stateful" (which is also desirable, eg. to display a loading spinner or a placeholder).

jquense commented 9 years ago

What other integration points would you suggest?

it seems to me that @ANDREYPOPP asked the right question. isn't the only thing we need to implement this in userland the lifecycle hook before render? that seems like the smallest minimal API change needed, the rest is setting and triggering forceUpdate appropriately as you change data based on whatever input stream/emitter/observable. Unless I'm missing something else special about it (entirely possible)?

nmn commented 9 years ago

I'm not getting drawn into the larger discussion here. But to answer @sebmarkbage I think one of the most important hook I would like is something that is only needed when not using real observables.

If the situation was a little more open, I think there should be hooks to replace the Observable-specific behaviour with custom hooks. This way we could use event-emitters, or CSP channels instead.