Как стать автором
Обновить

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

Время на прочтение6 мин
Количество просмотров5.8K
"Вы поймете, когда вам нужен 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


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

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии16

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн