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 2022 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.
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.
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.
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
andReactives
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.
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.
In Vuex you have some important rules:
// 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.
If you look at the Pinia instance you’re going to notice two things.
A use()
function which is for Pinia plugins.
A state is just a ref
of an initially empty object which gets 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.
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 needed.
pinia.state.value = {
auth: { /* ... */ },
cart: { /* ... */ },
// other stores' 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.
pinia.state.value.auth.user.login
$state
on the store instance: authStore.$state.user.login
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.
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.
💡 You can avoid this behavior with shallowRef()
and shallowReactive()
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 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.
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, 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.
💡 Fortunately VueUse is going to make the local storage function work on the server as well.
💡 Some composables require a little bit more work if you want to make them work on SSR. For example, you have to check your current instance or check if there is a window object that you can rely on.
If you want to learn more, you can check the source code of VueUse. It’s very comfortable because in the documentation, at the very bottom of every function you see a link to the source in the Github repository.
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 is set by UseAuthStore
is going to be replaced by the hydrate function. To recap:
On SSR:
pinia.state.value: {}
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 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 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 RGB hex
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.
state()
hydrate()
for composablessetup()
)skipHydrate()
to ignore values from the server
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.
Also published here.