Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX
- четверг, 16 января 2020 г. в 00:24:05
"Вы поймете, когда вам нужен Flux. Если вы не уверены, что вам это нужно, вам это не нужно." Пит Хант
Для управления состоянием приложения я как правило применяю Redux. Но не всегда есть необходимость в использовании модели Action\Reducer, хотя бы из-за трудозатратности ее применения для написания простейшего функционала. Возьмем в качестве примера обычный счетчик. На выходе хотелось получить простое и практичное решение, которое позволит описать модель состояния и пару методов его меняющие, наподобие такого:
state = {value: 0}
increase() {
state.value += 1
}
decrease() {
state.value -= 1
}
Сходу кажется, что такое решение может обеспечить MobX, так почему бы им и не воспользоваться? Поработав с MobX некоторое время, для себя пришел к выводу, что лично мне проще оперировать последовательностью иммутабельных состояний (наподобие Redux), чем логикой мутабельного состояния (наподобие MobX), да и его внутреннюю кухню я бы не назвал простой.
В общем, захотелось найти простое решение для управления состоянием, в основе которого лежала бы иммутабельность, с возможностью применять его в Angular\React и реализованное на TypeScript. Беглый обзор на просторах github подходящего решения не выдал, поэтому возьмем RxJS/Immer и попробуем сделать свое.
За основу возьмем 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 позволяет создавать новое иммутабельное состояние за счет мутации текущего. Работает он таким образом:
const state = {value: 0}
// создаем драфт текущего состояния
const draft = createDraft(state)
// производим с ним мутабельные изменения
draft.value += 1
// получаем новое состояние
const newState = finishDraft(draft)
Обернем использование 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
})
}
}
- 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
})
}
}
-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)
}
}
Так как в основе получившегося стора лежит 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>
Для 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>
)
}
В результате получилось достаточно простое и функциональное решение, которое я периодически использую в своих проектах. При желании в такой стор можно еще добавить разных полезностей: middleware, state slicing, update rollback — но это уже выходит за рамки данной статьи. С результатом таких добавлений можно ознакомиться на гитхабе https://github.com/simmor-store/simmor
Буду признателен за любые предложения и замечания.