@equinor/fusion-observable
Version:
163 lines • 6.33 kB
JavaScript
import { useCallback, useLayoutEffect, useRef, useState, useSyncExternalStore, } from 'react';
import { BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
/**
* Type guard for observables exposing a synchronous `value` property.
*
* @template TType - Observable value type.
* @param subject - Observable candidate.
* @returns `true` when the observable exposes a `value` field.
*/
function hasStatefulValue(subject) {
return 'value' in subject;
}
/**
* Resolves the initial value for an observable snapshot.
*
* @template TType - Observable value type.
* @param subject - Observable source.
* @param initial - Optional initial value supplied by the caller.
* @returns A resolved initial value, or `undefined`.
*/
function resolveInitialValue(subject, initial) {
return (
/** caller-provided initial takes precedence */
initial ??
/** fall back to subject's synchronous current value if available */
(hasStatefulValue(subject) ? subject.value : undefined));
}
/**
* Compares React-style dependency lists with `Object.is` semantics.
*
* @param previous - Previous dependency list, or `null` before the first subscription.
* @param next - Next dependency list to compare.
* @returns `true` when the dependency lists are equivalent.
*/
function areDependenciesEqual(previous, next) {
return (previous !== null &&
previous.length === next.length &&
previous.every((value, index) => Object.is(value, next[index])));
}
/**
* Compares observable state snapshots by the fields exposed from the hook.
*
* @template TType - Observable value type.
* @template TError - Observable error type.
* @param previous - Previous observable state snapshot.
* @param next - Next observable state snapshot.
* @returns `true` when the hook state is unchanged.
*/
function areObservableStateSnapshotsEqual(previous, next) {
return (Object.is(previous.value, next.value) &&
Object.is(previous.error, next.error) &&
previous.complete === next.complete);
}
/**
* Subscribes to an observable and returns its latest emitted value as React
* state. The component re-renders on every emission, error, or completion.
*
* Internally wraps `useSyncExternalStore` to guarantee tear-down safety and
* concurrent-mode compatibility. The subscription is created once per unique
* `subject` reference and cleaned up automatically when the component unmounts
* or the subject changes.
*
* By default, the subscription follows the `subject` reference, matching the
* original hook contract: callers should pass a stable observable or expect a
* resubscription when the observable reference changes. When an observable is
* intentionally recreated during render, pass `opt.deps` to describe the real
* lifecycle of the subscription. For example, `deps: []` subscribes to the
* first subject for the lifetime of the component, while `deps: [id]`
* resubscribes only when `id` changes.
*
* `opt.initial` and `opt.teardown` do **not** need to be memoized and do not
* trigger a subscription recreation on their own.
*
* @param subject - The observable to subscribe to. Must have a stable reference.
* @param opt - Optional initial value and teardown callback.
* @returns An object with `value`, `error`, and `complete` reflecting the latest observable state.
*
* @example
* ```tsx
* // Stable subject — created outside the component or wrapped in useMemo.
* const subject = useMemo(() => new BehaviorSubject(0), []);
*
* function Counter() {
* const { value, error, complete } = useObservableState(subject, { initial: 0 });
*
* if (error) return <p>Error: {String(error)}</p>;
* if (complete) return <p>Done: {value}</p>;
* return <p>Count: {value}</p>;
* }
* ```
*
* @example
* ```tsx
* // Cannot memoize the observable instance? Describe the real subscription lifetime.
* function Widget({ stream, widgetId }: { stream: Observable<number>; widgetId: string }) {
* const { value } = useObservableState(stream, { deps: [widgetId] });
* return <p>{value}</p>;
* }
* ```
*
* @example
* ```tsx
* // Subscribe to the first observable for the component lifetime.
* function StaticWidget({ stream }: { stream: Observable<number> }) {
* const { value } = useObservableState(stream, { deps: [] });
* return <p>{value}</p>;
* }
* ```
*/
export function useObservableState(subject, opt) {
const { initial, teardown, deps } = opt ?? {};
const subscribedRef = useRef(null);
const depsRef = useRef(null);
const teardownRef = useRef(teardown);
teardownRef.current = teardown;
const [stream] = useState(() => new BehaviorSubject({
value: resolveInitialValue(subject, initial),
error: null,
complete: false,
}));
useLayoutEffect(() => {
const subscriptionDeps = deps ?? [subject];
if (areDependenciesEqual(depsRef.current, subscriptionDeps)) {
return;
}
depsRef.current = subscriptionDeps;
if (subscribedRef.current) {
subscribedRef.current.unsubscribe();
}
subscribedRef.current = subject.subscribe({
next: (value) => {
stream.next({ value, error: null, complete: false });
},
error: (error) => {
stream.next({ value: stream.value?.value, error, complete: false });
},
complete: () => {
stream.next({
value: stream.value?.value,
error: stream.value?.error ?? null,
complete: true,
});
},
});
subscribedRef.current.add(teardownRef.current);
});
useLayoutEffect(() => {
return () => {
subscribedRef.current?.unsubscribe();
};
}, []);
const onStoreChange = useCallback((cb) => {
const subscription = stream
.pipe(distinctUntilChanged(areObservableStateSnapshotsEqual))
.subscribe(cb);
return () => subscription.unsubscribe();
}, [stream]);
const getSnapshot = useCallback(() => stream.value, [stream]);
return useSyncExternalStore(onStoreChange, getSnapshot);
}
export default useObservableState;
//# sourceMappingURL=useObservableState.js.map