Skip to content

Latest commit

 

History

History
818 lines (572 loc) · 23.2 KB

0000-vuex-5.md

File metadata and controls

818 lines (572 loc) · 23.2 KB
  • Start Date: 2021-03-02
  • Target Major Vue Version: 3.1.x
  • Target Major Vuex Version: 5.0.0
  • Reference Issues: N/A
  • Implementation: N/A

Summary

Introducing a brand new Vuex for Vue 3. It's designed to improve various architecture and API of Vuex from what we've learned from past years. Here are the key differentiators from Vuex 3 & 4.

  • 2 syntax support for the store creation, options api, and composition api.
  • No mutations. Only state, getters, and actions.
  • No nested modules. Only stores. Compose them instead.
  • Complete TypeScript support.
  • Transparent, automated code splitting.

Basic example

import { createVuex, defineStore } from 'vuex'

const vuex = createVuex()

const useCounter = defineStore({
  key: 'counter',

  state: () => ({
    count: 1
  }),

  getters: {
    double() {
      return this.count * 2
    }
  },

  actions: {
    increment() {
      this.count++
    }
  }
})

const counter = vuex.store(useCounter)

counter.count  // 1
counter.double // 2

counter.increment()

counter.count  // 2
counter.double // 4

Motivation

Vuex was introduced as an official Flux-like implementation of a centralized state management solution for Vue. While it serves the purpose of implementing a Flux architecture in Vue, it also has a great responsibility to provide an official way to share the state between Vue Components which are not in parent-child relationship.

Getting feedbacks until now, seeing the rise of Vue Composition API and many alternative state management solutions, this RFC focuses on making Vuex an official global state management tool for Vue, rather than it being a Flux library.

Having flux architecture is not a requirement for global state management, but it's just one of the best practices. For global state management, what we really need is;

  • Define global states, and provide a way to reference and mutate them.
  • Code splitting
  • Support SSR.
  • Support Vue Devtools.
  • Extensible to implement any other state management solution on top of it.

We got lots of questions saying "do we still need Vuex for Vue 3? (in favor of Composition API)". But It would take a lots of effort to support all of these features with just plain composition functions. Vuex should provide all of the features required to consume the global state management in the Vue app and its ecosystem.

Hopefully, the ideal scenario could be users to first reach out to Vuex for global state management, and then build a more advanced global state management feature on top of Vuex so that they don't have to worry about SSR or Devtools support. It should benefit other frameworks such as Nuxt to be compatible with such advanced state management tool.

High level walk through

Vuex 5 introduces quite new ideas on how to define, create, and manage the store. In this section, we'll walk through each step of the new Vuex usage.

Defining a Store

A store can be defined using defineStore function. The function will return a composable function that can be used to create a store. At here, we're defining a "counter" store and naming the returned function as useCounter.

import { defineStore } from 'vuex'

export const useCounter = defineStore({
  key: 'counter',

  state: () => ({
    count: 1
  }),

  getters: {
    double() {
      return this.count * 2
    }
  },

  actions: {
    increment() {
      this.count++
    }
  }
})

The differences from Vuex 3 & 4 are that:

  • No "mutations". "Actions" can directly mutate the state.
  • We may access other store properties through this context, just like in Vue Component. For example, if we want to access count state in increment action, we can simply reference this.count property.

Using a Store

As the name defineStore suggests, useCounter is not the actual store instance. In order to interact with this store, we must first create a new Vuex instance, and create a new store instance through vuex.store method.

We can create a new Vuex instance via createVuex method.

import { createVuex } from 'vuex'

const vuex = createVuex()

In Vuex 5, the store is an individual component that acts very similar to "modules" in Vuex 3 & 4. The new Vuex instance will behave as a container for those stores.

After creating a Vuex instance, we may now create the "counter" store by passing the useCounter function to the vuex.store method.

const counter = vuex.store(useCounter)

The created store instance will have all properties (state, getters, and actions) directly mapped to the instance it self. Which means, you may call any defined properties like this.

// Reference state.
counter.count // <- 1

// Reference getters.
counter.double // <- 2

// Call actions.
counter.increment()

There's no getters or dispatch method to access store properties. We can access everything, just like how we would access data or methods in Vue Component.

Accessing a store in Vue Component

Now we know how we can define and use a store, but how do we use it inside Vue Component? To use the store inside Vue Component, we must first register the Vuex instance to the Vue App instance through app.use method.

import { createApp } from 'vue'
import { createVuex } from 'vuex'
import App from '@/App.vue'

const app = createApp(App)

const vuex = createVuex()

app.use(vuex)

app.mount('#app')

After installing Vuex, you can retrieve the store via the injected this.$vuex.store method.

import { useCounter } from '@/stores/counter'

export default {
  computed: {
    counter () {
      return this.$vuex.store(useCounter)
    },

    count() {
      return this.counter.count
    },

    double() {
      return this.counter.double
    }
  },

  methods: {
    increment() {
      this.counter.increment()
    }
  }
}

We may retrieve the store by using the mapStores helper function as well.

import { mapStores } from 'vuex'
import { useCounter } from '@/stores/counter'

export default {
  computed: {
    ...mapStores([
      useCounter
    ]),

    count() {
      return this.counter.count
    },

    double() {
      return this.counter.double
    }
  },

  methods: {
    increment() {
      this.counter.increment()
    }
  }
}

In Composition API, you may directly call the store definition without passing it to the vuex.store method.

import { useCounter } from '@/stores/counter'

export default {
  setup() {
    const counter = useCounter()

    return {
      counter
    }
  }
}

Detailed design

In this section, we'll go through each API details.

Store Definition

Vuex 5 comes with 2 different syntax support for defining a store. The option syntax and composition syntax. From here on, we'll refer to them as "Option Store" and "Composition Store".

Option Store

An option store can be defined as below.

import { defineStore } from 'vuex'

const useCounter = defineStore({
  key: 'counter',

  state: () => ({
    count: 1
  }),

  getters: {
    double() {
      return this.count * 2
    }
  },

  actions: {
    increment() {
      this.count++
    }
  }
})
  • key - The unique identifier for the store. It's used to identify the store in Dev Tool and SSR hydration process. Because we must be able to serialize the key, it has to be a string.
  • state - Same as state in Vuex 3 & 4. However in Vuex 5, it must be a function.
  • getters - Similar to getters in Vuex 3 & 4, but it will not receive any arguments. To reference the state, access it through this context.
  • actions - Similar to actions in Vuex 3 & 4, but it can mutate the state directly. It may be an async function as well. actions will not take context, but you may define any arguments like any ordinal functions. You may access state and getters through this context as same as getters.

Option Store aligns very well to Vue Component option syntax. state, getters, and actions can easily move into Vue Component as data, computed, and methods property. They behave almost identical as well.

Composition Store

A composition store can be defined as below.

import { ref, computed } from 'vue'
import { defineStore } from 'vuex'

const useCounter = defineStore('counter', () => {
  const count = ref(1)

  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return {
    count,
    double,
    increment
  }
})

The 1st argument passed to defineStore function is identical to key property for Option Store. It serves as an identifier for the store. The 2nd argument is the setup function. It behaves very similarly to Vue's setup hook in composition api.

In fact, see how it uses Vue's native reactivity system, such as ref and computed to create reactive values. In Composition Store, you're free to use any other reactivity system, such as reactive.

Creating a Store via Vuex instance

To make store usable, we must pass a Store Definition to the Vuex instance. The Vuex instance is responsible for registering stores, and handle the store composition (will discuss it later).

It's mandatory to have this centralized container of stores to avoid making a store to become global singleton, and support SSR. All stores should be managed by the Vuex instance, which is most likely injected into the Vue App instance.

To create a store, we may use vuex.store method. The store method will unwrap Ref values and provide direct access to the store properties.

When you pass the Option Store to the store method, it won't make any noticeable difference.

import { createVuex } from 'vuex'
import { useOptionCounter } from '@/stores/optionCounter'

const vuex = createVuex()

const counter = vuex.store(useOptionCounter)

counter.count  // <- 1
counter.double // <- 2

However, when you pass the Composition Store to the store method, all of the ref values will be unwrapped.

import { createVuex } from 'vuex'
import { useCompositionCounter } from '@/stores/CompositionCounter'

const vuex = createVuex()

const counter = vuex.store(useCompositionCounter)

counter.count  // <- 1. No need for counter.count.value
counter.double // <- 2. No need for counter.double.value

Remember that counter.count was defined as ref, and counter.double was defined as computed, though when accessing them, we don't need to reference counter.count.value nor counter.double.value.

Automatic store registration

When creating stores through Vuex store method for the 1st time, Vuex will first generate store, make state reactive, then registers them to the internal store registry. Then when creating the same store for the second time, it will just return the store that is already registered.

This is going to eliminate the need for registering stores manually, as we did in Vuex 3 & 4 for Modules. We don't need things like registerModule method since the store registration is now completely transparent and automatic.

It also makes code-splitting easy and efficient since stores are not registered until it gets used. Bundlers such as webpack and rollup should be able to handle it automatically.

Using stores in Vue Component

In order to use store in Vue Component, at first, we'll register Vuex instance to the Vue App instance via app.use method.

import { createApp } from 'vue'
import { createVuex } from 'vuex'
import App from '@/App.vue'

const app = createApp(App)

const vuex = createVuex()

app.use(vuex)

app.mount('#app')

After registering Vuex, we may access stores in Vue Component.

In Vue Options API

When using stores in Vue Options API, we can define which stores to use with the mapStores helper function.

import { mapStores } from 'vuex'
import { useCounter } from '@/stores/Counter'

export default {
  computed: {
    ...mapStores([
      useCounter
    ]),

    count() {
      return this.counter.count
    }
  }
}

Under the hood, the mapStores will call vuex.store method to create the stores. Because it uses vuex.store method, ref values in Composition Store will be unwrapped as well. There's no need to do this.counter.count.value to access Composition Store properties.

mapStores takes an array of stores, and it uses the key property as a binding name. In the above example, useCounter is mapped as this.counter because the useCounter has key named counter.

In case we want have custom binding name, we may pass an object instead of an array.

import { mapStores } from 'vuex'
import { useCounter } from '@/stores/Counter'

export default {
  computed: {
    ...mapStores({
      myCounter: useCounter
    }),

    count() {
      return this.myCounter.count
    }
  }
}

Vue Composition API

When using stores in composition api, we may directly call store definition.

import { useCounter } from '@/stores/Counter'

export default {
  setup() {
    const counter = useCounter()

    counter.count // <- 1
  }
}

Under the hood, it calls the vuex.store method by retrieving Vuex instance via provide/inject method of Vue. Therefore, using a Composition Store also return any reactive values unwrapped.

Store Composition

We may use a store inside another store. Let's say we have another store named "greeter" defined as below.

export const useGreeter = defineStore({
  key: 'greeter',

  state: () => ({
    greet: 'Hello'
  })
})

To use this store inside the counter store, we can do so by using a store composition.

In Option Store

We can composite the greeter store to the counter store by defining greeter store in the use option.

import { useGreeter } from './greeter'

const useCounter = defineStore({
  key: 'counter',

  use: [
    useGreeter
  ],

  state: () => ({
    count: 1
  }),

  getters: {
    countWithGreet() {
      return `${this.greeter.greet} ${this.count}`
    }
  }
})

It works very similarly to how we use stores in Vue Component. As same as mapStores helper function, the use property can be defined as an object as well.

import { useGreeter } from './greeter'

const useCounter = defineStore({
  key: 'counter',

  use: {
    myGreeter: useGreeter
  },

  getters: {
    countWithGreet() {
      return `${this.myGreeter.greet} ${this.count}`
    }
  }
})

In Composition Store

Similar to when using a store inside the setup hook, we may directly call store definition.

import { useGreeter } from './greeter'

const useCounter = defineStore('counter', () => {
  const greeter = useGreeter()

  const count = ref(1)

  const countWithGreet = computed(() => {
    return `${greeter.greet} ${count.value}`
  })

  return {
    count,
    countWithGreet
  }
})

Note on Cross Store Composition (Circular Reference)

We can composite circular referenced stores as well, but with a limitation. Let's say we have "storeA" and "storeB", and both try to use each other.

In Option Store, it works without any limitation.

const useStoreA = defineStore({
  key: 'storeA',

  use: [
    useStoreB
  ],

  state: () => ({
    fooA: 1
  }),

  getters: {
    foo() {
      this.storeB.fooB // Works!
    }
  }
})

const useStoreB = defineStore({
  key: 'storeB',

  use: [
    useStoreA
  ],

  state: () => ({
    fooB: 1
  }),

  getters: {
    foo() {
      this.storeA.fooA // Works!
    }
  }
})

In Composition Store, we may only access store property inside function calls, like computed. Otherwise the store properties become undefined.

const storeA = defineStore('storeA', ({ use }) => {
  const storeB = useStoreB()

  // ERROR! `fooB` is `undefined`.
  storeB.fooB

  const bar = computed(() => {
    // Yes, it works!
    storeB.fooB
  })

  // NOPE! It wouldn't work. We get `undefined`.
  bar.value
})

This is a JavaScript limitation on how circular reference works. And in general, you should try to avoid circular reference as a best practice. If two stores need to access/operate the same piece of state, then you should "hoist" that common part into its own dedicated store.

Plugins

Vuex 5 supports the plugin feature. For example, a user might want to inject an external dependency, such as axios instance to the store. Vuex 5 provides plugins option similar to Vuex 3 & 4, but more aligned with how Vue 3 plugin works.

import { createVuex } from 'vuex'
import axios from 'axios'

function axiosPlugin(vuex, options) {
  vuex.storeProperties.$axios = axios
}

const vuex = createVuex({
  plugins: [axiosPlugin]
})

See the axios is injected into the storeProperties. Anythinfg registered to this property will be available in both Option Store and Composition Store. In the Option Store, it will be available through this context. And in Composition Store, it is passed through "context" object wich is passed as an argument to the setup function.

const useCounter = defineStore({
  name: 'counter',

  actions: {
    async fetch() {
      await this.$axios.get('...')
    }
  }
})

const useCounter = defineStore('counter', ({ $axios }) => {
  async function fetch() {
    await $axios.get('...')
  }

  return { fetch }
})

TypeScript support

Both Option Store and Composition Store are fully type-safe. In Option Syntax, getters and actions may require annotation as same as option syntax Vue Component.

const useCounter = defineStore({
  key: 'counter',

  state: () => ({
    count: 1
  }),

  getters: {
    // May require annotation as same as Vue Component.
    double(): number {
      return this.count * 2
    }
  },

  actions: {
    // May require annotation as same as Vue Component.
    increment(): void {
      this.count++
    }
  }
})

For the Composition Store, everything is correctly typed as same as Vue Composition API.

For the plugins, the plugin author must provide a typing for StoreCustomeProperties interface. This interface is extended by both Options Store and Composition Store context.

import { AxiosInstance } from 'axios'

declare module 'vuex' {
  interface StoreCustomProperties {
    $axios: AxiosInstance
  }
}

Other Advanced Features

We may provide other advanced features that are available in Vuex 3 & 4, such as watch, subscribe, etc. However, since the proposal is quite large already, those should be discussed in another proposal, or directly at issues or PRs as a feature request.

Store Serialization and Hydration

A store can be serialized so that users can save the store state to external storage, such as a cookie or index DB, and hydrate the state afterward. The same strategy applies to SSR state hydration, where the serialized state will be sent to the client through web request payload.

As an exmaple, assume we have an SSR app where the following store is used:

// universal API layer
import { fetch } from './api'

export const usePost = defineStore('post', () => {
  const post = ref(null)

  async function fetchPost(id) {
    post.value = await fetch(id)
  }

  return {
    post,
    fetchPost
  }
})

And in the route component, it calls await postStore.fetchPost(route.params.id) which fills the post store with data. Now the question is, how do we serialize the state and how do we hydrate the post with the already fetched data?

  • During serlization we want to just ignore:
    • computed refs (considered getters)
    • functions (considered actions)
  • During hydration:
    • for non-computed refs, we want to hydrate by setting its .value instead of replacing it.
    • for reactive objects, we need to mutate it in place to avoid replacing its reference.

Serialization

To serialize store, we may use vuex.serialize method. To make the example simpler, here, we use the counter store as an example.

const useCounter = defineStore('counter', () => {
  const count = ref(1)

  function increment() {
    count.value++
  }

  return {
    count,
    increment
  }
})

// create a store and increment the `count` value
const counter = vuex.store(useCounter)

counter.increment()

// serialize the store
const state = vuex.serialize()

/*
  {
    counter: {
      count: 2 // <- count is 2, because it was incremented.
    }
  }
*/

Now, users may store serialized state where ever they want.

Caveats in Composition Store

When serializing Option Store, there'll be no problem, though in Composition Store, we must always remember to "expose all state which might be mutated". For example, it's not possible to hydrate "hidden" property like this.

const useCounter = defineStore('counter', () => {
  const count = ref(0)

  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  // `count` is not exposed! We can't serialize such hidden value.
  return {
    double,
    increment
  }
})

In Option Store, there's no way to "hide" state, so this only applies to the Composition Store.

Hydration

We may use vuex.setState method to hydrate the store by given serialized state.

const state = {
  counter: {
    count: 2
  }
}

vuex.setState(state)

const counter = vuex.store(useCounter)

counter.count // <- 2

When a state is set via setState method, the store gets hydrated during the store creation time. Therefore, note that setState must be called before creating any store. Otherwise, it wouldn't take any effect.

The core feature will be provided via Vue core

The core feature to serialize and hydrate reactive object will be provided through Vue core, since this is useful for general composable functions as well. The RFC for that is planned to be published.

Although, Vuex still needs to provide vuex.serialize method and vuex.setState method to serialize/hydrate "multiple stores" in one shot.

Vue Devtools support

It's essential for Vuex to have good Devtools support, and it will. However, Devtools support is another large topic that should require deep discussion. We should create another proposal focusing only on Devtools support exclusively.

The brief idea for Devtools support are;

  • State can be inspected in the dev tools.
  • When the state changes, it's logged, and the log entry should provide a way for the user to easily trace back to the source code that triggered it.
  • Users should be able to "time travel" the state changes.

We should also work together with Devtools development to see if we can comeup with better Vuex debugging experience.

Drawbacks

  • The name "Vuex" might be a little confusing since initially it was named after Flux implementation. However, Vuex nowadays are referred to as more of an official global state management tool for Vue, I don't think we would have huge impact.
  • Huge migration cost if a user is moving from Vuex 3 or 4 to Vuex 5.

Alternatives

  • We don't have helper functions such as mapState or mapActions, where it might be useful in Vue Options Component. However, since Vuex 3 & 4 didn't have a way to retrieve a store as an object like Vuex 5 does, it might be not as useful as it is in Vuex 3 & 4. Therefore we're excluding it in the initial spec, but it's technically possible to add such helpers, and we can always do so afterwards if we have high demands from the community.

Adoption strategy

Vuex 5 is almost completely new software compared to Vuex 3 & 4. There wouldn't be an easy migration, though, with community effort, we might be able to create a plugin that offers a similar API to Vuex 3 & 4. It might be one idea to make migration a bit easier.

Unresolved questions

None.