Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX
Для управления состоянием приложения я как правило применяю Redux. Но не всегда есть необходимость в использовании модели Action\Reducer, хотя бы из-за трудозатратности ее применения для написания простейшего функционала. Возьмем в качестве примера обычный счетчик. На выходе хотелось получить простое и практичное решение, которое позволит описать модель состояния и пару методов его меняющие, наподобие такого:
state = {value: 0} increase() { state.value += 1 } decrease() { state.value -= 1 }
Сходу кажется, что такое решение может обеспечить MobX, так почему бы им и не воспользоваться? Поработав с MobX некоторое время, для себя пришел к выводу, что лично мне проще оперировать последовательностью иммутабельных состояний (наподобие Redux), чем логикой мутабельного состояния (наподобие MobX), да и его внутреннюю кухню я бы не назвал простой.
В общем, захотелось найти простое решение для управления состоянием, в основе которого лежала бы иммутабельность, с возможностью применять его в Angular\React и реализованное на TypeScript. Беглый обзор на просторах github подходящего решения не выдал, поэтому возьмем RxJS/Immer и попробуем сделать свое.
Используем RxJS
За основу возьмем BehaviorSubjeсt
, который будет моделировать поток изменений состояния {value: 0} -> {value: 1} -> {value: 2}
и у которого также есть метод getValue
, с помощью которого можно получить текущее состояние. Если сравнить API BehaviorSubject
со стором Redux
getValue() / getState() // получить текущее состояние
subscribe() / subscribe() // подписаться на оповещение о новом состоянии
next(value) / dispatch(action), replaceReducer(nextReducer) // поменять состояние на новое
можно заметить, что они довольно похожи. Основное отличие как раз в том, что у BehaviorSubject
вместо Action/Reducer
новое состояние можно задать методом next()
.
Для упомянутого выше примера со счетчиком, реализация могла бы выглядеть так:
CounterService V1
class CounterServiceV1 { state = new BehaviorSubject({value: 0}) increase() { this.state.next({value: this.state.value.value + 1}) } decrease() { this.state.next({value: this.state.value.value - 1}) } }
В глаза бросается избыточность повторений из this.state.next
и громоздкость изменения состояния. Это сильно отличается от желаемого результата state.value += 1
Добавляем Immer
Для упрощения изменения иммутабельного состояния воспользуемся библиотекой Immer. Immer позволяет создавать новое иммутабельное состояние за счет мутации текущего. Работает он таким образом:
const state = {value: 0} // создаем драфт текущего состояния const draft = createDraft(state) // производим с ним мутабельные изменения draft.value += 1 // получаем новое состояние const newState = finishDraft(draft)
Связка BehaviorSubject и Immer
Обернем использование BehaviorSubject
и Immer в свой собственный класс и назовем его RxState
:
class RxState<TState> { private subject$: BehaviorSubject<TState> private currentDraft?: Draft<TState> get state() { return this.subject$.value } get state$() { return this.subject$ } get draft(): Draft<TState> { if (this.currentDraft !== undefined) { return this.currentDraft } throw new Error("draft doesn't exists") } constructor(readonly initialState: TState) { this.subject$ = new BehaviorSubject(initialState) } public updateState(recipe: (draft: Draft<TState>) => void) { let topLevelUpdate = false // необходим при вызове вложенных updateState if (!this.currentDraft) { this.currentDraft = createDraft(this.state) topLevelUpdate = true } recipe(this.currentDraft) if (!topLevelUpdate) { return } const newState = finishDraft(this.currentDraft, () => {}) as TState this.currentDraft = undefined if (newState !== this.state) { this.subject$.next(newState) } } }
Используя RxState
, перепишем CounterService
:
CounterService V2
class CounterServiceV2 { state = new RxState({value: 0}) increase() { this.state.updateState(draft => { draft.value += 1 }) } decrease() { this.state.updateState(draft => { draft.value -= 1 }) } }
Diff
- state = new BehaviorSubject({value: 0}) + state = new RxState({value: 0}) increase() { - this.state.next({value: this.state.value.value + 1}) + this.state.updateState(draft => { + draft.value += 1 + }) } decrease() { - this.state.next({value: this.state.value.value - 1}) + this.state.updateState(draft => { + draft.value -= 1 + }) }
Смотрится немного лучше первого варианта, но все еще осталась необходимость каждый раз вызывать updateState
. Для решения этой проблемы создадим еще один класс и назовем его SimpleImmutableStore
, он будет базовым для сторов.
class SimpleImmutableStore<TState> { rxState!: RxState<TState> get draft() { return this.rxState.draft } constructor(initialState: TState) { this.rxState = new RxState<TState>(initialState) } public updateState(recipe: (draft: Draft<TState>) => void) { this.rxState.updateState(recipe) } }
Реализуем стор с его помощью:
CounterStore V1
class CounterStoreV1 extends SimpleImmutableStore<{value: number}> { constructor(){ super({value: 0}) } increase() { this.updateState(() => { this.draft.value += 1 }) } decrease() { this.updateState(() => { this.draft.value -= 1 }) } }
Diff
-class CounterServiceV2 { - state = new RxState({value: 0}) +class CounterStoreV1 extends SimpleImmutableStore<{value: number}> { + constructor(){ + super({value: 0}) + } increase() { - this.state.updateState(draft => { - draft.value += 1 + this.updateState(() => { + this.draft.value += 1 }) } decrease() { - this.state.updateState(draft => { - draft.value -= 1 + this.updateState(() => { + this.draft.value -= 1 }) } }
Как видим существенно ничего не поменялось, но теперь у всех методов есть общий код в виде обертки this.updateState
. Чтобы избавиться от этого дублирования, напишем функцию, которая оборачивает все методы класса в вызов updateState
:
const wrapped = Symbol() // Для предотвращения двойного оборачивания function getMethodsNames(constructor: any) { const names = Object.getOwnPropertyNames(constructor.prototype).filter( x => x !== "constructor" && typeof constructor.prototype[x] === "function", ) return names } function wrapMethodsWithUpdateState(constructor: any) { if (constructor[wrapped]) { return } constructor[wrapped] = true for (const propertyName of getMethodsNames(constructor)) { const descriptor = Object.getOwnPropertyDescriptor( constructor.prototype, propertyName, )! const method = descriptor.value descriptor.value = function(...args: any[]) { const store = this as SimpleImmutableStore<any> let result: any store.updateState(() => { // оборачиваем вызов метода в updateState result = method.call(store, ...args) }) return result } Object.defineProperty(constructor.prototype, propertyName, descriptor) } }
и будем вызывать ее в конструкторе (при желании этот метод также можно реализовать как декоратор для класса)
constructor(initialState: TState ) { this.rxState = new RxState<TState>(initialState) wrapMethodsWithUpdateState(this.constructor) }
CounterStore
Финальный вариант стора. Для демонстрации добавим немного логики в decrease
и еще пару методов с передачей параметра setValue
и асинхронностью increaseWithDelay
:
class CounterStore extends SimpleImmutableStore<{ value: number }> { constructor() { super({value: 0}) } increase() { this.draft.value += 1 } decrease() { const newValue = this.draft.value - 1 if (newValue >= 0) { this.draft.value = newValue } } setValue(value: number) { this.draft.value = value } increaseWithDelay() { setTimeout(() => this.increase(), 300) } }
Использование с Angular
Так как в основе получившегося стора лежит RxJS, то с Angular его можно использовать в связке с async
pipe:
<div *ngIf="store.rxState.state$ | async as state"> <span>{{state.value}}</span> <button (click)="store.increase()">+</button> <button (click)="store.decrease()">-</button> <button (click)="store.setValue(0)">Reset</button> <button (click)="store.increaseWithDelay()">Increase with delay</button> </div>
Demo
Использование с React
Для React напишем кастомный hook:
function useStore<TState, TResult>( store: SimpleImmutableStore<TState>, project: (store: TState) => TResult, ): TResult { const projectRef = useRef(project) useEffect(() => { projectRef.current = project }, [project]) const [state, setState] = useState(projectRef.current(store.rxState.state)) useEffect(() => { const subscription = store.rxState.state$.subscribe(value => { const newState = projectRef.current(value) if (!shallowEqual(state, newState)) { setState(newState) } }) return () => { subscription.unsubscribe() } }, [store, state]) return state }
Компонент
const Counter = () => { const store = useMemo(() => new CounterStore(), []) const value = useStore(store, x => x.value) return ( <div className="counter"> <span>{value}</span> <button onClick={() => store.increase()}>+</button> <button onClick={() => store.decrease()}>-</button> <button onClick={() => store.setValue(0)}>Reset</button> <button onClick={() => store.increaseWithDelay()}>Increase with delay</button> </div> ) }
Demo
Заключение
В результате получилось достаточно простое и функциональное решение, которое я периодически использую в своих проектах. При желании в такой стор можно еще добавить разных полезностей: middleware, state slicing, update rollback — но это уже выходит за рамки данной статьи. С результатом таких добавлений можно ознакомиться на гитхабе https://github.com/simmor-store/simmor