Top 5 mistakes to avoid when using Pinia

Top 5 mistakes to avoid when using Pinia

This article provides some common mistakes when using Pinia and how to fix them.

Eduardo San Martin Morote

Eduardo San Martin Morote

November 28, 2023

Pinia, the official state management solution for Vue 3, just reached 4 years old! 🎉 This means we’ve had plenty of time to see it in action, to see it succeed but also fail. Let me share with you some of the most common mistakes I have seen in projects using Pinia and how to fix them.

Here's the TL;DR:

  • Calling useStore() in the wrong places
  • YOLO casting empty objects
  • Using reactive() for objects that can be replaced
  • Using Deep reactivity with large collections
  • Storing URL state in the store

Let's dive into each of these mistakes and how to fix them!

Calling useStore() in the wrong places

Does this message ring a bell?

[🍍]: "getActivePinia()" was called but there was no active Pinia. Did you forget to install pinia?
  const pinia = createPinia()
  app.use(pinia)
This will fail in production.

In Pinia, all stores are defined with defineStore(), which doesn't return a store instance like we had in Vuex:

import { createStore } from 'vuex'
const store = createStore({
  state: () => ({
    isHidden: true,
  }),
  mutations: {
    SET_HIDDEN(state, isHidden) {
      state.isHidden = isHidden
    },
  },
})

This store object could be used right away and was accessible in components through $store. In Pinia, defineStore() returns a function that we need to call to get the store instance:

import { defineStore } from 'pinia'
const useModalStore = defineStore('exit-modal', {
  state: () => ({
    isHidden: true,
  }),
  // ...
})

This function is in fact a composable and that gives away where it should be called: within the setup() of components. Wait, that's it? No, not at all! You can technically also call it within other composables as long as they are called within the setup() of components. But you can also call them within other stores and within some special functions:

Here is an example calling it within a store:

import { defineStore } from 'pinia'
const useModalStore = defineStore('documents', () => {
  const auth = useAuthStore
  async function createDocument() {
    if (!auth.isAuthenticated) {
      throw new Error('You need to be authenticated to create a document')
    }
    fetch('/api/documents', {
      method: 'POST',
      headers: {
        // create the document owned by the current user
        Authorization: `Bearer ${auth.token}`,
      },
    })
  }
  // ...
})

Pretty useful, isn't it!

But what are these special functions? Usually, they come from other libraries that are connected to the Vue app through a nice advanced API [runWithContext()](https://vuejs.org/api/application.html#app-runwithcontext). I am certain you won't need to use this method in your app but it allows libraries like Vue Router and Pinia to use composables that rely on [inject/provide](https://vuejs.org/guide/components/provide-inject.html#provide-inject). This includes using the router within stores and using the stores within navigation guards!

import { defineStore } from 'pinia'
router.beforeEach((to, from) => {
  // ✅ use the store within a navigation guard
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return '/login'
  }
})

But wait, I've used the store in other places before and everything worked just fine 🤔. This limitation is only important when dealing with SSR (Server-Side Rendering). In Single Page applications, there is no risk of cross-state pollution (fancy way of saying your application is not secure). Therefore, you can just call the useStore() function anytime after installing the pinia plugin:

const pinia = createPinia()
app.use(pinia)
useStore() // ✅ works

That being said, I recommend you to stick the rules mentioned above rather than relying on the fact that it works in SPA. It will make your code more predictable and easier to maintain.

YOLO casting empty objects

I have seen this mistake a lot in the past year. It's a very easy one to make and it's not only related to Pinia, but also TypeScript. Very often you have objects that start undefined but they get populated before you get to use them. One example is the user object in an authentication store:

import { defineStore } from 'pinia'
const useAuthStore = defineStore('auth', () => {
  const user = ref()
  async function updateAvatar(url: string) {
    // ! TS Error: Object is possibly 'undefined'.
    fetch('/api/user/' + user.value.id, {
      method: 'PATCH',
      body: JSON.stringify({ url }),
    })
  }
  return { user }
})

The error here is totally normal and actually a good thing: it forces out to verify that the user is authenticated before calling updateAvatar(). But it's also annoying because we know that the user will be defined before we call updateAvatar(). So, depending on your experience with TypeScript, you might be tempted to do this:

const user = ref({} as User)

And poof! The error is gone! But what did we just do? We just told TypeScript that the user is always defined, even when it's not. I like to call this the YOLO cast. It's, in my opinion, worse than using as any or @ts-ignore because with those, we are accepting that we couldn't type something properly. So we just decide that we still need to get that feature shipped and we'll come back to it later (but we never do). Casting an empty object to a type is, however, straight-out lying to ourselves. The object is right there, empty, it's clearly not a user. We are making it impossible for TypeScript to detect certain runtime errors like nested objects read:

// ! JS Error: Cannot read property 'dashboard' of undefined
user.value.permissions.dashboard

The proper way is to pass the User type to ref():

const user = ref<User>()

This will make user.value be of type User | undefined and TypeScript will force us to check if the user is defined before accessing its properties. Another possible version is using ref<User | null>(null) with the explicit initial value.

Using reactive() for objects that can be replaced

reactive() is really neat because it allows us not to write .value everywhere. But it's also limited to objects and you can't replace the object itself. Let me show you what I mean:

export const useTodosStore = defineStore('todos', () => {
  const items = reactive([])
  function addTodo(todo: Todo) {
    items.push(todo) // works without .value 🙌
  }
  // ! Syntax Error 👍
  items = []
  // ...
  return { items }
})

If we try to reassign a value to items, we will get a Syntax Error, which is great! This error won't go unnoticed. The real problem comes when we use this outside of the store:

const todos = useTodosStore()
// No syntax error, no TS error 🤔
todos.items = []

This is supposed to clear the todo list, except it doesn't. And worst of all is that we don't get any error about it 😱. At first, it will look like it worked but what we end up doing is disconnecting todo.items from the actual source of truth, located at store.$state.items. This will break Devtools, SSR Hydration, and Plugins. Overall, we end up with bugs that are really hard to trace.

My recommendation is to stick to ref() for arrays and objects. You can still use reactive() with collections like Set and Map because they are less likely to be replaced thanks to their method clear(). You can also stick to ref() in general, of course!

Using Deep reactivity with complex data

In Vue, Deep reactivity is the default. This is convenient. Things just work. However, it comes at the cost of performance when dealing with complex data like large collections that never change. One common example is fetching large set of data like products that we display on the page. They are usually fetched as a whole:

const products = ref([])
const products.value = await fetchProducts()

While the overhead is, in most scenarios negligible. It can become a problem when the data is large and the user is on a slower device. Opting out of Vue's deep reactivity change is a very simple change, it comes down to using one of the shallow equivalents or the [markRaw()](https://vuejs.org/api/reactivity-advanced.html#markraw) helper.

const products = shallowRef([])

With shallowRef() only .value = ... will trigger reactivity. Very limited, but enough in some cases.

If you want to learn more about this topic, I recommend you to read this page in Vue.js Documentation about using shallow reactivity to reduce reactivity overhead.

Storing URL state in the store

I'm pretty much giving away the solution in the title so let's explore the problem first. Let's say we have a page that displays a list of products. We want to allow the user to filter the products by category. The user selects a filter on the page and we use that filter on our store:

import { defineStore } from 'pinia'
const useProductsStore = defineStore('products', () => {
  const products = ref([])
  const category = ref('')
  const filteredProducts = computed(() =>
    products.value.filter((product) => product.category === category.value)
  )
  return { products, category, filteredProducts }
})

While this solution works while the user is on the page, the selected category is completely lost if they reload the page or share the link with a friend. And it's such a shame, because the fix is so simple and it improves the user experience so much!

Within stores, we can get the current route with useRoute(), and the router instance with useRouter(). This allows us to create a computed property that returns the category from the URL but also, that can be set to push to the URL:

const category = computed({
  get: () => route.query.category,
  set(category) {
    // that's right, we just pass the query!
    router.push({ query: { category } })
  },
})

And that's it! Now the user can share the link with their friends and they will see the same products. They can also reload the page and the category will still be selected. This is a very simple example but it can be applied to any other state that you want to keep in the URL. You can even use VueUse's [useRouteQuery()](https://vueuse.org/router/useRouteQuery/#useroutequery) or roll your own composable to handle other types of values like numbers, booleans, and arrays.

Conclusion

Avoiding these common mistakes ensures a smoother and more maintainable application development process with Pinia. I hope you find these tips useful in your own projects. If you have any questions or want to share your own experience, please, reach out to me on Twitter.

The Mastering Pinia Course is Here!

Get a free lesson delivered to your inbox, just click on the button below.

Buy Now