Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX

от автора

«Вы поймете, когда вам нужен 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 и попробуем сделать свое.

Используем 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

Буду признателен за любые предложения и замечания.

ссылка на оригинал статьи https://habr.com/ru/post/483526/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *