Telerik blogs

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.

The Problem

Consider the following component tree for an app.

App points to header and settings. Settings points to usersettings, points to userform

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.

The Solution(s)

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 Up Pinia

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.

Our First Pinia 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.

State

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.

Getters

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.

Actions

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.

Wrapping Up

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!


About the Author

Marina Mosti

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.

Related Posts

Comments

Comments are disabled in preview mode.