@equinor/fusion-observable
Version:
262 lines (238 loc) • 7.62 kB
text/typescript
import type { Reducer } from 'react';
import {
asyncScheduler,
BehaviorSubject,
EMPTY,
from,
Observable,
Subject,
type Subscription,
} from 'rxjs';
import {
catchError,
distinctUntilChanged,
filter,
map,
mergeMap,
observeOn,
scan,
} from 'rxjs/operators';
import { filterAction } from './operators';
import type { Action, ActionType, ExtractAction } from './types/actions';
import type { Flow, Effect } from './types/flow';
import type { ReducerWithInitialState } from './types/reducers';
/**
* A specialized Observable that maintains internal state, which can be mutated by dispatching actions.
* Actions are processed sequentially by a reducer function to produce new state values.
* This class extends from Observable to allow subscribers to react to state changes over time.
*/
export class FlowSubject<S, A extends Action = Action> extends Observable<S> {
/**
* The internal subject for actions.
* @private
*/
#action = new Subject<A>();
/**
* The internal behavior subject for state management.
* @private
*/
#state: BehaviorSubject<S>;
/**
* Observable stream of actions dispatched to the subject.
*/
get action$(): Observable<A> {
return this.#action.asObservable();
}
/**
* The current value of state.
*/
get value(): S {
return this.#state.value;
}
/**
* Flag to indicate if the observable is closed.
*/
get closed(): boolean {
return this.#state.closed || this.#action.closed;
}
/**
* Creates a new instance of the FlowSubject class with an initial state reducer.
* @param reducer A reducer with an initial state or a reducer function.
*/
constructor(reducer: ReducerWithInitialState<S, A>);
/**
* Create a new instance of the FlowSubject class with a reducer function and initial state.
* @param reducer state reducer
* @param initialState initial state
*/
constructor(reducer: Reducer<S, A>, initialState: S);
/**
* 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: ReducerWithInitialState<S, A> | Reducer<S, A>, initialState?: S) {
super((subscriber) => {
return this.#state.subscribe(subscriber);
});
const initial = 'getInitialState' in reducer ? reducer.getInitialState() : (initialState as S);
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.
*/
public reset: VoidFunction;
/**
* Dispatches an action to the subject.
*
* @param action The action to dispatch.
*/
public next(action: A): void {
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.
*/
public select<T>(
selector: (state: S) => T,
comparator?: (previous: T, current: T) => boolean,
): Observable<T> {
return this.#state.pipe(map(selector), distinctUntilChanged(comparator));
}
/**
* Adds an effect that listens for actions and performs side effects.
*
* @param fn The effect function to execute.
* @returns A subscription to the effect.
*/
public addEffect(fn: Effect<A, S>): Subscription;
/**
* Adds an effect that listens for a specific action type and performs side effects.
*
* @param actionType The type of action to listen for.
* @param cb The effect function to execute when the action is dispatched.
* @returns A subscription to the effect.
*/
public addEffect<TType extends ActionType<A>>(
actionType: TType,
cb: Effect<ExtractAction<A, NoInfer<TType>>, S>,
): Subscription;
/**
* Adds an effect that listens for an array of action types and performs side effects.
*
* @param actionType The array of action types to listen for.
* @param cb The effect function to execute when one of the actions is dispatched.
* @returns A subscription to the effect.
*/
public addEffect<TType extends ActionType<A>>(
actionType: Array<TType>,
cb: Effect<ExtractAction<A, NoInfer<TType>>, S>,
): Subscription;
/**
* 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.
*/
public addEffect<TType extends ActionType<A>>(
actionTypeOrFn: TType | Array<TType> | Effect<A, S>,
fn?: Effect<ExtractAction<A, NoInfer<TType>>, S>,
): Subscription {
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) as Effect<A, S>;
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 is A => !!x),
observeOn(asyncScheduler),
)
.subscribe(this.#action);
}
/**
* Deprecated. Use `addFlow` instead.
*
* @deprecated use `addFlow`
*
* @param fn The flow function to execute.
* @returns A subscription to the flow.
*/
public addEpic(fn: Flow<A, S>): Subscription {
return this.addFlow(fn);
}
/**
* Adds a flow that listens for actions and performs side effects.
*
* @param fn The flow function to execute.
* @returns A subscription to the flow.
*/
public addFlow(fn: Flow<A, S>): Subscription {
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);
}
/**
* Unsubscribes from actions and removes subscribers.
*/
public unsubscribe() {
this.#action.unsubscribe();
this.#state.unsubscribe();
}
/**
* Finalizes the subject and completes observers.
*/
public complete() {
this.#action.complete();
this.#state.complete();
}
/**
* Clones the subject to a simple observable.
*
* @returns An observable of the state.
*/
public asObservable() {
return this.#state.asObservable();
}
}
export default FlowSubject;