Now that Pinia is the officially recommended global state management solution for Vue.js, are you eager to migrate your global store from Vuex? If so, you’ve come to the right place! Let me show you how I did just that, using the code-base for our Vue.js 3 Master Class.
Before we dive into the how-to, let’s quickly discuss why you might consider doing this. Some advantages of Pinia over Vuex include:
Personally, I think the DX improvement in Pinia justifies the switch but weigh the pros and cons for yourself. Depending on the size of your app and the preferences of your team, it might not make sense to migrate right away. As far as I know, support for Vuex isn’t going anywhere anytime soon. But, if you’re convinced it’s time to migrate to Pinia then this article is for you.
To help you best follow this tutorial, first there are a couple things you should know about the code-base we’re working on.
With this in mind, a couple of these steps might look a little different for you depending on your setup, but it should be mostly the same.
The first step in migrating is installing the pinia npm package.
npm install pinia
I chose to leave Vuex installed during the migration and then only remove it once the migration is complete. This strategy means the app will continue to run and I can incrementally migrate one store at a time.
Next, you’ll want to install the Pinia plugin in main.js
.
import { createPinia } from 'pinia'
app.use(createPinia())
After this, I started up the dev server and got a bunch of compile errors that looked like this.
This is specific to WebPack and can be fixed with the following config added to vue.config.js
.
module.exports = {
configureWebpack: {
module: {
rules: [
{
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto'
}
]
}
}
}
You can see this issue in github for more info if you’re interested.
Next, I chose my Vuex module called auth.js
to convert to a Pinia store. It was by no means the shortest of my stores but I think it makes for the best example for the tutorial. Here is a stripped down version of it:
// src/store/modules/auth.js (Vuex)
export default {
namespaced: true,
state: {
authId: null,
authUserUnsubscribe: null,
authObserverUnsubscribe: null
},
getters: {
authUser: (state, getters, rootState, rootGetters) => {
return rootGetters['users/user'](state.authId)
}
},
actions: {
signInWithEmailAndPassword (context, { email, password }) {
return firebase.auth().signInWithEmailAndPassword(email, password)
},
async signOut ({ commit }) {
await firebase.auth().signOut()
commit('setAuthId', null)
},
//9 more actions existed here...
},
mutations: {
setAuthId (state, id) {
state.authId = id
},
//2 more mutations existed here
// (each for updating a single piece of the state above 🤮)...
}
}
We can create something similar to this Vuex “module” in Pinia by defining what’s known as a Pinia “store”. It’s common convention to create Pinia stores in a directory called stores
and I like to name them with the following formatting: [StoreName]Store.js
. So that’s what I’m doing here with the boilerplate code for a Pinia store.
// src/stores/AuthStore.js (Pinia)
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('AuthStore', {
state: () => {
return {
}
},
getters: {},
actions: {}
})
Now we can start moving things from the Vuex auth
module to the Pinia AuthStore
. The state itself is pretty much a one-to-one port. I can just copy and paste it.
// src/stores/AuthStore.js (Pinia)
export const useAuthStore = defineStore('AuthStore', {
state: () => {
return {
authId: null,
authUserUnsubscribe: null,
authObserverUnsubscribe: null
}
},
//...
})
Of course, accessing the state is a different matter entirely, but we’ll get to that after we’re done converting the Vuex module definition to a Pinia store definition.
Next, I’ve got a single getter inside auth.js
that reaches into a different Vuex module to get the actual user based on the authId
in the state.
// src/store/modules/auth.js (Vuex)
getters:{
authUser: (state, getters, rootState, rootGetters) => {
return rootGetters['users/user'](state.authId)
}
}
//...
In order to focus on a single module at a time, I’ll continue to use the users
Vuex module by importing the Vuex store and accessing the getters on that, since rootGetters aren’t available in Pinia getters.
// src/stores/AuthStore.js (Pinia)
import store from '@/store' // this is the Vuex store (and is temporary until migration is complete)
//...
getters:{
authUser(){ // should be a non-arrow function now
return store.getters['users/user'](this.authId) // get authId on `this`
}
}
Notice that I no longer have to supply all the different arguments to my getter. I can also access the authId
piece of state directly on this
instead of on a passed state
parameter.
I did choose to change the function declaration to the non-arrow style so that this
would properly refer to the store. Had I kept the arrow function, I could have accessed the state like this:
authUser: (state) =>{
return store.getters['users/user'](state.authId) // get authId on `state`
}
Next up, we need to take care of the actions. This is what they look like in Vuex along with the mutations they call.
// src/store/modules/auth.js (Vuex)
export default {
actions: {
signInWithEmailAndPassword (context, { email, password }) {
return firebase.auth().signInWithEmailAndPassword(email, password)
},
async signOut ({ commit }) {
await firebase.auth().signOut()
commit('setAuthId', null)
},
//9 more actions existed here...
},
mutations: {
setAuthId (state, id) {
state.authId = id
},
// ...
}
}
The first is straight forward since it doesn’t alter any state. I just need to import firebase and remove the context
argument since Pinia actions don’t need it.
// src/stores/AuthStore.js (Pinia)
import firebase from '@/helpers/firebase'
actions: {
signInWithEmailAndPassword ({ email, password }) {
return firebase.auth().signInWithEmailAndPassword(email, password)
}
}
//...
The signOut
action does call a mutation to update the state.
// src/store/modules/auth.js (Vuex)
async signOut ({ commit }) {
await firebase.auth().signOut()
commit('setAuthId', null)
},
In Pinia, we no longer need to call mutations. Instead we can alter the state directly.
// src/stores/AuthStore.js (Pinia)
async signOut ({ commit }) {
await firebase.auth().signOut()
this.authId = null
}
This means we have no need to port any of the mutations over 💪. Furthermore, we no longer need the commit function from the context object. This concept simply doesn’t exist in Pinia.
// src/stores/AuthStore.js (Pinia)
async signOut () { //..
auth
Vuex Module to Pinia AuthStore
At this point, I migrated the other 9 actions that existed in my auth.js
Vuex module and the process looked pretty much like those steps above except for:
I also modified lines where dispatch
was used to call other actions defined in the same module to reference actions on this
// this in Vuex
*await* dispatch('fetchAuthUser')
// became this in Pinia
*await* this.fetchAuthUser()
For actions called from other stores I dispatched them temporarily from the imported Vuex store
. (I did likewise for mutations defined in the root level store but commited in auth.js
)
import store from "@/store"
// this in Vuex
dispatch('users/createUser')
// became this in Pinia (this is just temporary!)
store.dispatch('users/createUser')
// likewise this in Vuex
commit('setItem', { resource: 'posts', item }, { root: true })
// became this in Pinia (this is just temporary!)
store.commit('setItem', { resource: 'posts', item }, { root: true })
I also noted down that AuthStore
uses the Vuex users
module and a mutation from the root Vuex store. This was so that I could come back to it once the users
Vuex module had been migrated. (Don't judge my handwriting 😆)
In the rare case I defined a Vuex action with an arrow function, I converted that to a non-arrow function for Pinia so that this
would properly reference the Pinia store.
// this in Vuex
fetchAuthUser: async ({ dispatch, state, commit }) => {/*...*/}
// became this in Pinia
async fetchAuthUser () {/*...*/}
I also took special care to remove the context object as the first argument of the actions.
My remaining mutations, I left behind all together as they are not needed in Pinia 🥳.
With that, my auth.js
Vuex module was no longer needed, so I removed it’s import and registration in the root Vuex store.
// src/store/index.js
// ...
~~import auth from './modules/auth'~~ //removed this
export default createStore({
modules: {
//...
~~auth~~ // and removed this
},
//...
})
(Just before this I also cmd
clicked on the export default in auth.js
to be sure it wasn’t being used anywhere else.)
Finally I had the please of deleting that old Vuex module 🔥!
I must admit that first bit felt really nice! But we still have some work to do on the auth
Vuex module… we must now update it’s action calls and state retrievals within our components. To figure out where all these were being called I search for 'auth/
throughout the project, which gave me 11 results. Most were in components but 2 were in the file where I setup and register my routes with Vue Router.
Now I have 2 options with how to proceed. I can replace the Vuex dispatches with calls to the Pinia actions via the options API or the Composition API. Pinia is cool like that 😎. It supports both. Let’s start with the Options API.
In TheNavbar.vue
, I’m dispatching the signOut method when the user clicks a Sign Out link.
// TheNavbar.vue
<a
@click.prevent="$store.dispatch('auth/signOut'),
$router.push({name: 'Home'})"
>
Sign Out
</a>
Instead of accessing the store on $store
like with Vuex, we should import our Pinia AuthStore
composable into the component.
// TheNavbar.vue
import { useAuthStore } from '../stores/AuthStore'
Then we can also import the mapActions
helper from Pinia and use it to map our signOut action to a local component method.
// TheNavbar.vue
import { mapActions } from 'pinia'
export default{
methods:{
...mapActions(useAuthStore, ['signOut'])
}
//...
}
Lastly, we can update the click handler for the Sign Out link.
// TheNavbar.vue
@click.prevent="signout() //..."
Next, let’s take a look at using the composition API instead. In SignIn.vue
within a signIn
method, I’m dispatching the signInWithEmailAndPassword
action.
// SignIn.vue
methods:{
async signIn () {
try {
await this.$store.dispatch('auth/signInWithEmailAndPassword', { ...this.form })
this.successRedirect()
} catch (error) {
alert(error.message)
}
},
}
Once again I’ll import the useAuthStore
composable but this time I’ll call it from within the setup
option and destructure the desired action out of it.
setup () {
const { signInWithEmailAndPassword } = useAuthStore()
},
To expose the action to the rest of the component, I’ll return it from setup.
setup () {
const { signInWithEmailAndPassword } = useAuthStore()
return { signInWithEmailAndPassword }
},
Finally, I’ll replace the dispatch with a direct call to the action.
// replace this
await this.$store.dispatch('auth/signInWithEmailAndPassword', { ...this.form })
// with this
await this.signInWithEmailAndPassword({ ...this.form })
Following the pattern above, I continued replacing all instances of Vuex dispatches with Pinia action calls. At this point, I was feeling pretty confident, so I went to try my luck in the browser 🤞. Somewhat surprisingly but much to my delight the home page loaded just fine. However, in the console I get 2 store related errors.
Ah… I forgot about mapActions and mapGetters. A search of mapActions('auth reveals 2 changes I need to make…
And mapGetters('auth
three more.
The instances of mapAction
I was able to handle just like the calls to dispatch previously, however reading the getters present a scenrio we haven’t seen yet. Let’s explore these further.
In TheNavbar.vue
there’s an instance of mapGetters
being used to get the authUser
.
// TheNavbar.vue
computed: {
...mapGetters('auth', ['authUser'])
},
To make this work with Pinia and the Options API I can import mapState
from Pinia and replace the call to Vuex mapGetters
with it.
// TheNavbar.vue
import { useAuthStore } from '../stores/AuthStore'
~~import { mapGetters } from 'vuex'~~ // remove this
import { mapActions, mapState } from 'pinia' // add this
computed:{
~~...mapGetters('auth', ['authUser']),~~ // remove this
...mapState(useAuthStore, ['authUser']) // add this
}
Do take note that in Pinia there is NO mapGetters
. When used with the options API, mapState
is useful for accessing both state AND getters.
In Profile.vue
I’ve got another instance of mapGetters('auth
.
// (with Vuex)
...mapGetters('auth', { user: 'authUser' }),
This time, let’s switch it out using the composition API. Since there’s already a setup option in Profile.vue
that uses an action from the AuthStore you might think I could access the getter like so:
// (with Pinia)
setup () {
const { fetchAuthUsersPosts, authUser } = useAuthStore() // ❌ won't work
return { fetchAuthUsersPosts, authUser }
},
But this won’t work. In order to retain reactivity when destructring getters and state from stores, we need to use the storeToRefs
helper function.
// (with Pinia)
import { storeToRefs } from 'pinia'
//...
setup () {
const { fetchAuthUsersPosts } = useAuthStore()
const { authUser } = storeToRefs(useAuthStore())
return { fetchAuthUsersPosts, authUser }
},
Finally, if you’ve got a keen eye, you’ll have noticed we were renaming the authUser
getter inside the Vuex mapGetter helper to helper
.
// (with Vuex)
...mapGetters('auth', { user: 'authUser' }),
We can do this as well for Pinia.
// (with Pinia)
setup () {
//...
return { fetchAuthUsersPosts, user: authUser }
},
I also took care of the last mapGetter
this way as well.
Next, we can take care of all the instances where we’re reading state from the old Vuex module like this: $store.state.auth
.
A regular expression search reveals 3 locations that require updates.
I did the regx here so that I could search for instances with and without the starting $
. This was so that I could catch cases outside of components where I might have directly imported the store. To fix these, you can use the same solutions as for the getters.
This is the solution for PostList.vue
:
// this in Vuex
<a v-if="post.userId === $store.state.auth.authId" //...
// became this in Pinia
<a v-if="post.userId === authId" //...
//...
import { useAuthStore } from '@/stores/AuthStore'
import { mapState } from 'pinia'
//...
computed: {
...mapState(useAuthStore, ['authId']),
},
The solution for the 2 occurences in router/index.js
is similar but since we were not in the context of a component where refs are auto-unwrapped, I had to specify .value
.
// src/router/index.js
router.beforeEach(async (to, from) => {
const { authId } = storeToRefs(useAuthStore())
//...
if (to.meta.requiresAuth && !authId.value /* <- used .value here */) {
//...
}
})
The last step is to change out insances INSIDE remaining Vuex modules where the auth
module state is being accessed on rootState.auth
.
This step is optional if you plan to do all your migration at one time, however I highly suggest doing it nonetheless. Yes, you will be deleting these Vuex modules soon but it can help you focus on one store at a time, running tests, and doing manual testing after finishing each module to store transition. Plus, when you do copy your actions over to new Pinia stores for modification, this access of state from other stores will already be setup correctly. Thus, I highly recommend only moving on to the next Vuex module once the app is totally working.
A search for rootState.auth
reveals 6 results.
We can fix them all by changing out rootState.auth.authId
to the authId
from the Pinia store like this.
// /src/store/modules/posts.js
import { useAuthStore } from '@/stores/AuthStore'
//...
actions:{
async createPost ({ commit, state, rootState }, post) {
const authStore = useAuthStore() // use AuthStore
// this in Vuex
post.userId = rootState.auth.authId
//becomes this in Pinia
post.userId = authStore.authId
}
//...
}
Just make sure you’re calling const authStore = useAuthStore()
in each action that you attempt to access authStore.authId
in that way it’s actually defined in the current scope. You might be tempted to call useAuthStore()
at the top of the file but this won’t work since, you can only use Pinia stores in the context of the Vue application when the root Pinia store is defined.
After spending about 6 hours going through this process, I was able to successfully move the auth
Vuex module to a Pinia AuthStore
. It had a total of 3 pieces of state, 1 getter, and 11 actions to migrate with access to such actions and state found in 25 places throughout the app. Mind you, that time includes writing this article as well. Thus, you will probably spend less time migrating a single Vuex module to a Pinia store.
I did not finish the migration completely before publishing this article, only the migration of the single auth
module. However, my other 5 Vuex modules I suppose will probably just be a matter of “rinse and repeat”.
Once I’ve had the opportunity to finish the process I’ll release a second article detailing any further lessons learned or issues encountered.
In conclusion, the process was not a very difficult one though it does take a little time. The Vue.js 3 Master Class source code is an educational app but it is no small piece of software. It includes all the trappings of a real-world Vue.js application and should be a decent representation of a migration for your application.
So what are you waiting for? Get that migration off the ol’ backlog and into production! 😃
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.