Gerenciando o estado do aplicativo com o RxJS / Immer como uma alternativa simples ao Redux / MobX

"Você entenderá quando precisar do Flux. Se não tiver certeza de que precisa, não precisa." Pete hunt


Para controlar o estado do aplicativo, eu costumo usar o Redux. Mas nem sempre é necessário usar o modelo Action \ Reducer, mesmo que seja devido à laboriosa aplicação para escrever a funcionalidade mais simples. Tome um contador comum como exemplo. Na saída, eu queria obter uma solução simples e prática que nos permitisse descrever o modelo de estado e alguns métodos que o mudam, assim:


state = {value: 0} increase() { state.value += 1 } decrease() { state.value -= 1 } 

Parece imediatamente que o MobX pode fornecer uma solução desse tipo, então por que não usá-la? Depois de trabalhar com o MobX por um tempo, cheguei à conclusão de que é mais fácil para mim operar pessoalmente com uma sequência de estados imutáveis ​​(como Redux) do que com a lógica de um estado mutável (como MobX), e eu não chamaria sua cozinha interna de simples.


Em geral, eu queria encontrar uma solução simples para gerenciar o estado, que seria baseada na imunidade, com a capacidade de usá-lo no Angular \ React e implementado no TypeScript. Uma rápida revisão sobre os espaços abertos do github não deu uma solução adequada, então vamos usar o RxJS / Immer e tentar fazer o nosso.


Usamos RxJS


Tomaremos o BehaviorSubjet como base, que modelará o fluxo de alterações de estado {value: 0} -> {value: 1} -> {value: 2} e que também possui um método getValue com o qual você pode obter o estado atual. Se você comparar a API BehaviorSubject com o Redux


  • getValue() / getState() //
  • subscribe() / subscribe() //
  • next(value) / dispatch(action), replaceReducer(nextReducer) //

Você pode perceber que eles são bem parecidos. A principal diferença é que no BehaviorSubject em vez de Action/Reducer novo estado pode ser definido usando o método next() .


Para o exemplo de contador acima, uma implementação pode ser assim:


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}) } } 

A redundância de repetições deste this.state.next e o incômodo das mudanças de estado são impressionantes. Isso é muito diferente do estado do resultado desejado.valor state.value += 1


Adicionar Immer


Para simplificar a mudança no estado imunológico, usaremos a biblioteca Immer. O Immer permite criar um novo estado imutável devido à mutação da corrente. Funciona assim:


 const state = {value: 0} //     const draft = createDraft(state) //      draft.value += 1 //    const newState = finishDraft(draft) 

Pacote de ComportamentoSubject and Immer


Embrulhe o uso de BehaviorSubject e Immer em nossa própria classe e chame-o de 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) } } } 

Usando RxState , reescrevemos o 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 + }) } 

Parece um pouco melhor que a primeira opção, mas ainda é necessário chamar updateState todas as vezes. Para resolver esse problema, crie outra classe e chame-a SimpleImmutableStore , que será a base da história.


 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) } } 

Implementamos o armazenamento com sua ajuda:


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 }) } } 

Como você pode ver, nada mudou significativamente, mas agora todos os métodos têm um código comum na forma de um invólucro this.updateState . Para se livrar dessa duplicação, escrevemos uma função que updateState todos os métodos de classe em uma chamada 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) } } 

e o chamaremos no construtor (se desejado, esse método também pode ser implementado como um decorador para a classe)


  constructor(initialState: TState ) { this.rxState = new RxState<TState>(initialState) wrapMethodsWithUpdateState(this.constructor) } 

Counterstore


A versão final da história. Para demonstrar, adicione um pouco de lógica para decrease e mais alguns métodos, passando o parâmetro setValue e setValue assincronia setValue :


 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) } } 

Use com Angular


Como o RxJS é a base da pilha resultante, ele pode ser usado com o Angular em conjunto com o pipe async :


 <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


Use com React


Para o React, escreveremos um gancho personalizado:


 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 } 

Componente


 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


Conclusão


O resultado é uma solução bastante simples e funcional, que uso periodicamente em meus projetos. Se desejar, você pode adicionar várias utilidades a este lado: middleware, fatia de estado, reversão de atualização - mas isso já está além do escopo deste artigo. O resultado de tais adições pode ser encontrado no github https://github.com/simmor-store/simmor


Ficaria muito grato por quaisquer sugestões e comentários.

Source: https://habr.com/ru/post/pt483526/


All Articles