State Management
Tools for creating and managing a flux-like state with full RxJS compatibility.
useRxState
Allows to bind reducers to a reactive state and observables.
Description
It implements a light flux pattern using rxjs' Observables.
Even though state management is not the primary concern of vuse-rx
, it still allows for a basic flux-like state management with observables using useRxState
.
This function returns 3 key parts:
- Reactive state
- State reducers
- RXjs observable
All of them work in unison to always keep vue components in sync with the application business logic.
The big difference from other flux-like solutions is that useRxState
doesn't care whether it's a singleton that manages the state of the whole application or just used locally in a component. Therefore, it's much more flexible for small- to mid- scale applications.
It accepts, at minimum, an initial state and some reducers, and returns a reactive state, actions, and some observables to make things easier to control.
What's a reducer?
Reducer is a pure function that returns a Mutation - a part of a new state that needs to be mixed into the original state in order for the state to change.
const state = { count: 0 };
const reducers = {
increment: () => state => ({ count: state.count + 1 })
};
// Now using const destructuring:
const {
// actions that mutate the state via reducers
actions,
// vue reactive state
state,
// state observable
state$,
// observables that are triggered on specific actions
actions$
} = useRxState(state)(reducers).subscribe();
TIP
useRxState
automatically makes the state reactive for Vue, so you don't need to worry about applying reactive
to it.
Basic example
import { useRxState, syncRef } from 'vuse-rx';
export const counter = useRxState({ count: 0 })({
// stateful reducer
increment: () => state => ({
// automatic type inference for the state
count: state.count + 1
}),
// stateless reducer
setCount: (count: string) => ({
// custom business logic
count: isNaN(Number(count)) ? 0 : Number(count)
}),
});
<script setup lang="ts">
import { counter } from './counter.ts';
const {
actions: { increment, setCount },
state,
state$ // state observable
} = counter;
// "Activating" the actions
state$.subscribe(state => console.log('counter: ', state.count));
// One-way data binding from reactive state (with type convertation)
const countRef = syncRef(state, 'count', String);
</script>
<template>
<button @click="increment">increment to {{ state.count + 1 }}</button>
<br>
<input v-model="countRef"/>
<button @click="setCount(countRef)">set count to {{ countRef }}</button>
</template>
<script setup lang="ts">
import { counter } from './counter.ts';
const {
actions: { increment, setCount },
state
} = counter.subscribe(state => console.log('counter: ', state.count));
// One-way data binding from reactive state (with type convertation)
const countRef = syncRef(state, 'count', String);
</script>
<template>
<button @click="increment">increment to {{ state.count + 1 }}</button>
<br>
<input v-model="countRef"/>
<button @click="setCount(countRef)">set count to {{ countRef }}</button>
</template>
TIP
Every variable in this example is exposed to window
, so feel free to open the console and play with it!
Detaled Description
useRxState
Type Signature
function <S extends Record<string, any>, Mutation>(
initialState: S | (() => S),
options?: RxStateOptions
) => <R extends StateReducers<S>>(
reducers: R,
map$?: (
state$: Observable<Readonly<S>>,
reducers: R,
state: Readonly<S>,
actions$: Record<`${keyof R}$`, Observable<S>>,
context: MutationContext
) => Observable<Mutation>
) => {
actions: ReducerActions<R>;
state: S;
state$: Observable<S>;
actions$: ReducerObservables<ReducerActions<R>, S>;
subscribe: (next?) => {
actions: ReducerActions<R>;
state: S;
state$: Observable<S>;
actions$: ReducerObservables<ReducerActions<R>, S>;
subscription: { unsubscribe(): void };
};
}
useRxState
is split into two parts:
- State-capturing function - determines the shape and contents of the state and returns the second part:
- Reducers-capturing function - binds the reducers to the state and creates an observable for it.
Why?
This is done in order to enable full type inference in reducers, as well as to allow different reducers to share the same state without bundling them all in one place in contrast to other flux-like solutions. For an example of this, see the shared counter state recipe.
1. State-capturing function
Type Signature
function <S extends Record<string, any>, Mutation = deepReplaceArrayMutation>(
initialState: S | (() => S),
options?: RxStateOptions<S, Mutation>
): Function
Accepts either a state object or a state factory function and an optional options object.
It remembers the state, applies the options, and then returns the second function.
TIP
This allows to split away the code that operates on the state from the state itself.
For an example of this, see the shared counter state recipe.
2. Reducers-capturing function
Type Signature
function <R extends StateReducers<S>>(
reducers: R,
map$?: (
state$: Observable<Readonly<S>>,
reducers: R,
state: Readonly<S>,
actions$: Record<`${keyof R}$`, Observable<S>>,
context: MutationContext
) => Observable<Mutation>
) => SubscribableRxRes<ReducerActions<R>, S>
This function is used to bind reducers to the state and produce an observable that reacts to the changes that reducers make on the state.
There's a lot to unpack. Let's go one parameter at-a-time.
TIP
For a detailed usage example with both parameters see the stopwatch recipe.
Parameter 1: reducers
This function's primary goal is to bind reducers to the state.
The reducers are passed in as a first parameter. Each reducer must return either a part of the state or an observable that emits a part of the state.
A reducer can be either stateful or stateless:
A stateful reducer uses a state object to compute the mutation:
For example, an add-reducer:ts// Reducer returns a function that accepts a state and a mutation context // and returns the final mutation (addAmount) => (state, mutation) => ({ count: state.count + addAmount })
A stateless reducer only uses its initial parameters to compute the mutation:
For example, a replace-reducer:ts// Reducer returns the final mutation right away (newValue) => ({ count: newValue })
The resulting mutation is then automatically merged with the current state.
Let's see a complete example:
const { actions, state } = useRxState({ count: 0 })({
// stateful
add: (addAmount) => (state) => ({ count: state.count + addAmount }),
// stateless
set: (newValue) => ({ count: newValue }),
}).subscribe();
actions.add(9);
It's also possible to inform observables about errors or make them complete from within the reducers. The mutation
context parameter is used for this.
WARNING
mutation
is nullable, so it is recommended to use the optional chaining (?.
) operator when accessing it.
Let's rewrite our add
reducer with this in mind:
const maximumValue = 10;
const { actions, state } = useRxState({ count: 0 })({
// stateful
add: (addAmount) => (state) => ({ count: state.count + addAmount }),
add: (addAmount) => (state, mutation) => {
if (addAmount < 0) {
// Raise a mutation error
mutation?.error('add amount cannot be negative!');
// Signify that no changes need to be made
return {};
}
const newValue = state.count + addAmount;
if (newValue >= maximumValue) {
// This mutation will never be called again
mutation?.complete();
}
return { count: newValue };
},
// stateless
set: (newValue) => ({ count: newValue }),
}).subscribe();
}).subscribe({
error: errorText => {
console.error('Oh no, an error:', errorText);
},
complete: () => {
console.log('Counter stopped at ', state.count);
}
});
actions.add(9);
// 50%
if (Math.random() > 0.5) {
actions.add(-1);
//> Oh no, an error: add amount cannot be negative!
} else {
actions.add(1);
//> Counter stopped at 10
}
Parameter 2: map$
It's also possible to modify the resulting observable using the second parameter, map$
.
It accepts a function with the following parameters:
state$
- the resulting observable (fired on each action)reducers
- a map of raw reducers (basically, the first parameter itself)state
- current reactive stateactions$
- a map of all observables that are fired on action callscontext
- a current mutation context, same as the one in the reducers,
can be passed into the reducers to allow them to control the mutation too and expects a state observable to be returned from it.
useRxState(state)(
reducers,
// add this parameter to any useRxState call to try
(state$, reducers, state, actions$, context) => {
console.log('This is logged only once');
console.log('These are all reducers defined above, unchanged:', reducers);
console.log('This is an initial reactive state:', state);
console.log('This is a map of all actions to their specific observables:', actions$);
console.log('This context can be used to create an error or to complete the observable:', context);
// By the way, state$ is just merged actions$,
// so this
return state$.pipe(tap(state => console.log('this is logged on each action', state)));
// and this
return merge(...Object.values(actions$)).pipe(tap(state => console.log('this is logged on each action', state)));
// are identical
}
)
Returned value
The function then returns the following object:
state
- a plain Vue reactive state, but immutable.actions
- transformed reducers, they accept the defined parameters, but mutate the state, instead of just returning its parts.state$
- an observable that emits a new state each time there's an update.actions$
- a map of individual observables per each action, useful for tracking individual action calls.subscribe
- a shorthand for callingsubscribe
on thestate$
, returns the same object with rxjsSubscription
mixed-in.
Options and fine-tuning
useRxState
allows to change some of its behaviour via the optional options
parameter in the first function.
It accepts the following type of object:
export interface RxStateOptions<S extends Record<PropertyKey, any>, Mutaiton> {
mutationStrategy?: MutationStrategy<S, Mutaiton>;
strategyContext?: any;
}
There are 4 mutation strategies provided out-of the box:
shallow
deep
deepReplaceArray
- DEPRECATED in favor ofdeepReplaceBuiltin
deepReplaceBuiltin
- DEFAULT
Each out-of-the-box mutation strategy also sets its own mutation converter type, so a mutation type for the deep
strategy may be different from a mutation type for the shallow
strategy.
A mutation converter is simply a type
that mimics the way the strategy processes any given value in a mutation. It's named Mutation
in the options type signature above.
Via options you can change how new mutations are applied to the state:
import {
useRxState,
// this is the default mutation merge strategy
deepReplaceArray,
// fast checker of whether we can mutate the state deeper
canMergeDeep
} from 'vuse-rx';
useRxState(initialState, {
strategyContext: [], // will be passed as `this` to the mutationStrategy
mutationStrategy(
this, // the `strategyContext` option
state, // A full base state to mutate
mutate // Current mutation strategy (this exact function)
) {
return (mutation /*Mutation to apply*/) => {
// Let's say we also need to apply our mutations to symbols:
for (const key of Object.getOwnPropertySymbols(mutation)) {
// Check if we can go deeper
state[key] = canMergeDeep(state, mutation, key)
// If yes - mutate the state further using our function
? mutate.call(this, state[key])(mutation[key])
// if no - just assign the value of our mutation
: mutation[key];
}
// Apply the default strategy once we're done
return deepReplaceBuiltin.apply(this, [state])(mutation);
// or...
// if we need to restrict our mutations to symbols only
// we can just return the state
// without applying the default strategy
return state;
}
}
});
WARNING
This may not be enough to get correct type inference in mutations.
To make sure that the types represent actual behavior, add a mutation converter type as a type guard for the mutation.
Example
import { DeepReplaceBuiltinMutation } from 'vuse-rx';
type SymbolMutation<T> = T extends Record<any, any>
? { [K in keyof Partial<T>]: SymbolMutation<T[K]> }
: DeepReplaceBuiltinMutation<T>;
useRxState(initialState, {
mutationStrategy(state, mutate) {
return (mutation: SymbolMutation<typeof state>) => {
for (const key of Object.getOwnPropertySymbols(mutation)) {
state[key] = canMergeDeep(state, mutation, key)
? mutate(state[key])(mutation[key])
: mutation[key];
}
return deepReplaceBuiltin.apply(this, [state])(mutation);
}
}
});
TIP
Mutation type is not restricted to a product of the initial state or an object even!
You can even pass a string if you want to:
useRxState({ count: 0 }, {
// This is not advised, of course, but for the sake of example...
mutationStrategy: state => (mutation: 'increment' | 'decrement') => ({
count: mutation === 'increment' ? state.count + 1 : state.count - 1
}),
})({
increment: () => 'increment',
decrement: () => 'decrement',
});
Mutation strategies
These are the 4 mutations strategies that vuse-rx
provides "out-of-the-box", so that there's no need to reimplement them every time there's a need for mutations to work a little differently.
Here's how the strategies are implemented
export type ShallowMutation<S> = {
// Hack to avoid double `undefined` in `[key]?: value | undefined`
[K in keyof Partial<S>]: S[K];
};
/**
* A merge strategy for mutations
*
* Shallow-merges state and mutation recursively,
* by enumerable keys (`for..in`)
*/
export const shallow = <S extends Record<PropertyKey, any>>(
state: S
) => (
mutation: ShallowMutation<S>
) => {
for (const key in mutation) {
state[key] = mutation[key];
}
return state;
};
import { canMergeDeep } from './common';
export type DeepMutation<T> = T extends object
? T extends Array<infer U>
? Array<DeepMutation<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepMutation<U>>
: T extends Record<any, any>
? { [K in keyof Partial<T>]: DeepMutation<T[K]> }
: Partial<T>
: T;
/**
* A deep-merge strategy for mutations
*
* Deep-merges state and mutation recursively,
* by enumerable keys (`for..in`),
* so avoid recursive object links
*/
export const deep = <S extends Record<PropertyKey, any>>(
state: S
) => (
mutation: DeepMutation<S>
) => {
for (const key in mutation) {
state[key as keyof S] = canMergeDeep(state, mutation, key)
? deep(state[key])(mutation[key])
: mutation[key] as S[keyof S];
}
return state;
};
import { deepReplaceBuiltin } from './deepReplaceBuiltin';
export type DeepReplaceArrayMutation<T> = T extends Array<any> | ReadonlyArray<any>
? T
: T extends Record<any, any>
? { [K in keyof Partial<T>]: DeepReplaceArrayMutation<T[K]> }
: Partial<T>;
/**
* Deep-merges state and mutation objects recursively,
* by enumerable keys (`for..in`),
* but replaces arrays and primitives
*
* @deprecated in favor of `deepReplaceBuiltin`
*/
export const deepReplaceArray = <S extends Record<PropertyKey, any>>(
state: S
) => (
mutation: DeepReplaceArrayMutation<S>
): S => deepReplaceBuiltin.apply([Array], [state])(mutation);
import { canMergeDeep } from './common';
export type Builtin =
| Function
| Array<any>
| ReadonlyArray<any>
| Date
| Error
| RegExp
| string
| number
| boolean
| bigint
| symbol
| undefined
| null;
export type BuiltinConstructors = Array<new (...args: any) => any>;
export const defaultBuiltin: BuiltinConstructors = [
Array,
Date,
RegExp,
Error,
];
export type DeepReplaceBuiltinMutation<T> = T extends Builtin
? T
: T extends Record<any, any>
? { [K in keyof Partial<T>]: DeepReplaceBuiltinMutation<T[K]> }
: Partial<T>;
/**
* Default merge strategy for mutations
*
* Deep-merges state and mutation objects recursively,
* by enumerable keys (`for..in`),
* but replaces builtin types (supplied by `this` context) and primitives
*/
export function deepReplaceBuiltin<S extends Record<PropertyKey, any>>(
this: BuiltinConstructors,
state: S
) {
return (mutation: DeepReplaceBuiltinMutation<S>) => {
for (const key in mutation) {
const submutation = mutation[key];
state[key as keyof S] = (
canMergeDeep(state, mutation, key)
&& !this.some(c => [state[key].constructor, submutation.constructor].includes(c))
)
? deepReplaceBuiltin.call(this, state[key])(submutation) as S[keyof S]
: submutation as S[keyof S];
}
return state;
};
}
export type MutationStrategy<S extends Record<PropertyKey, any>, M, C = any> = {
/**
* Creates a mutation applier
*
* @param state - a base state to mutate
* @param strategy - current mutation strategy
*/
(this: C, state: S, strategy: MutationStrategy<S, M, C>): (mutation: M) => S;
};
/**
* Checks if it's possible to advance deeper
* into the sibling object structures,
* with one being partial
*
* @param state - the object source
* @param mutation - the main checking reference
* @param key - a key into which to advance
*/
export const canMergeDeep = <S extends Record<PropertyKey, any>, Mutation extends Record<keyof S, any>>(
state: S,
mutation: Mutation | null | undefined,
key: keyof S,
) => (
mutation != null
&& typeof mutation[key] === 'object'
&& typeof state[key] === 'object'
);
shallow
Surface-level merge, equivalent to an object spread (state = { ...state, ...mutation }
).
deep
Recursively merges mutations with the state, iterating on keys of any object.
deepReplaceArray
Same as deep
, but does a simple shallow replacement for arrays.
DEPRECATED
This strategy is deprecated in favor of deepReplaceBuiltin
.
deepReplaceBuiltin
DEFAULT
Same as deep
, but does a simple shallow replacement for builtin types, like Array, Date, RegExp and Error.
You can control what counts as builtin by setting the strategyContext
parameter in options:
to add to default builtins
tsimport { defaultBuiltin } from 'vuse-rx'; class MyBuiltin {} // Now all mutations also replace instances of MyBuiltin, // in addition to Array, Date, RegExp and Error useRxState(state, { strategyContext: [...defaultBuiltin, MyBuiltin] })
to replace default builtins
tsclass MyBuiltin {} // Now all mutations replace only instances of MyBuiltin, // while deep-merging any other type useRxState(state, { strategyContext: [MyBuiltin] })
WARNING
This may not be enough to get correct type inference in mutations.
To make sure that the types represent actual behavior, pass a mutation converter type as a second type parameter to useRxState
.
Type helpers
State
Allows to declaratively define the state type from the result of the first call of useRxState
:
import { useRxState, State } from 'vuse-rx';
export const counterState = useRxState({ count: 0 });
export type CounterState = State<typeof counterState>;
// CounterState = { count: number }