UNPKG

@equinor/fusion-observable

Version:
203 lines 7.33 kB
import { asyncScheduler, BehaviorSubject, EMPTY, from, Observable, Subject, } from 'rxjs'; import { catchError, distinctUntilChanged, filter, map, mergeMap, observeOn, scan, } from 'rxjs/operators'; import { filterAction } from './operators'; /** * A specialized Observable that maintains internal state mutated by dispatching actions. * * Actions are processed sequentially by a reducer function to produce new state values. * Extends `Observable` so subscribers react to state changes over time. * * Inspired by Redux-style state management but built on top of RxJS, `FlowSubject` * combines a `BehaviorSubject` for state with a `Subject` for actions, providing * a reactive, observable state container. * * @template S - The state type managed by this subject. * @template A - The action type dispatched to this subject. * * @example * ```ts * import { FlowSubject, createReducer } from '@equinor/fusion-observable'; * * type State = { count: number }; * type Action = { type: 'increment' } | { type: 'decrement' }; * * const reducer = createReducer<State, Action>({ count: 0 }, (builder) => * builder * .addCase('increment', (state) => { state.count += 1; }) * .addCase('decrement', (state) => { state.count -= 1; }), * ); * * const subject = new FlowSubject(reducer); * subject.subscribe((state) => console.log(state.count)); * subject.next({ type: 'increment' }); // logs: 1 * ``` */ export class FlowSubject extends Observable { /** * The internal subject for actions. * @private */ #action = new Subject(); /** * The internal behavior subject for state management. * @private */ #state; /** * Observable stream of actions dispatched to the subject. */ get action$() { return this.#action.asObservable(); } /** * The current value of state. */ get value() { return this.#state.value; } /** * Flag to indicate if the observable is closed. */ get closed() { return this.#state.closed || this.#action.closed; } /** * Initializes a new instance of the FlowSubject class. * * @param reducer A reducer with an initial state or a reducer function. * @param initialState The initial state of the subject. */ constructor(reducer, initialState) { super((subscriber) => { return this.#state.subscribe(subscriber); }); const initial = 'getInitialState' in reducer ? reducer.getInitialState() : initialState; this.#state = new BehaviorSubject(initial); this.#action.pipe(scan(reducer, initial), distinctUntilChanged()).subscribe(this.#state); this.reset = () => this.#state.next(initial); } /** * Resets the state to the initial value. */ reset; /** * Dispatches an action to the subject. * * @param action The action to dispatch. */ next(action) { this.#action.next(action); } /** * Selects a derived state from the observable state and emits only when the selected state changes. * * @param selector - A function that takes the current state and returns a derived state. * @param comparator - An optional function that compares the previous and current derived states to determine if a change has occurred. * @returns An observable that emits the derived state whenever it changes. */ select(selector, comparator) { return this.#state.pipe(map(selector), distinctUntilChanged(comparator)); } /** * Adds an effect that listens for actions and performs side effects. * * @param actionTypeOrFn The type of action to listen for, an array of action types, or the effect function itself. * @param fn The effect function to execute when the action is dispatched, if the first parameter is an action type. * @returns A subscription to the effect. */ addEffect(actionTypeOrFn, fn) { const action$ = typeof actionTypeOrFn === 'function' ? this.action$ : Array.isArray(actionTypeOrFn) ? this.action$.pipe(filterAction(...actionTypeOrFn)) : this.action$.pipe(filterAction(actionTypeOrFn)); const mapper = (fn ? fn : actionTypeOrFn); return action$ .pipe(mergeMap((action) => from(new Promise((resolve, reject) => { try { resolve(mapper(action, this.value)); } catch (err) { reject(err); } })).pipe(catchError((err) => { console.warn('unhandled effect', err); return EMPTY; }))), filter((x) => !!x), observeOn(asyncScheduler)) .subscribe(this.#action); } /** * @deprecated Use {@link FlowSubject.addFlow} instead. * * @param fn - The flow function to execute. * @returns A subscription to the flow. */ addEpic(fn) { return this.addFlow(fn); } /** * Adds a flow (epic-style) that transforms the action stream and optionally * reads state, then emits new actions back into the subject. * * Flows receive the full `action$` stream and the state observable, and must * return an `Observable<A>`. This is useful for complex async orchestration. * * @param fn - The flow function that receives `(action$, state$)` and returns an action observable. * @returns A subscription to the flow. Unsubscribe to remove the flow. * @throws {TypeError} If the flow function does not return an observable. * * @example * ```ts * subject.addFlow((action$) => * action$.pipe( * filterAction('fetchData'), * switchMap((action) => * fetchApi(action.payload).pipe( * map((data) => ({ type: 'fetchSuccess', payload: data })), * ), * ), * ), * ); * ``` */ addFlow(fn) { const epic$ = fn(this.action$, this); if (!epic$) { throw new TypeError(`addEpic: one of the provided effects "${fn.name || '<anonymous>'}" does not return a stream. Double check you're not missing a return statement!`); } return epic$ .pipe(catchError((err) => { console.trace('unhandled exception, epic closed!', err); return EMPTY; }), observeOn(asyncScheduler)) .subscribe(this.#action); } /** * Tears down the subject by unsubscribing both the action and state subjects. * * After calling this method, the subject is no longer usable. */ unsubscribe() { this.#action.unsubscribe(); this.#state.unsubscribe(); } /** * Completes both the action and state subjects, signalling to all * subscribers that no further values will be emitted. */ complete() { this.#action.complete(); this.#state.complete(); } /** * Returns a plain `Observable` of the state, hiding the subject's * `next`, `complete`, and other mutation methods. * * @returns A read-only observable of the state. */ asObservable() { return this.#state.asObservable(); } } export default FlowSubject; //# sourceMappingURL=FlowSubject.js.map