UNPKG

@equinor/fusion-observable

Version:
163 lines 6.33 kB
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