DEV Community

loading...
Cover image for Vue-DFS-Store: A Simple Store Wrapping Vue's Built-in Reactivity

Vue-DFS-Store: A Simple Store Wrapping Vue's Built-in Reactivity

jacobsngoodwin profile image Jacob Goodwin Updated on ・5 min read

A few weeks ago while taking a dive back into the Vue ecosystem, I found an interesting article here on dev.to entitled You Might Not Need Vuex with Vue 3. Given the guidance and my desire to learn more about Vue's reactivity system, I wondered if I could create a simple store implementation built-on top of the reactivity system to easily initialize, provide, and inject stores into Vue components.

I also recently played with the Zustand, a state management solution mostly used with React, and liked the simplicity of creating actions with access to state via "set" and "get" methods. Of course, Vue's reactivity system works with mutable state, so there are some differences from React, but I wanted a similar API to what Zustand offers.

I now present the product of my recent learning.

Introducing Vue-DFS-Store

Let's show how to use this with... a counter app because nothing under the sun is new.

Demo Application

Create Store

We use the createStore function with the following properties provided in a configuration object.

import { createStore } from 'vue-dfs-store';

type CounterState = {
  count: number;
};

type CounterAccessors = {
  incCount: (val: number) => void;
  clearCount: () => void;
  multCount: (val: number) => number;
};

const counterStore = createStore<CounterState, CounterAccessors>({
  name: 'counterStore',
  initialState: {
    count: 0,
  },
  accessorsCreator: (mutate, get) => ({
    incCount: (val: number) => mutate(state => (state.count += val)),
    clearCount: () => mutate(state => (state.count = 0)),
    multCount: (val: number) => get().count * val,
  }),
  mutatorHook: state => console.log(state),
});

export default counterStore;
Enter fullscreen mode Exit fullscreen mode

CreateStore Details

The configuration receives a name, an initialState object (a plain old object), an accessorsCreator, and a mutatorHook.

The accessorsCreator receives mutate and get functions, and returns an object of methods where we can get and/or mutate the state.

get provides access to a Vue readonly version of the reactive state. You can see how this is used in the multCount accessor below to compute a multiple of the current counter value.

The only access to the store's mutable state is provided by the mutate method. This is similar Zustand's set method. You can make as many mutations as you want inside of an accessor, and these accessor can also by async.

mutatorHooks is used to run some code after each mutation. In the future, I hope to add functionality to create multiple stores which share a common mutatorHook (as well as functionality for executing code both before committing the mutation and after committing the mutation). This can be used for storing state history down the road.

You may provide explicit types for the state and accessors (as shown above) to createState, or you may rely on inferred types from the configuration.

Created Store Properties

The createStore function returns the following, where the generic parameters T and U are the typings for the State and the Accessors.

// Store is returned by createStore()
export type Store<T extends State, U extends Accessors> = {
  readonly name: string;
  storeAPI: StoreAPI<T, U>;
  install: (app: App) => void; // makes Store implement Plugin from vue
  readonly storeKey: symbol;
  provider: () => void;
};
Enter fullscreen mode Exit fullscreen mode

The main property you will be using in our components is the storeAPI, and this is what will be returned from the useStore function, described below.

The StoreAPI is typed as follows. You can see it exposes a ReadOnly version of the reactive state (both readonly provided by Vue, and readonly provided by Typescript).

export type StoreAPI<T extends State, U extends Accessors> = {
  readonly state: ReadonlyState<ReactiveState<T>>;
  accessors: U;
};
Enter fullscreen mode Exit fullscreen mode

Provide Store

Observe that the object returned by createStore, has an install method and a provider.

You can pass the store directly to app.use() as follows to provide the store at the root of your application.

import { createApp } from 'vue';
import App from './App.vue';
import counterStore from './store/counter';

createApp(App)
  .use(counterStore)
  .mount('#app');
Enter fullscreen mode Exit fullscreen mode

You can also import and call provider from the store inside of any setup method inside of any Vue components down your tree structure.

Use Store

Import the useStore function and pass it the store we just created to get access to the readonly state and accessors. Look at how we can use an accessor method inside of a computed to recalculate the multCount method when it's reactive dependency, multiplier is updated.

If you need to "spread" to reactive state object returned from useStore, use toRefs.

<script lang="ts">
import { defineComponent, ref, computed, toRefs } from 'vue';
import { useStore } from 'vue-dfs-store';
import counterStore from '../store/counter';

export default defineComponent({
  name: 'Counter',
  setup() {
    const multiplier = ref(0);
    const { state, accessors } = useStore(counterStore);

    const { count } = toRefs(state);

    const multipliedCount = computed(() =>
      accessors.multCount(multiplier.value)
    );

    return {
      state,
      count,
      incCount: accessors.incCount,
      clearCount: accessors.clearCount,
      multiplier,
      multipliedCount,
    };
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

Learn More!

Check out my Github repo for source code, documentation, and how to run the demo application.

If you would like me to go over the source code, I'll write up another article. Just let me know in the comments!

Why Not Just Use Vuex?

Noooo.... by all means, use it!

While Vuex is the obvious option for state management in Vue, I did find typing the stores in the upcoming v4 to be a little difficult and sparsely documented (or at least sparsely "exampled"). Check out Vuex's Typescript documentation for what it takes to inject a store with a single "count" property into your application. Perhaps I just need a little more practice, or perhaps the Vuex team just needs some time to update the documentation and for bloggers to provide better examples.

Either way, I by no means hope to, nor think I can, replace Vuex. As I wrote, I merely wanted to learn more about Vue's reactivity system and Composition API. Hell, there could be some major flaws in my thinking. If so, please let me know.

But if you do find this useful, let me know, and maybe I'll work on a build pipeline for this project so making improvements can go smoothly!

An Issue I could use your help with

I wanted the storeAPI to directly provide access to the spread, toRefs, version of the state when calling useStore(). However I kept getting the following Typescript error when I wrapped the readonly state in ToRefs:

error TS2590: Build:Expression produces a union type that is too complex to represent.

I did find some issues addressing this, but could not determine how to simplify my Typescript to fix this. My suspicion if that the complexity of DeepReadonlyfrom vue's reactivity typing, combined with ToRefs and this library's types was just creating too complex of a type. I could use a ToRefs writable object, or DeepReadonly object only.

Below is the type used when creating a DeepReadonly (with readonly()) in Vue3.

type Primitive = string | number | boolean | bigint | symbol | undefined | null
type Builtin = Primitive | Function | Date | Error | RegExp
export type DeepReadonly<T> = T extends Builtin
  ? T
  : T extends Map<infer K, infer V>
    ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
    : T extends ReadonlyMap<infer K, infer V>
      ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
      : T extends WeakMap<infer K, infer V>
        ? WeakMap<DeepReadonly<K>, DeepReadonly<V>>
        : T extends Set<infer U>
          ? ReadonlySet<DeepReadonly<U>>
          : T extends ReadonlySet<infer U>
            ? ReadonlySet<DeepReadonly<U>>
            : T extends WeakSet<infer U>
              ? WeakSet<DeepReadonly<U>>
              : T extends Promise<infer U>
                ? Promise<DeepReadonly<U>>
                : T extends {}
                  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
                  : Readonly<T>
Enter fullscreen mode Exit fullscreen mode

Discussion (0)

pic
Editor guide