Skip to content
On this page

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.

Source

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.

diagram

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.

ts
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

ts
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)
  }),
});
vue
<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>
vue
<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
ts
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:

  1. State-capturing function - determines the shape and contents of the state and returns the second part:
  2. 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
ts
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
ts
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:

ts
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:

ts
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 state
  • actions$ - a map of all observables that are fired on action calls
  • context - 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.
ts
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 calling subscribe on the state$, returns the same object with rxjs Subscription 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:

ts
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:

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:

ts
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
ts
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:

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

    ts
    import { 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

    ts
    class 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:

ts
import { useRxState, State } from 'vuse-rx';

export const counterState = useRxState({ count: 0 });
export type CounterState = State<typeof counterState>;
// CounterState = { count: number }

Released under the MIT License.