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

3380

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

Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX
Управление состоянием приложения с 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

 

источник