"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}
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
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()
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>
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> ) }
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.