DEV Community

Cover image for Pinia - Crash Course for Beginners
Alexander Gekov
Alexander Gekov

Posted on • Edited on

Pinia - Crash Course for Beginners

Introduction

Hello everyone, in this crash course we will be talking about Pinia - the state management library for Vue. We will also be building a simple shopping cart project to showcase how Pinia stores work.

You can follow along here:

The code for the project can be found on my GitHub: https://github.com/alexander-gekov/pinia-cart-tutorial

Content

What is Pinia?

Pinia is a state management library for Vue.js.

It is the successor of Vuex the original state management system for Vue. However, nowadays Pinia is the recommended way to manage state as said by the Vue.js Core Team.

The problem that state management solves is the problem of keeping shared state across your Vue components. Without it a lot of the state would have to be passed around using endless amount of props and emits.

Prop drilling

Let’s say we have a user who is authenticated, we would like to share that state across our components. We can also later store it in a cookie or local storage so that it persists even after page loads.

User state


Pinia uses “stores” in order to manage state. A Store is compromised of:

  • state (The data that we want to share)
  • getters (A way for us to get the data from the state, read only)
  • actions (Methods that we can use to modify/mutate the data in the state)
  • mutations - One of the differences between Pinia and Vuex is that Pinia does not have explicit mutations defined in the store. They were removed from the store definition due to being too verbose. Read till the end to find out how to monitor mutations in your app.

Another differences between Pinia and Vuex is that Pinia is modular. What that means is that Pinia encourages users to have different stores, each corresponding to a different logic in our app. This is a great approach because it follows the separation of concern principle. On the other hand Vuex had developers manage a single bulky store for the whole application. You can see where I am going with this…

Pinia also has great Typescript support and autocompletion. This helps with the overall development experience, a metric that is becoming ever more important in modern frontend tools.

ts

Another reason to try out Pinia, last one I promise 😃, is that it is incredibly lightweight with a total bundle size of just 1KB.

Shopping Cart App

What we are going to build today to showcase how Pinia works is a simple ecommerce-like website. We will have some products that the user can add to their cart. The cart in the top right corner will need to keep track of the items that the user has added. It will also have to keep track of the quantity of each product as well as calculate the total count and total price.

The design was made with tailwindcss, however, this article will not be focusing on the styling part. If you want you can go to the GitHub repository and clone it to start with a simple UI template I prepared with hardcoded values.

project

Setting up a Vue.js app with Vite (using vue-ts template)

Let’s start by creating our Vue app. You can use the Vue CLI or Vite for this. I will use Vite as well as the “vue-ts” template provided by vite.



npm create vite@latest pinia-cart-tutorial -- --template vue-ts


Enter fullscreen mode Exit fullscreen mode

Once created, cd into the just created project folder and open it in VS Code. In VS Code I will open the terminal and run npm install.

Now, we can remove the boilerplate code - so remove the Hello World and any unnecessary styling. As I mentioned I won’t go over creating the components and styles, but feel free to look at the GitHub link.

Installing and registering Pinia

Let’s install Pinia by running:



npm install pinia


Enter fullscreen mode Exit fullscreen mode

Once done, we need to go to our main.ts file and register it like this:



import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'

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


Enter fullscreen mode Exit fullscreen mode

Now, Pinia should be successfully installed.

Creating the Product Pinia Store

Currently we have an array in our App.vue that we are passing to our components through props. While this is by any means, a valid and totally ok way to pass the state. Imagine if the app was more complex and contained a lot more nested components. We should create a store to keep that state of products.

In our src folder, let’s create a folder called stores. In there create a file ProductStore.ts:



import { defineStore } from "pinia";
import { computed, ref, Ref } from "vue";
import { Product } from "../types/Product";

export const useProductStore = defineStore('products', () => {
    const products: Ref<Product[]> = ref([
        {
          name: 'Bananas',
          price: 5,
          image: 'https://img.freepik.com/free-vector/vector-ripe-yellow-banana-bunch-isolated-white-background_1284-45456.jpg'
        },
        {
          name: 'Strawberries',
          price: 10,
          image: 'https://img.freepik.com/free-photo/strawberry-berry-levitating-white-background_485709-57.jpg?w=2000'
        },
        {
          name: 'Apples',
          price: 15,
          image: 'https://img.freepik.com/premium-photo/red-apples-isolated-white-background-ripe-fresh-apples-clipping-path-apple-with-leaf_299651-595.jpg'
        }
      ]); // ref = state

    const totalPrice = computed(
        () => products.value
        .map(p => p.price)
        .reduce((a, b) => a + b, 0)
    ); // computed = getter

    const addProduct = (product: Product) => {
        products.value.push(product);
    } // method = action

    return {
        products,
        totalPrice,
        addProduct
    }
})


Enter fullscreen mode Exit fullscreen mode
  1. We start by importing the defineStore method from pinia. We then use it to export a const called useProductStore. It is a common convention to prefix stores and composables with the “use” word.
  2. defineStore accepts two arguments, the first being the name of the store, and the second depending on how we write our store (Options API vs Composition API) will either be the object containing the state, getters and actions or a callback that will return an object with the state, getters, actions.
  3. In our store, we are using the Composition API so: ref and reactive become the state, computed variables become getters and methods become actions.

Here is a simple image showcasing Options API store vs Composition API Store:

options vs comp api

Using the Pinia store in a component

Back in App.vue we can import it like this:



<script>
import { useProductStore } from './stores/ProductStore';

const productStore = useProductStore();
</script>

<template>
..
</template>


Enter fullscreen mode Exit fullscreen mode

As you can see we import it and then just initialize it. In order to verify that it’s working, we can open our app in Chrome and open Vue Devtools. There should be a new tab for Pinia, where you will be able to see your stores and other relevant data.

devtools

What’s more we if we go to the Timeline tab and then to Pinia, we can monitor different actions and mutations happening throughout our app:

timeline

Creating the Cart Store

Going back to the stores folder, let’s create CartStore.ts:



import { defineStore } from 'pinia'
import { computed, ref, Ref } from 'vue';
import { Product } from '../types/Product';

export const useCartStore = defineStore('cart', () => {
    const items: Ref<Product[]> = ref([]); // state

    const itemsCount = computed(() => items.value.length); // getter

    const groupedItems = computed(() => {
         return items.value.reduce((acc, item) => {
            if (!acc[item.name]) {
                acc[item.name] = [];
            }
            acc[item.name].push(item);
            return acc;
        }, {} as Record<string, Product[]>);
    }); // getter

    const addItem = (item: Product) => {
        items.value.push({...item});
    } // action

    const removeItem = (item: Product) => {
        const index = items.value.findIndex(i => i.name === item.name);
        items.value.splice(index, 1);
    } // action

    const $reset = () => {
        items.value = [];
    } // action $reset

    return { items, itemsCount, groupedItems, addItem, removeItem, $reset}
});


Enter fullscreen mode Exit fullscreen mode

In this store we keep an array which will hold the products. We have getters for the total count of all products as well as grouping them by their name. This is used to later get the quantity of each product. We also have some actions for adding and removing a product as well as a reset method that will reset the state of the store.

Adding functionality for Cart

Our App.vue should look like this:



<script setup lang="ts">
import { ref } from 'vue';
import Card from './components/Card.vue';
import Cart from './components/Cart.vue';
import NavBar from './components/NavBar.vue';
import { useProductStore } from './stores/ProductStore';
import { useCartStore } from './stores/CartStore';

const showCart = ref(false);

const productStore = useProductStore();
const cartStore = useCartStore();

</script>

<template>
  <div class="relative max-w-6xl mx-auto">
    <!-- NavBar contains the toggle for the cart -->
    <NavBar :show-cart="showCart" @toggleCart="showCart = !showCart"/>
    <!-- Cart Dropdown -->
    <Cart v-if="showCart"/>
    <main class="flex flex-1">
      <!-- Product Card -->
      <Card v-for="product in productStore.products" :key="product.name" :product="product" @add-to-cart="cartStore.addItem"/>
    </main>
  </div>
</template>

<style scoped>
</style>


Enter fullscreen mode Exit fullscreen mode
  1. NavBar - the NavBar contains the shopping cart icon. The NavBar emits an event toggleCart and that is used to toggle the state of showCart which itself toggles the Cart dropdown.
  2. Cart - which is the Cart Dropdown, where the added products will be displayed. More on that in a moment.
  3. Product Card - these are our products, we use the productStore to loop over all the products and display them. The Card also emits an event called addToCart. We then use the cartStore to call the addItem action and add the product to the state.

This is how NavBar.vue shoud look like:



<template>
    <nav class="border border-gray-300 rounded-xl rounded-t-none p-4 mb-10">
      <div class="container flex items-center justify-between">
        <h2 class="font-bold text-2xl w-1/3">Pinia Cart Tutorial</h2>
        <div class="w-1/3">
          <img class="w-12" src="/pinia.png" alt="">
        </div>
        <button @click="$emit('toggleCart')" class="relative hover:bg-gray-200 rounded-full p-2">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
          <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
          </svg>
          <div class="bg-red-500 rounded-full px-2 absolute -top-2 -right-2">{{ itemsCount }}</div>
        </button>
      </div>
    </nav>
</template>

<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '../stores/CartStore';

defineProps({
    showCart: {
        type: Boolean,
        default: false
    },
})

const {itemsCount} = storeToRefs(useCartStore());
</script>


Enter fullscreen mode Exit fullscreen mode
  1. We emit an event “toggleCart”
  2. We have a bubble notifying us how many products are in the cart
  3. We import the useCartStore, however when we want only one or two variables, we can destructure our store using the storeToRefs helper. It will unsure that reactivity is preserved when destructuring our store.

Lastly, this is how Cart.vue looks like:



<template>
    <div class="absolute top-20 right-0 w-1/3 p-4 border rounded-lg bg-white border-gray-300">
          <h1 class="text-xl font-bold">My Cart</h1>
          <ul>
            <li v-for="[name, items] in Object.entries(cartStore.groupedItems)" :key="name">
              <div class="flex items-center justify-between py-2">
                <span>{{name}}</span>
                <div class="flex items-center">
                  <button @click="cartStore.removeItem(items[0])" class="hover:bg-gray-200 rounded-full p-2">
                  -
                </button>
                <span class="mx-2">{{ items.length }}</span>
                <button @click="cartStore.addItem(items[0])" class="hover:bg-gray-200 rounded-full p-2">
                  +
                </button>
                </div>
                <span>${{items.map(i => i.price).reduce((totalItemPrice, price) => totalItemPrice + price, 0)}}</span>
              </div>
            </li>
          </ul>
          <hr class="my-2">
          <div class="flex items-center justify-between">
            <span class="font-bold">Total</span>
            <span class="font-bold">${{ cartStore.items.map(p => p.price).reduce((acc,curr) => acc + curr, 0) }}</span>
          </div>
        </div>  
</template>

<script setup lang="ts">
import { useCartStore } from '../stores/CartStore';

const cartStore = useCartStore();
</script>


Enter fullscreen mode Exit fullscreen mode
  1. We import the useCartStore and we use it to get access to items,groupedItems, removeItem and addItem.
  2. We loop over our groupedItems and display the name, then on removing or adding we use the item at index 0, so the item that is first in the array.
  3. We also calculate the total price per product by using the .reduce method to sum up the individual costs.
  4. Lastly we also calculate the total price for all products, again using the .reduce method

cart

Conclusion

We are now done with our application. You can go on and try adding items to cart. Changing the quantity to 100, or changing it 0 and it disappearing from the cart.

I hope you liked this crash course about Pinia and managed to use it in our example app. If you have any questions don’t hesitate to reach out.

Useful Resources

You can always refer to the official documentation, it is really clear and helpful.

💚  If you want to learn more about Vue and the Vue ecosystem make sure to follow me on my socials. I create Vue content every week and am slowly starting to gain traction so I’d really appreciate your help!

Twitter

LinkedIn

YouTube

Top comments (2)

Collapse
 
adamabundis profile image
Adam Abundis

Great article. I am jumping back into Vue recently. So this crash course in Pinia is most helpful. Looking forward to learning more from your articles and videos.

Collapse
 
kcko profile image
Kcko

Nice article, but a few oddities:

1) Why is there a method in useStore for the total price of all products if it is not used anywhere?

2) Why on the other hand is there no computed method in useCart for the total price in the cart and it is calculated in the template via reduce? (ugly) and also the method on price per product * number of units?