crimx/observable-hooks

useObservableEagerState missing emit

Paitum opened this issue · 13 comments

I have a Preact app that is using observables for state and use observable-hooks library to update components.

The problem I am facing is with useObservableEagerState missing important emits. I have a specific scenario involving a mouse-over component and backend call-- both of which are asynchronous to the lifecycle, that renders the component with the INITIAL value, but then misses the backend call's data from the second emit of the observable.

I have tracked this down to this line:
https://github.com/crimx/observable-hooks/blob/master/packages/observable-hooks/src/use-observable-eager-state.ts#L65

I can see that my observable is emitting the data within this useEffect-subscription, but it is being ignored because isAsyncEmissionRef.current is false.

I understand that this logic is there to protect against triggering extra initial re-rendering, but if it is "swallowing" a value, then that isn't desirable either.

Shouldn't this function guarantee that the synchronous values not be ignored? Something along the lines of:

  useEffect(() => {
    ...
    const NO_VALUE = Symbol("NO_VALUE");
    let synchronousValue: TState | typeof NO_VALUE = NO_VALUE;
    const subscription = input$.subscribe({
      next: value => {
        ...
        if (isAsyncEmissionRef.current) {
          // ignore synchronous value
          // prevent initial re-rendering
          setState(value)
        } else {
          synchronousValue = value;
        }
      },
      error: error => {
        ...
      }
    })

    if(synchronousValue !== NO_VALUE) {
      setState(synchronousValue);
    }

    isAsyncEmissionRef.current = true

    ...
crimx commented

What does your observable look like? As the docs stated, useObservableEagerState should only be used on hot or pure observables. Use useObservableState if the observable is cold and with side-effects.

They are all BehaviorSubjects. But are being provided as Observables. Here is an example.

  const alerts$ = new BehaviorSubject<AlertRecords | null>(null);
  const alertsObs$ = alerts$.pipe(distinctUntilChanged());

accessible publically ONLY via an accessor that returns a SINGLE NEVER CHANGING observable instance:

    get alerts$(): Observable<AlertRecords | null> {
      return alertsObs$;
    },

Let me try to state the sequence of events I believe is happening:

  1. My service is created and exposes an observable, e.g. alerts$ (BehaviorSubject initialized to null)
  2. My functional component renders and has an useObservableEagerState call, e.g. useObservableEagerState(useAlertsService().alerts$)
  3. It synchronously evaluates to null. NOTE the useObservableEagerState's useEffect has not executed.
  4. The service emits a new value, e.g. alerts$.next([...])
  5. The useObservableEagerState's useEffect executes but ignores all values
  6. The functional component does NOT re-render with the latest results.
crimx commented

Don't know how the alerts$ was implemented. Could you provide a minimum example that reproduce the issue?

  1. The service emits a new value, e.g. alerts$.next([...])
  2. The useObservableEagerState's useEffect executes but ignores all values

Base on your description, if "useEffect executes but ignores all values" then the value should be from synchronous emission, which should be captured by the preceding subscription in useState.

state$
.subscribe({
next: value => {
didSyncEmit.current = true
state = value
},
error: error => {
errorRef.current = error
}
})
.unsubscribe()

If not, then it means the alerts$ may produce agnostic side-effects. You should use useObservableState instead.

crimx commented

useObservableState is also BehaviorSubject-friendly thanks to #33 .

I haven't gotten around to creating a standalone example-- but in lieu, let me respond to some points.

Don't know how the alerts$ was implemented.

It is the BehaviorSubject implementation from rxjs. https://github.com/ReactiveX/rxjs/blob/master/src/internal/BehaviorSubject.ts

Base on your description, if "useEffect executes but ignores all values" then the value should be from synchronous emission, which should be captured by the preceding subscription in useState.

The useEffect callback is executed later, after the useObservableEagerState has completed. As I explained in my sequence of events comment-- after the useObservableEagerState has completed, the Subject has emitted a new value, and the input$.subscribe subscription is "skipping" it based on the isAsyncEmissionRef.current still being false.

The point is you can't guarantee that the value hasn't changed between the time the useObservableEagerState returns and the useEffect callback executed since they are contractually asynchronous.

If not, then it means the alerts$ may produce agnostic side-effects.

alert$ is just an observable. I'm not sure I understand what "agnostic side-effects" have to do with the observable design pattern. If you are arguing that I am misusing observables within the React hooks infrastructure, then we could explore that.

On one hand, you could argue that functional components shouldn't be creating "side effects" during their execution. But on the other hand, the point of using Observables is to manage asynchronous events or side-effects. Observables present a asynchronous-like contract, but its actual implementation is largely (entirely?) synchronous.

useObservableState is also BehaviorSubject-friendly thanks to #33 .

The problem with useObservableState is that it always defaults to its initial value, which is often null even though the observable already has the value. The component renders but then IMMEDIATELY re-renders with the current value.

I hope to get you a reproducible example sometime!

crimx commented

The useEffect callback is executed later, after the useObservableEagerState has completed. As I explained in my sequence of events comment-- after the useObservableEagerState has completed, the Subject has emitted a new value, and the input$.subscribe subscription is "skipping" it based on the isAsyncEmissionRef.current still being false.

The point is you can't guarantee that the value hasn't changed between the time the useObservableEagerState returns and the useEffect callback executed since they are contractually asynchronous.

If isAsyncEmissionRef.current is still false, then it means it is not asynchronous emission.

const subscription = input$.subscribe({
next: value => {
if (input$ !== state$Ref.current) {
// stale observable
return
}
if (isAsyncEmissionRef.current) {
// ignore synchronous value
// prevent initial re-rendering
setState(value)
}
},
error: error => {
if (input$ !== state$Ref.current) {
// stale observable
return
}
errorRef.current = error
forceUpdate()
}
})
isAsyncEmissionRef.current = true

isAsyncEmissionRef.current is set to true synchronously right after the second subcription where useObservableEagerState gets asynchronous values.

Synchronous values has been captured from the first subscription, that's why we need to skip values from the second one.

const [state, setState] = useState<TState>(() => {
let state: TState
state$
.subscribe({
next: value => {
didSyncEmit.current = true
state = value
},
error: error => {
errorRef.current = error
}
})
.unsubscribe()
return state!
})

alert$ is just an observable. I'm not sure I understand what "agnostic side-effects" have to do with the observable design pattern. If you are arguing that I am misusing observables within the React hooks infrastructure, then we could explore that.

This is not about the observable design pattern but how useObservableEagerState is implemented. As the warning in the docs stated, since useObservableEagerState will subscribe the observable more than once, if the observable produces agnostic values on each subscription then there is no way to get a correct initial value safely.

On one hand, you could argue that functional components shouldn't be creating "side effects" during their execution. But on the other hand, the point of using Observables is to manage asynchronous events or side-effects. Observables present a asynchronous-like contract, but its actual implementation is largely (entirely?) synchronous.

useObservableState works on any type of observable. That's the limitation of useObservableEagerState.

The problem with useObservableState is that it always defaults to its initial value, which is often null even though the observable already has the value. The component renders but then IMMEDIATELY re-renders with the current value.

Unfortunately because React introduces concurrent mode, we cannot perform synchronous side effects safely. The obserjvable subscription has to happen asynchronously. For BehaviorSubject, useObservableState will take it's value as initial value automatically which will prevent an extra browser paint on the extra re-rendering(just vdom diff).

Suspense is the official attempt to prevent extra re-rendering on side effects.

I tried to recreate my problem, but am having a hard time understanding a certain behavior. Perhaps you can explain it to me.

Clone https://github.com/Paitum/eager-app then run yarn install, yarn start, and show the console in the browser.

To reproduce the behavior, I am purposely emitting a new value immediately after the useObservableEagerState.

function App() {
    const value = useValue();
    serviceInstance.fetchData();

NOTE: I made a copy to make it easier to work with.

I can't reproduce the problem, because the state is somehow being set, and I don't understand where. The only places where state are set, are in the useState result and within the next: of the subscription inside the useEffect.

So how is the state being set to the newly-emitted value of "Final Value" here: https://github.com/Paitum/eager-app/blob/main/src/use-observable-eager-state.js#L56 ?

crimx commented

So how is the state being set to the newly-emitted value of "Final Value" here

This is because you had strict mode enabled and performed side effects during React rendering.

React is hacking the console.log so that you don't see the extra loggings🤕.

Thanks. I just used the react create app and it added that. Although Strict must be doing more than hacking console.log, since the behavior changed as well.

Ok, so now when I run it without StrictMode, I see the problem that I was trying to reproduce. The result is "The value is Initial Value" gets rendered.

Now we can have a conversation about "correctness". Let me explain my architecture first.

My application uses a "Service Context", that can provide any component with any service instance via a hook, like useService("alertsService"). Each service provides an API and encapsulates its behavior, some of which may be synchronous and some of which may be asynchronous. Each service exposes data synchronously via normal accessors or "asynchronously" via RXJS observables. The observable-hooks library provides us with the means to easily access those ever-changing-values in the UI components.

A pattern we setup is for the service to automatically fetch data ONLY WHEN an observable accessor is requested. So, the alertsService has an alerts$ accessor that returns the observable for the AlertsData type. Internally, the service knows if the data has been fetched or not, and goes and fetches it on first request.

Any given component SHOULDN'T know whether a service function or accessor will have a side-effect. That's the point of using observables.

And each service shouldn't need to add any unnecessary logic to support UI constraints-- like making something asynchronous ala setTimeout(() => ..., 0).

So that's why I'm wondering why the implementation of useObservableEagerState ignores new values? What is the harm in storing the value inside the useEffect and setState(storedValue) alongside the isAsyncEmissionRef.current = true;?

Here's the architecture visually.

image

observable-hooks is what makes it easy for UI to get updated when any observable emits... except when it ignores the value.

crimx commented

Thanks for the code!

Although Strict must be doing more than hacking console.log, since the behavior changed as well.

Yes, hooks are invoked twice in strict mode. They try to hide that with hacking console.log to reduce confusion(which causes more confusions in this case).

A pattern we setup is for the service to automatically fetch data ONLY WHEN an observable accessor is requested. So, the alertsService has an alerts$ accessor that returns the observable for the AlertsData type. Internally, the service knows if the data has been fetched or not, and goes and fetches it on first request.

The pattern you described is very much similar to React's Suspense pattern IMO. With observable-hooks you can easily implement this kind of Stale-While-Revalidate pattern.

A more "Rx" way to achieve this is to make a cold observable that connects to the BehaviorSubject for result-check or triggering side effects.

const fetchData = () => {
  console.log('start fetching...')
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('Final Value')
    }, 2000)
  })
}

function Service() {
  const subject$ = new BehaviorSubject('Initial Value');
  let fetched = false
  
  const observable$ = new Observable(subscriber => {
    if (!fetched) {
      fetched = true
      fetchData()
         .then(value => subject$.next(value))
         .catch(error => subject$.error(error))
    }
    return subject$.subscribe(subscriber)
  });
  
  return {
    get value$() {
      return observable$;
    }
  }
}

const serviceInstance = new Service()

serviceInstance.value$.subscribe(v => console.log('subscription1', v))
serviceInstance.value$.subscribe(v => console.log('subscription2', v))

So that's why I'm wondering why the implementation of useObservableEagerState ignores new values? What is the harm in storing the value inside the useEffect and setState(storedValue) alongside the isAsyncEmissionRef.current = true;?

useObservableEagerState should not ignore new values. useObservableState will triggered an extra React rendering if the observable emits synchronous values. This is because side-effects on React rendering is not safe. An observable subscription is considered a side effect because useObservableState accepts all kinds of observables.

useObservableEagerState is an optimization for observables that does not perform side-effects on subscription, so that we can make a synchronous subscription on useState to get the initial value. And since we already have the initial value, it is safe the we ignore the synchronous emissions on the second subscription to prevent extra re-rendering.

Back to the code, the serviceInstance.fetchData(); triggers an emission after the first subscription of useObservableState where it collects initial values and before the second subscription, it is incorrectly dumped by the useObservableEagerState. It should make an equality check before dumping the value.

crimx commented

observable-hooks@4.1.1 published which fixes this issue.