Getting more out of your Pinia Stores  — Vue Amsterdam Conference 2022 Summary series — Second Talk

Mohsen VaziriMohsen Vaziri
14 min read

Welcome! Happy to see you in the second part of my Vuejs Amsterdam Conference 2022 summary series, in which I share a summary of all the talks with you.

You can read my JSWorld Conference 2022 Summary series (in four parts) here, where I summarized all the talks of the first day. You can also find the first Talk where Evan You talks about the State of Vue in 2022 here.

(Recurring) Introduction

After two and a half years, JSWorld and Vue Amsterdam Conference were back in Theater Amsterdam between 1 and 3 June, and I had the chance to attend this conference for the first time. I learned many things, met many wonderful people, spoke with great developers, and had a great time. On the first day the JSWorld Conference was held, and on the second and third days, the Vue Amsterdam.

The conference was full of information with great speakers, each of whom taught me something valuable. They all wanted to share their knowledge and information with other developers. So I thought it would be great if I could continue to share it and help others use it.

At first, I tried to share a few notes or slides, but I felt it was not good enough, at least not as good as what the speaker shared with me. so I decided to re-watch each speech, dive deeper into them, search, take notes and combine them with their slides and even their exact words in their speech and then share it with you so that what I share with you is at least at the same level as what I learned from them

A very important point

Everything you read during these few articles is the result of the effort and time of the speaker itself, and I have only tried to learn them so that I can turn them into these articles. Even many of the sentences written in these articles are exactly what they said or what they wrote in Slides. This means if you learn something new, it is because of their efforts. (So if you see some misinformation blame them, not me, right? xD)

Last but not least, I may not dig into every technical detail or live codings in some of the speeches. But if you are interested and need more information, let me know and I’ll try to write a more detailed article separately. Also, don’t forget to check out their Twitter/Linkedin.

Here you can find the program of the conference:

JSWORLD Conference


Getting more out of your Pinia Stores

Eduaro San Martin Morote - Creator at Vue-Router

Pinia might be a light library with a simple API but it takes advantage of many Vue Reactivity concepts like Effect Scopes, which are unknown by most. In this talk we will go through some of the internals of Pinia, understanding them and discovering how to enhance our usage of Pinia.

During the time I was creating Pinia, I had a lot of experimenting going on with the Refs and Reactives and the whole reactivity system of Vue, which gave me a lot of insights on how to keep that one single source of truth that we love so much in Vue.**

State Alchemy with Pinia

Pinia is the successor of Vuex, without compromising all the developer experience that comes with Vuex. It is compatible with Vue 2 and Vue 3, and it’s lighter and Vuex.

One of the advantages of Pinia is that it’s Fully (automatically) type-safe. This library is based on the Composition API, but you don’t need to use the Composition API to use Pinia.

It has a testing library and a Nuxt module as well.

Vuex vs Pinia

In Vuex you have some important rules:

  • You can only change the state in mutations.
  • You have to shout in action if you want to do it! (That’s why we write it in uppercase!)
  • Mutations have to be synchronous and only actions can be asynchronous
// Vuex

import { createApp } from 'vue'
import { createStore } from 'vuex'

// Create the store instance
const store = createStore({
    state: () => ({ count: 0 })
    mutations: {
        INCREMENT: state => { state.count++ }
    },
    getters: {
        double: state => state.count * 2
    },
    actions: {
        async increment ({ commit }) {
            await someAsyncCall()
            commit('increment')
        }
    }
})
// You have to use it as an Application plugin
createApp(App).use(store).mount('#app')

Now let’s take a look at Pinia:

// Pinia

import { createApp } from 'vue'
import { defineStore, createPinia } from 'pinia'

const useStore = defineStore({
    state: () => ({ count: 0 })
    getters: {
        double: state => state.count * 2
    },
    actions: {
        async increment () {
            await someAsyncCall()
            // There is no mutation, you can access the state with this keyword
            this.state++
        }
    }
})

createApp(App).use(createPinia()).mount('#app')

There aren’t many changes, except you no longer have to shout to change the state, and there is a function that is not creating a store, it’s defining a store. The difference is, that function is going to return another function that we call to get the actual store. The reason is to handle all the different ways we use the store to have an out of the box dynamic module registrations. In the end, CreatePinia() the function creates the Pinia instance.**

What does the Pinia instance really do?

If you look at the Pinia instance you’re going to notice two things.

A use() function which is for Pinia plugins.

A state, which is just a ref of an initially empty object which get’s populated after you call some stores.

That state is instantiated inside its own EffectScope.

To explain EffectScope we can talk about components. When you have a component that is mounted, it’s going to create its own EffectScope , which is going to collect all the reactivity variables like watchers, computed, etc. under its umbrella, and when the component is unmounted, this EffectScope is disposed and all the variables go away.

It’s the same for Pinia, except it never disappears and is always there.

Pinia Holds State

The stores are never going to hold the state by themselves, they're going to transfer that to the Pinia instance. So you can access the state at any time with pinia.state.value.

The first time you call the useSomeStore() in your application, it’s going to put the initial state into the store using the id of the store as the key.

const authStore = useAuthStore()

// Here the id will be "auth"
pinia.state.value.auth = { /* ... */ }

// If we call another store, It will add another object
const cartStore = useCartStore()

pinia.state.value.cart = { /* ... */ }

So as the user navigates through the application, the store keeps growing as it needed.

pinia.state.value = {
    auth: { /* ... */ },
    cart: { /* ... */ },
    // other stores' state
}

Pinia connects state

You can access the whole state of a store through $state. This variable is just a getter to access the store in Pinia.

There are three different ways of accessing a state in the store.

  • Through the Pinia instance: pinia.state.value.auth.user.login
  • Through $state on the store instance: authStore.$state.user.login
  • Directly through the store instance: authStore.user.login

Any of these variables is going to yield the same result, and also if you write to any of these variables you are also going to write to the three of them, in fact, you are only writing to the Pinia state, because there is only one source of Truth.

In Vue composition API there is a toRef function that gives you a ref to a reactive object. So if you have something that is reactive, you can get a ref to any part of the object and it will be connected and if one of them changes, the other one will change as well (and you have only one single source of truth).

When we define the store with defineStore :

const useAuthStore = defineStore('auth', {
    state: () => ({
        user: {
            login: 'alice',
        },
    }),
}),

// pinia.state.value.auth

and when we instantiate the store with the useAuthStore function and pass an object to the state, effectively what is happening is that we are getting a ref out of the state.

useAuthStore()

pinia.state.value.auth = {
    user: {
        login: 'alice',
    },
}

authStore.user = toRef(pinia.state.value.auth, 'user')

We don’t have to write authStore.user.value.login, we just read the property or write to it using .value which is called ref unwrapping.

authStore is a reactive object so any ref passed inside is going to get unwrapped.

Ref Unwrapping

When you create a reactive object, any ref passed inside at any level of that object gets automatically unwrapped, so you don’t need .value anywhere and is gonna be type-safe.

pinia.state = ref({}) // or reactive({})
pinia.state.value.auth = {
    user: {
        login: ref('alice'), // Don't do this! 🙅🏻‍♂️
    },
}
pinia.state.value.auth.user.login // alice

You should never write a ref inside a reactive object because there is no point in doing so, writing ref(’alice’) in the code above is the same as writing the string ‘alice’.

But what is interesting is not refs themselves, but refs with all their behaviors attached to them.

Composables

Composables are functions that have a stateful return. One of the common composables is useLocalStorage which is from a library called VueUse, and it gives you a ref that is going to read from and write to local storage.

pinia.state = ref({}) // or reactive({})
pinia.state.value.auth = {
    user: {
        login: useLocalStorage('pinia/user/login', 'alice'),
    },
}
pinia.state.value.auth.user.login // alice

This means that we can call that composable inside of the state function when we define a store:

import { useLocalStorage } from '@vueuse/core'

const useAuthStore = defineStore('auth', {
    state: () => ({
        user: {
            login: useLocalStorage('pinia/user/login', 'alice'),
        },
    }),
})

So if you have composables that return a writable source of truth (like a ref), you can put them inside state and they are going to get unwrapped. So the actual behavior that may be interesting for us becomes an implementation detail we don’t need to care about anymore because it is just going to work out of the box. For example useColorMode or useRouteHash.

But if you have a read-only state — which is more like a computed property — or you have a function — which is more like an action — it’s going to be a little different and we will see later how to use them.**

When does this not work?

If you are doing CSR it works fine. However, if you are doing SSR or SSG there will be some problems.

The problem is the state function is only called once the first time we instantiate the store.

In CSR, we start with an empty state, then we call the store with useAuthStore() and that’s going to instantiate the state and become reactive, easy!

In SSR, we do the same, But the problem is when the page comes back to the client on the browser, although we have the Client Side Rendering again, but on the server, the state was already executed and it was already instantiated once and we are not going to do that again on the client. That means we don’t get an empty object here and we get the object from the server.

So when we call useAuthStore, useLocalStorage is no longer called, and we no longer have that composable creating all the watchers, connecting all the event listeners to the window, it becomes just a string and probably the server is going to give us ‘alice’ because there is no local storage.

But what do we need to make this work on SSR?

We need an extra function called hydrate() that is going to create the watchers and event listeners on the client-side. This function is called if the initial state exists at store creation.

import { useLocalStorage } from '@vueuse/core'

const useAuthStore = defineStore('auth', {
    state: () => ({
        user: {
            login: useLocalStorage('pinia/user/login', 'alice'),
        },
    }),
    hydrate(state, initialState) {
        state.user.login = useLocalStorage('pinia/user/login', 'alice')
    },
})

So that state being set by UseAuthStore is going to be replaced by the hydrate function. To recap:

On SSR:

  • We have an initial state:
pinia.state.value: {}
  • We call useAuthStore()
useAuthStore()
  • We have the new state:
pinia.state.value: {
    auth: { /* ... */ },
}

and that is going to be sent to the client. Then on CSR:

  • We start with that state
pinia.state.value: {
    auth: { /* ... */ },
}
  • We call useAuthStore()
useAuthStore()
// does not call state()
  • And then we have the hydrate function adding the little bit that we were missing from the state function again
state.user.login = useLocalStorage('pinia/user/login', 'alice')

Options Store vs Setup Store

Options store which is pretty much like options API is what we talked about so far.

Setup stores are stores that are defined with a function similar to the setup function in composition API.

const useAuthStore = defineStore('auth', () => {
    const user = useLocalStorage(...)

    return { user }
})

So in this case we don’t have multiple properties like state, getters, and actions, we just have one function and it has to create reactive objects if you want a state and return them, and it’s going to have computed for getters and functions for actions.

const useAuthStore = defineStore('auth', () => {
    const user = useLocalStorage(...)

    const isLoggedIn = computed(() => /* ... */)

    function login(user, password) { /* ... */ }
    function logout() { /* ... */ }

    return { user, login, logout, isLoggedIn }
})

In the end, you return anything you want to expose and you can keep anything you want to keep private.

Because we have just one function that defines everything, we can not just not call it! We have to call it both on the server and the client. We need to hint to Pinia that e.g. useLocalStorage shouldn’t be hydrated.

import { defineStore, skipHydrate } from 'pinia'

const useAuthStore = defineStore('auth', () => {
    const user = skipHydrate(useLocalStorage(...))

    const isLoggedIn = computed(() => /* ... */)

    function login(user, password) { /* ... */ }
    function logout() { /* ... */ }

    return { user, login, logout, isLoggedIn }
})

What happens behind the scene is that normally when we’re on the client after the server has rendered the application, the store goes through all the different properties that have been put into the initial state, takes the values from the initial state, and put them into the store state; more exactly into the ref that we return in the function: user.value = pinia.state.value.auth.user // alice

That ref created by the store is then transferred into the Pinia state so that we have one single source of truth: pinia.state.value.auth.user = user // 1 source of truth

The thing is with skipHydrate, we are skipping the first line, so the user.value = … that’s no longer happening.

We should not use skipHydrate everywhere. If we look at the useEyeDropper for example:

import { defineStore, skipHydrate } from 'pinia'
import { useEyeDropper } from '@vueuse/core'

const useColorStore = defineStore('colors', () => {
    const { isSupported, open, sRGBHex } = useEyeDropper()
    // ...
    return {
        lastColor: sRGBHex, // Ref<string>
        open, // Function
        isSupported, // Boolean
    }
})

Among these three, only the lastColor is going to become a state.

What is interesting is that we can stack these refs together. So we can pass the sRGBHex

ref to the local storage.

import { defineStore, skipHydrate } from 'pinia'
import { useEyeDropper, useLocalStorage } from '@vueuse/core'

const useColorStore = defineStore('colors', () => {
    const { isSupported, open, sRGBHex } = useEyeDropper()
    const lastColor = useLocalStorage('lastColor', sRGBHex)
    // ...
    return {
        lastColor, // Ref<string>
        open, // Function
        isSupported, // Boolean
    }
})

In this scenario, we want to use skipHydrate, because the useLocalStorage relies on what the browser is seeing and doesn’t care at all about the server response on the initial value for the lastColor. If we were using only the useEyeDropper, wouldn’t need the skipHydrate because most of the time we don’t care if we hydrate from the server-side response or not, because there is not a new value in the browser context that is interesting for us and that could get overwritten by what the server sent. But if we use the local storage then we need to make sure that the one in the browser prevails and the server one is just ignored.

So what you need is not to use skipHydrate on every single ref, just on the ones that you want to avoid setting the value from the initial state, and only on SSR of course.

Summary

Options Store

  • Can use basic Composition API inside state()
  • Can only return writable state
  • Use hydrate() for composables

Setup Store

  • Use any Composable (like in setup())
  • Automatically tells apart state from actions from getters
  • Use skipHydrate() to ignore values from the server

End of the second Talk

I hope you enjoyed this part and it can be as valuable to you as it was to me.

You can find the next talk about Histoire here

2
Subscribe to my newsletter

Read articles from Mohsen Vaziri directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mohsen Vaziri
Mohsen Vaziri

A fast learner and self-motivated Full-stack Web Developer with more Frontend experience, who really enjoys designing and building products with a strong focus on clean architecture with superior attention to detail, and who is able to work alongside other talented IT professionals in creating websites and web apps to the very highest standards. I'm continually evaluating and upgrading my skills to stay at the cutting edge of Web Development. I could consider myself a natural problem solver and solution maker, who has proven himself by successfully completing projects for companies and clients.