re-rxjs / react-rxjs

React bindings for RxJS
https://react-rxjs.org
MIT License
542 stars 17 forks source link

Subscribe incorrectly rendering fallback on initial mount #300

Closed pkpbynum closed 1 year ago

pkpbynum commented 1 year ago

Hi there, great library! I'm having an issue with the rendering lifecycle of Subscribe. In the core concepts you mention a drawback of shareReplay is that it keeps the last value around in memory--that is actually my desired behavior. e.g. if I navigate from page A -> B -> A, I'd like to hydrate A with the old data immediately and refetch new data in the background. I believe this is the model used by many other data fetching libraries (e.g. react query).

To work around this, I just piped a shareReplay into my observable, but Subscribe still isn't picking up on that latest value. On initial mount, it still renders the fallback component for a split second. A reproducible example is here.

Is there a way I can achieve this? Or is this a bug with Subscribe's initial state?

josepot commented 1 year ago

Is there a way I can achieve this? Or is this a bug with Subscribe's initial state?

Of course! Just keep a subscription always open, and then it will always have the latest value in memory. I.e: subscribe to the observable, right after defining it.

voliva commented 1 year ago

<Subscribe> always guarantees that it will have subscribed to the source observables before any of its children is rendered. This is something that's needed to prevent lingering subscriptions, specially with React with concurrent mode.

As mentioned in the docs:

It will subscribe to all the observables used by its children before they get mounted, and will unsubscribe from all of them once it unmounts. IMPORTANT: This Component first mounts itself rendering null, subscribes to source$ and then it renders its children.

The way it does this is by first rendering nothing (or the fallback) on initial render, subscribing to the sources, and then rendering the children passed to it.

Not sure if it needs more clarification on the docs, or maybe an explanation on the actual implications of this.

If you want to avoid this double-render you'll have to manage the subscriptions further up: Don't use <Subscribe> at that level, instead declare an observable that will subscribe to all the streams your components need (a merge usually does the trick) when they are needed to become active, and subscribe to this observable further up on your tree.

pkpbynum commented 1 year ago

I think I understand the points you both are making. My point is that an observable with shareReplay should have it's last (aka "stale") value available synchronously in the initial mount, and the fallback should never be rendered. It should render the stale value until the next value is observed. This is the stale-while-revalidate paradigm. It seems to me that react-rxjs is doing nothing-while-revalidate. If this isn't the goal of this library I understand.

To @voliva's point, I'll try to raise my Subscribe up my react tree, but I think this isn't quite the behavior I want. If I do that, then the subscription's lifetime is no longer tied to where the data is needed in the react tree. Also--if I understand correctly--if there's an error, I'd need to remount that entire tree to start the subscription again.

josepot commented 1 year ago

My point is that an observable with shareReplay should have it's last (aka "stale") value available synchronously in the initial mount

Not necessarily, no. Just because an Observable has been enhanced with shareReplay, that doesn't guarantee that the Observable will emit synchronously. It will only emit synchronously if:

1) It has previously emitted a value (while there were at least a one listener). 2) After having emitted that first value at least one listener stayed subscribed by the time that the new subscription takes place.

However, the first value can come at any point in time, if it ever comes. So, yeah: just because an Observable has been created through the shareReplay enhancer, that doesn't mean that the observable will always emit synchronously.

My point is that an observable with shareReplay should have it's last (aka "stale") value available synchronously in the initial mount, and the fallback should never be rendered

Even if that was true (which it isn't), the only way to access that value is through a subscription, and since subscribing to an Observable it's a side-effect, it's something that can't be done on the render phase of the component. It can only be done after the component was mounted. That's b/c due to React concurrent-mode: a render function can be called on a component that will never be mounted (and thus never unmounted), and if that happen then we would be creating unnecessary side-effects and stale subscriptions. Therefore, there is no way to actually access the "current" value of a shareReplay Observable during the render function. That's why in this library we have had to come up with these StateObservables and other shenanigans, so that we can work around these short-comings (or trade offs, depends on who you ask) of React.

This is the stale-while-revalidate paradigm. It seems to me that react-rxjs is doing nothing-while-revalidate. If this isn't the goal of this library I understand.

We've most definitely failed at explaining the goals and the trade off of our library. Not your fault, of course. We should really improve our docs and explain these things better.

voliva commented 1 year ago

To @voliva's point, I'll try to raise my Subscribe up my react tree, but I think this isn't quite the behavior I want. If I do that, then the subscription's lifetime is no longer tied to where the data is needed in the react tree. Also--if I understand correctly--if there's an error, I'd need to remount that entire tree to start the subscription again.

In theory it's posible to do the same behaviour you want, without relying on React's render cycle, but it's more tedious than just using <Subscribe> on the place you wanted.

<Subscribe> has this limitation that it will do that double render. In some cases that's not bad, but on others it is annoying... and the solution is to not use Subscribe there. You can move it to the nearest place where it doesnt get unmounted/remounted, and declare how that subscription is managed passing in the source$ prop.

I've modified your sandbox to better show you what I mean: https://codesandbox.io/s/elegant-feather-fw2vjs?file=/src/App.tsx On these cases it gets more tedious, as React-RxJS currently can't really circumvent the limitations of React when it comes to execution of side-effects (such as creating a subscription).

If you need error handling, this is something you can also add to your custom subscription management. For this, retry({ delay: () => anotherObservable$ }) is usually very useful: It prevents the error from being propagated into the <Subscribe>, and re-subscribes to the observable when you declare it to.

Edit --- I forgot to mention that there's another alternative which sometimes can also work (in your sandbox it does). You can remove the fallback from Subscribe and just add a suspense boundary inside:

  const content = toggle ? null : (
    <Subscribe>
      <Suspense fallback="wait...">
        <TodoList />
      </Suspense>
    </Subscribe>
  );

Subscribe will still do the double render, but in this case it will render nothing on mount, so the user won't see the fallback "Wait..." for that split second. This is just to say most of the time this behaviour is not noticeable, but there are some cases where it does matter. For those ones, it's better to just declare how your subscriptions are managed through another observable.