diff --git a/active-rfcs/0000-vuex-5.md b/active-rfcs/0000-vuex-5.md new file mode 100644 index 00000000..1b00f6e4 --- /dev/null +++ b/active-rfcs/0000-vuex-5.md @@ -0,0 +1,818 @@ +- 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 + +```js +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`. + +```js +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. + +```js +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. + +```js +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. + +```js +// 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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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. + +```js +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`. + +```js +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. + +```js +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. + +```js +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](https://v3.vuejs.org/guide/typescript-support.html#using-with-options-api). + +```ts +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. + +```ts +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: + +```js +// 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. + +```js +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. + +```js +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. + +```js +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.