For straightforward state management in Vue, let’s look at Pinia, which lets us store and share data between components and page.
There will come a time within most apps where you will have two pieces of your application that you wished shared a common reactive state. A good example of this is sharing the user information between a header component and a user edit form that is deeply nested in your application.
Consider the following component tree for an app.
Say now that you need to get a user
object that you fetched out of the API on the App
component with both the Header
and the UserForm
. You certainly could start creating a prop and emits snakes and ladders down both trees, but in a more complex application where UserForm
or any other component that requires access to shared reactive state this quickly can get out of hand.
Vue 3 comes with a couple of tools to handle such a problem. The first approach would be passing down the user
object as required down through props as we mentioned before. This can get out of hand quickly as we could potentially be dealing with an N
amount of components to put the prop down through, and if we later on need to add more properties to inject down the tree this can really add up.
A second approach would be to leverage provide
and inject
. This is beyond the scope of this article, but I can spoil it for you already and say that, while it is a solution, it’s not the best solution. Provide
and inject
are extremely powerful and flexible tools, and as any tool with the combination of these attributes it comes with a lot of drawbacks, especially when working with large teams or codebases as it can very quickly become unmanageable spaghetti.
A third approach when dealing specifically with state that is coming exclusively from your API is to use asynchronous state management libraries such as Tanstack Query if you have a RESTful API or Vue Apollo if you are using GraphQL. They are extremely powerful tools and you should get familiar with them (in my opinion), but they are designed for use with network data and not as a store.
The solution we will explore today is Pinia, a straightforward, easy-to-use state management store for Vue 3. This store allows us to share data between components and page in a seamless way while keeping a centralized state. Let’s take a look.
Setting Pinia up for a Vue 3 SPA is very straightforward. As usual, if you are building a new application from scratch, the npm or yarn create
Vue script will ask you if you want to use Pinia as your state management of choice.
To incorporate it manually into your application or to add it to an existing app, we need to follow a couple of steps.
First, add the package to your application with either npm
or yarn
.
yarn add pinia
# or
npm install pinia
We then need to navigate to our app’s main.js
or index.js
(or wherever you are mounting your app) file and use Vue.use
to add it to our application.
main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
That’s it! Now we can move on to creating our very own user store.
We are going to create a user store to continue on with our made-up application example from the tree above. To add a store, we need to create a new file that will contain the code for it, I recommend you add these files under a stores
folder to keep tidied up.
UserStore.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
})
To create a store, we use the defineStore
method exposed by pinia
. It takes two main params. The first one will be the id
of the store; it can be anything you prefer, but it needs to be unique to your application—“user” makes the most sense for our example since this is what the store will be holding.
The second param is an object that holds the store’s options, let’s take a look at what we can add here.
The first property we will add to the options is the state
, if you are familiar with Vue’s options API this will feel right at home, as the state
for a store is basically the same thing. It is a function that returns an object that will hold all the state that we want this store to keep for us. For the sake of example we will add a user
property and an isLoading
flag.
UserStore.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => {
return {
isLoading: false,
user: null
}
}
})
As quick as that we can already import this store into our mock UserForm.vue
component and make use of the isLoading
and user
states.
UserForm.vue
<template>
<p v-if="store.isLoading">Loading!</p>
<form>
<input :value="store.user.name" />
</form>
</template>
<script setup>
import { useUserStore } from './stores/UserStore.js'
const store = useUserStore()
</script>
In the above example, we are importing the useUserStore
and then calling the method. This returns to us a copy of the user store that we just created. The state in this store is shared, so any changes we do to it will of course be shared by any other components using the store.
Notice in the template we can now use the isLoading
flag to check for the state and display a friendly message, and bind the user.name
property to an input for later use.
Similar to computed
values, we can create getters inside of our store. A straightforward example would be a getter that joins the user’s name and last name. Let’s give that one a shot.
UserStore.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => {
return {
isLoading: false,
user: null
}
},
getters: {
userFullName: (state) => state.user.name + ' ' + state.user.lastName
}
})
As simply as it would be to create a computed
property we now have a userFullName
getter that we can use across our components. Let’s head to the mock Header.vue
component and use it to display a message for our users.
Note that the getter does receive a param state
with the store’s current state. It will contain any and all properties of state
we created beforehand plus any other getters as well. Additionally, it’s important to keep in mind that getters
can not be async!
Header.vue
<template>
<header>
Welcome {{ store.userFullName }}
</header>
</template>
<script setup>
import { useUserStore } from './stores/UserStore.js'
const store = useUserStore()
</script>
Note that once again we import the store and call the useUserStore
method to create it. This store points to the same state as the one we created before on the UserForm.vue
file, so if the user changes their name it will also be reflected here.
The getter we created, userFullName
, can be used directly from the store as shown in the above example.
The last option we can add to our store are actions
, these are the equivalent of a component’s methods
. As opposed to getters
, methods
can be async and can contain API calls. This is a good location to set up our logic to load the user’s data.
UserStore.js
import { defineStore } from 'pinia'
import { getCurrentUser } from './api/user'
export const useUserStore = defineStore('user', {
state: () => {
return {
isLoading: false,
user: null
}
},
getters: {
userFullName: (state) => state.user.name + ' ' + state.user.lastName
},
methods: {
async loadUser () {
this.isLoading = true
const data = await getCurrentUser()
this.user = data
this.isLoading = false
}
}
})
We’ve imported a mock getCurrentUser
API call on the top of the file, this would most likely make a GET
call to an endpoint to retrieve the user.
Then, we’ve created a new method on our store loadUser
. This method sets the isLoading
flag to true
so that the user knows something is happening, and then makes an async call to the getUser
method. When we receive the data back, we store it into our user
state and then reset the isLoading
flag to false
.
A couple of things that are missing from this example are error handling and double-checking to see if we already have a user in the state before making a new network request to refetch it, but that’s outside of the scope of this article.
Now we can go back to our UserForm.vue
component and call this method.
<template>
<p v-if="store.isLoading">Loading!</p>
<form>
<input :value="store.user.name" />
</form>
</template>
<script setup>
import { useUserStore } from './stores/UserStore.js'
const store = useUserStore()
store.loadUser()
</script>
Now when our component loads, it will call the loadUser
method of our store and load the user from the API. As soon as the user is loaded, the Header.vue
component will display the userFullName
getter we created. Remember, the user
state is shared between all of the components that are using this store.
This merely scratches the surface of what you can do with Pinia. As with any library, I recommend taking a deep dive into their documentation as it is very well written and will help you learn not only the fundamentals but also the more intricate parts of this amazing library.
Nevertheless, you are now armed with the basics and can start writing your very own Pinia stores!
Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.