"Entenderá cuándo necesita Flux. Si no está seguro de que lo necesita, no lo necesita". Caza de pete

Para controlar el estado de la aplicación, generalmente uso Redux. Pero no siempre es necesario usar el modelo Action \ Reducer, aunque solo sea por la laboriosidad de su aplicación para escribir el funcional más simple. Tome un contador ordinario como ejemplo. En la salida, quería obtener una solución simple y práctica que nos permitiera describir el modelo de estado y un par de métodos que lo cambian, como este:
state = {value: 0} increase() { state.value += 1 } decrease() { state.value -= 1 }
Parece enseguida que MobX puede proporcionar esa solución, ¿por qué no usarla? Después de trabajar con MobX por un tiempo, llegué a la conclusión de que es más fácil para mí personalmente operar con una secuencia de estados inmutables (como Redux) que con la lógica de un estado mutable (como MobX), y no llamaría simple a su cocina interna.
En general, quería encontrar una solución simple para administrar el estado, que se basara en la inmunidad, con la capacidad de usarla en Angular \ React e implementarla en TypeScript. Una revisión rápida sobre los espacios abiertos de github no dio una solución adecuada, así que tomemos RxJS / Immer e intentemos hacer el nuestro.
Usamos RxJS
Tomaremos BehaviorSubjet
como base, que modelará el flujo de cambios de estado {value: 0} -> {value: 1} -> {value: 2}
y que también tiene un método getValue
con el que puede obtener el estado actual. Si compara la API BehaviorSubject
con Redux
getValue() / getState() //
subscribe() / subscribe() //
next(value) / dispatch(action), replaceReducer(nextReducer) //
Puede notar que son bastante similares. La principal diferencia es que en BehaviorSubject
lugar de Action/Reducer
nuevo estado se puede establecer utilizando el método next()
.
Para el ejemplo del contador anterior, una implementación podría verse así:
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}) } }
La redundancia de las repeticiones de this.state.next
y los engorrosos cambios de estado son sorprendentes. Esto es muy diferente del resultado deseado state.value += 1
Añadir Immer
Para simplificar el cambio en el estado inmune, utilizaremos la biblioteca Immer. Immer le permite crear un nuevo estado inmutable debido a la mutación de la corriente. Funciona de esta manera:
const state = {value: 0}
Paquete de comportamiento Sujeto e Inmersión
Envuelva el uso de BehaviorSubject
e Immer en nuestra propia clase y 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
, reescribimos 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 }) } }
Diferencia - 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 + }) }
Se ve un poco mejor que la primera opción, pero aún es necesario llamar a updateState
cada vez. Para resolver este problema, cree otra clase y SimpleImmutableStore
, será la base de la historia.
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 el stor con su ayuda:
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 }) } }
Diferencia -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 puede ver, nada ha cambiado significativamente, pero ahora todos los métodos tienen un código común en forma de envoltorio this.updateState
. Para deshacerse de esta duplicación, escribimos una función que envuelve todos los métodos de clase en una llamada updateState
:
const wrapped = Symbol()
y lo llamaremos en el constructor (si lo desea, este método también se puede implementar como decorador para la clase)
constructor(initialState: TState ) { this.rxState = new RxState<TState>(initialState) wrapMethodsWithUpdateState(this.constructor) }
Contraventana
La versión final de la historia. Para demostrarlo, agregue un poco de lógica para decrease
y un par de métodos más con pasar el parámetro setValue
y setValue
asincronía con 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) } }
Usar con angular
Dado que RxJS es la base de la pila resultante, se puede usar con Angular junto con una tubería 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>
Usar con React
Para React, escribiremos un 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> ) }
Conclusión
El resultado es una solución bastante simple y funcional, que uso periódicamente en mis proyectos. Si lo desea, puede agregar varias utilidades a este lado: middleware, división de estado, reversión de actualizaciones, pero esto ya está fuera del alcance de este artículo. El resultado de tales adiciones se puede encontrar en el github https://github.com/simmor-store/simmor
Agradecería cualquier sugerencia y comentario.