@equinor/fusion-observable
Version:
203 lines • 7.33 kB
JavaScript
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