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? (store, state, getters, actions)
- Creating a Shopping Cart Project with Pinia
- Setting up a Vue.js app with Vite
- Installing and Registering Pinia
- Creating a Pinia Store
- Using store in component and Pinia DevTools
- Creating the Cart Store
- Adding functionality for Cart
- Conclusion
- Useful resources
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.
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.
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.
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.
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
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
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')
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
}
})
- We start by importing the
defineStore
method frompinia
. We then use it to export a const calleduseProductStore
. It is a common convention to prefix stores and composables with the “use” word. -
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. - In our store, we are using the Composition API so:
ref
andreactive
become the state,computed
variables become getters and methods become actions.
Here is a simple image showcasing Options API store vs Composition API Store:
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>
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.
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:
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}
});
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>
- NavBar - the NavBar contains the shopping cart icon. The NavBar emits an event
toggleCart
and that is used to toggle the state ofshowCart
which itself toggles the Cart dropdown. - Cart - which is the Cart Dropdown, where the added products will be displayed. More on that in a moment.
- 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 calledaddToCart.
We then use thecartStore
to call theaddItem
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>
- We emit an event “toggleCart”
- We have a bubble notifying us how many products are in the cart
- We import the
useCartStore
, however when we want only one or two variables, we can destructure our store using thestoreToRefs
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>
- We import the
useCartStore
and we use it to get access toitems
,groupedItems
,removeItem
andaddItem
. - 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. - We also calculate the total price per product by using the
.reduce
method to sum up the individual costs. - Lastly we also calculate the total price for all products, again using the
.reduce
method
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!
Top comments (2)
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.
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?