Created
January 22, 2022 13:38
-
-
Save flensrocker/c9c8e047c5186219208898e6ed369065 to your computer and use it in GitHub Desktop.
Simple reactive state management
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Observable, BehaviorSubject, Subject, Subscription, queueScheduler } from "rxjs"; | |
import { distinctUntilChanged, map, observeOn, scan, shareReplay, takeUntil, tap } from "rxjs/operators"; | |
export type BsStoreAction<TState> = (lastState: TState) => TState; | |
export type BsStoreDispatcher<TState> = (...actions: BsStoreAction<TState>[]) => void; | |
export type BsStoreSelector<TState> = <R>(projection: (state: TState) => R) => Observable<R>; | |
export type BsStoreEffect<TState> = Observable<BsStoreAction<TState>>; | |
export interface BsStore<TState> { | |
readonly stateChanges: Observable<TState>; | |
readonly dispatch: BsStoreDispatcher<TState>; | |
readonly select: BsStoreSelector<TState>; | |
readonly addEffect: (effect: BsStoreEffect<TState>) => Subscription; | |
} | |
export const identityAction = <TState>(lastState: TState): TState => lastState; | |
export const composeActions = <TState>(...actions: BsStoreAction<TState>[]): BsStoreAction<TState> => { | |
if (actions == null || actions.length === 0) { | |
return identityAction; | |
} | |
if (actions.length === 1) { | |
return actions[0]; | |
} | |
return (lastState: TState) => actions.reduce((state, action) => action(state), lastState); | |
}; | |
export class BsRootStore<TState> implements BsStore<TState> { | |
private readonly _actions = new BehaviorSubject<BsStoreAction<TState>>(identityAction); | |
public readonly stateChanges: Observable<TState>; | |
public readonly dispatch: BsStoreDispatcher<TState>; | |
public readonly select: BsStoreSelector<TState>; | |
public readonly addEffect: (effect: BsStoreEffect<TState>) => Subscription; | |
public logState = false; | |
constructor(initialState: TState, onDestroy: Observable<unknown>) { | |
this.stateChanges = this._actions.pipe( | |
observeOn(queueScheduler), | |
scan((lastState, action) => action(lastState), initialState), | |
tap(state => { | |
if (this.logState) { | |
console.log("state:", state); | |
} | |
}), | |
takeUntil(onDestroy), | |
shareReplay({ bufferSize: 1, refCount: true }) | |
); | |
this.dispatch = (...actions: BsStoreAction<TState>[]) => this._actions.next(composeActions(...actions)); | |
this.select = <R>(projection: (state: TState) => R): Observable<R> => this.stateChanges.pipe(map(projection), distinctUntilChanged()); | |
this.addEffect = (effect: BsStoreEffect<TState>): Subscription => | |
effect.pipe(takeUntil(onDestroy)).subscribe({ | |
next: this.dispatch, | |
error: err => console.error("unexpected error from effect:", err), | |
}); | |
// heartbeat | |
this.stateChanges.pipe(takeUntil(onDestroy)).subscribe(); | |
} | |
} | |
export const updateFeature = <TParentState, TFeature extends keyof TParentState>( | |
lastState: TParentState, | |
feature: TFeature, | |
action: BsStoreAction<TParentState[TFeature]> | |
): TParentState => { | |
if (lastState == null || feature == null || action == null) { | |
return lastState; | |
} | |
return { | |
...lastState, | |
[feature]: action(lastState[feature]), | |
}; | |
}; | |
const createFeatureDispatcher = <TParentState, TFeature extends keyof TParentState>( | |
store: BsStore<TParentState>, | |
feature: TFeature | |
): BsStoreDispatcher<TParentState[TFeature]> => { | |
return (...actions) => store.dispatch(s => updateFeature(s, feature, composeActions(...actions))); | |
}; | |
export class BsFeatureStore<TParentState, TFeature extends keyof TParentState> implements BsStore<TParentState[TFeature]> { | |
public readonly stateChanges: Observable<TParentState[TFeature]>; | |
public readonly dispatch: BsStoreDispatcher<TParentState[TFeature]>; | |
public readonly select: BsStoreSelector<TParentState[TFeature]>; | |
public readonly addEffect: (effect: BsStoreEffect<TParentState[TFeature]>) => Subscription; | |
constructor(parentStore: BsStore<TParentState>, feature: TFeature) { | |
const onDestroy = new Subject<true>(); | |
parentStore.stateChanges.subscribe({ | |
error: _ => { | |
onDestroy.next(true); | |
onDestroy.complete(); | |
}, | |
complete: () => { | |
onDestroy.next(true); | |
onDestroy.complete(); | |
}, | |
}); | |
this.stateChanges = parentStore.select(s => s[feature]); | |
this.dispatch = createFeatureDispatcher(parentStore, feature); | |
this.select = projection => this.stateChanges.pipe(map(projection), distinctUntilChanged()); | |
this.addEffect = effect => | |
effect.pipe(takeUntil(onDestroy)).subscribe({ | |
next: this.dispatch, | |
error: err => console.error("unexpected error from effect:", err), | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment