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
...
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:
- My service is created and exposes an observable, e.g. alerts$ (BehaviorSubject initialized to null)
- My functional component renders and has an
useObservableEagerState
call, e.g.useObservableEagerState(useAlertsService().alerts$)
- It synchronously evaluates to
null
. NOTE theuseObservableEagerState
'suseEffect
has not executed. - The service emits a new value, e.g.
alerts$.next([...])
- The
useObservableEagerState
'suseEffect
executes but ignores all values - The functional component does NOT re-render with the latest results.
Don't know how the alerts$
was implemented. Could you provide a minimum example that reproduce the issue?
- The service emits a new value, e.g.
alerts$.next([...])
- The
useObservableEagerState
'suseEffect
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
.
If not, then it means the alerts$
may produce agnostic side-effects. You should use useObservableState
instead.
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!
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.
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.
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 ?
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;
?
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.
observable-hooks@4.1.1 published which fixes this issue.