One of the most crucial principles in web development is state management. Up until the introduction of Pinia, Vuex served as the de facto state management solution for Vue applications.
In this tutorial, we’ll investigate the key aspects of Pinia by learning how to create and use data stores.
Pinia
Pinia is a store library for Vue.js applications that allows you to share state across components or pages.
Pinia vs Vuex
Pinia can be compared to Vuex 5, but there are several key distinctions between the two that you should be aware of :
- The Composition API and the Options API are both used by Pinia.
- Pinia can be used by Vue 2 and Vue 3 applications, both with devtools support.
- Mutations are eliminated in Pinia due to their excessive verbosity.
- Pinia has proper TypeScript support.
- Pinia offers server-side rendering support.
The benefits of using Pinia
Support for Server-Side Rendering
If you are familiar with the Composition API, you would believe that a straightforward "export const state = reactive({})" already allows you to share a global state. While this is true for single-page applications, server-side rendering exposes your application to security flaws.
You won't need to be concerned about security flaws while using Pinia because it is compatible with both single-page applications and server-side rendered applications.
Hot Module Replacement
Pinia preserves any current state while developing and lets you edit your stores without refreshing your page. This enhances developer experience and makes it simple and quick to construct stores.
Devtools support
The devtool included with Pinia allows you to easily debug problems, track actions and mutations, and know where stores are being used in components.
TypeScript support
Pinia enables autocompletion for JavaScript code and fully supports TypeScript.
Plugins
By using Pinia, you have the option to freely add plugins to increase its functionality.
Getting started with Pinia
To give an idea of Pinia's features, we will be building a simple shopping cart with the following features :
- A list of all products
- Add to cart functionality
- Increase item quantity functionality
- Total product price functionality
Here is the complete project demo - pinia-article-demo-dk8o.vercel.app
Before you get too excited, let's first create a new Vue 3 project by running the following command in our terminal:
npm init vue@latest
This script will set up a new project with Vue 3 and Vite by installing and running create-vue
, the official Vue project scaffolding tool.
During the process, you will be asked to give the name of your project and select the tools needed for the project.
Give your project the name Pinia-Shopping-Cart-Demo
and choose any tool that is designated with the keyword Yes
. The tools are Pinia and Vue-Router.
Navigate into the project and run the following commands in the terminal once the setup is completed to install the dependencies :
cd Pinia-Shopping-Cart-Demo && npm install
Run the following command in the terminal to open the project in the browser after installing the dependencies:
npm run dev
Your new Vue 3 application will be served at http://127.0.0.1:5173/
. Here is what you should see:
Styles
We'll be utilizing a CSS framework known as TailwindCSS for the project's styling.
To install TailwindCSS in our project, we are going to run the following command in our terminal:
npm install -D tailwindcss postcss autoprefixer
We are then going to run the
npx tailwindcss init -p
command in our terminal to generate both the tailwind.config.cjs
and postcss.config.cjs
file after installing tailwind css and its peer dependencies via npm.
We will provide the paths to each of our template files in the tailwind.config.cjs
file after the files have been generated. This is how your file should look like:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
In your assets
directory, remove the following files:
- base.css
- logo.svg
- main.css
and add the following directory and file respectively: css
and index.css
.
In your index.css
file, add the @tailwind
directives for each of Tailwind's layers. The layers include:
@tailwind base
@tailwind components
@tailwind utilities
This is how your index.css
file should look like:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.flex-center {
@apply flex justify-center items-center
}
.flex-around {
@apply flex justify-around items-center
}
.flex-between {
@apply flex justify-between items-center
}
.grid-center {
@apply grid place-items-center
}
}
Navigate to your main.js
file and add your css path as shown below
Remove the default css path and add your new css path as shown above.
The new css path in the image shown above is: import "./assets/css/index.css";
Project Structure
We will now tidy up the project's default structure in order to customize it to meet our needs. Here is how it should appear:
My GitHub repository has the Pinia SVG that we'll be using. Don't worry, at the end of this article I will include a link to my GitHub repository.
To make our project vibrant, we are going to be installing some packages.
Run the following command in your terminal:
npm i axios vue-toastification@next @heroicons/vue @vueuse/core
The use cases for the installed packages are listed below:
Axios - A promise based HTTP client for the browser that enables us to fetch data from an external API.
Vue-toastification - A lightweight and configurable notification library for displaying important messages.
Heroicons - A set of free open source SVG icon library.
Vueuse - A collection of Vue composition utilities.
Navigate to your main.js
file after the packages have been installed and import the Toast
and POSITION
from vue-toastification. On the same breathe, we are going to import the css styles for the toasts.
We are then going to include the Toast
in our project. This is how your main.js
file should look like:
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import Toast, { POSITION } from "vue-toastification";
import "vue-toastification/dist/index.css";
import "./assets/css/index.css";
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(Toast, {
position: POSITION.TOP_CENTER
});
app.mount('#app')
Initialize Pinia instance
Let's go ahead and see how Pinia root store is created and included in our project in our main.js
file:
As you can see in the image above, we have imported the createPinia()
function from pinia
to create a new Pinia instance.
Finally, we provide it to Vue using app.use(createPinia())
.
Defining a store
The Pinia API is extremely simplified.
To define a store, we use the defineStore
function. Here, the word define
is used instead of create
because a store is not created until it is used in a component or page.
The store name must start with the use
keyword because it is a convention across composables. Each store must provide a unique id
to mount the store to devtools.
Pinia has 3 concepts which include :
state
- A function to define the central state. It is equivalent todata
in components.getters
- Getters receive the state as the first parameter to encourage the usage of arrow function. They are equivalent tocomputed values
in components.actions
- Inactions
is where we define our business logic. Unlike getters, actions can be asynchronous, you can await any API call inside of actions or even other actions! Actions are equivalent tomethods
in components.
Store structure
Navigate to your store and paste the following code:
// productStore.js
import { defineStore, acceptHMRUpdate } from "pinia";
import axios from "axios";
import { useStorage } from "@vueuse/core"
export const useProductStore = defineStore({
id: "store",
state: () => ({
products: [],
cart: useStorage("cart", []),
loading: false,
error: "",
}),
getters: {
cartQuantity: (state) => {
return state.cart?.length;
},
itemQuantity: (state) => (product) => {
const item = state.cart.find((item) => item.id === product.id);
return item?.quantity;
},
productTotal: (state) => {
return state.cart.reduce((val, item) => val + item.quantity * item.price, 0)
}
},
actions: {
async fetchProducts() {
try {
this.loading = true;
const response = await axios.get("https://fakestoreapi.com/products");
this.loading = false;
response.data.map((product) => {
this.products.push(product);
});
} catch (err) {
this.error = err.message;
}
},
addToCart(product) {
const item = this.cart.find((item) => item.id === product.id);
if (item) {
item.quantity++;
} else {
this.cart.push({ ...product, quantity: 1 });
};
},
removeFromCart(product) {
const item = this.cart.find((item) => item.id === product.id);
if (item) {
if (item.quantity > 1) {
item.quantity--;
} else {
this.cart = this.cart.filter((item) => item.id !== product.id);
}
};
}
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useProductStore, import.meta.hot));
}
For our small project, we will use the fake store api as a data source.
First, we define our state
with four properties:
products - For holding the fetched products
loading - For holding the loading state
cart - For holding the selected products
error - For holding the error message
You may have noticed that we have imported a useStorage
composable from @vueuse/core
. This composable will help us persist the products in the cart in localStorage.
Second, we create a getter. By default, a getter takes the state
as an argument and uses it to get access to the cart
array.
So, we define our getter function with three functions:
cartQuantity - To get how many products have been added to the cart.
itemQuantity - To get the quantity of a particular product in the cart.
productTotal - To get the total price of the products in the cart.
Third, we create an asynchronous
operation to fetch all the products. In fetchProducts()
actions, we first create a try/catch block and set loading
to true
. We then fetch the products by using axios
, meaning we need to import axios
from "axios"
and the products' resource from fake store api.
If there is an error, we assign the error to the error property and finally set the loading
back to false
.
In our actions, we have two other functions apart from the asynchronous function which will help us add and remove a product to and from the cart respectively.
These two functions are:
addToCart - The
addToCart
function takes aproduct
as a parameter. Next, we check whether that specific item is already in the cart. Finally, we create an if/else statement to increase the product's quantity if the product already exists and add the product to the cart if the product doesn't exist.removeFromCart - The
removeFromCart
function takes aproduct
as a parameter. Next, we check whether that specific item is already in the cart. We then create an if/else statement to check if the product's quantity is greater than one. If indeed the product's quantity is more than one, we reduce it by one. If not, we take the product out of the cart.
Creating Views and Components
In this section, we will create the necessary views and components to apply the Pinia store we just created.
Creating the necessary components
In the components
directory, create the following files:
Loader.vue
Navbar.vue
Product_Cards.vue
Loader Component
In your Loader.vue
file, paste the following code:
<script setup>
</script>
<template>
<main class="flex-center flex-wrap">
<div class="px-4 border border-gray-200 rounded-sm w-72 m-2 md:w-80 md:m-10">
<div class="grid-center pt-2">
<div class="w-full h-48 bg-gray-200"></div>
</div>
<div class="space-y-4">
<div>
<h4 class="bg-gray-200 mt-4 py-4 px-14 rounded-md animate-pulse"></h4>
</div>
<div>
<h4 class="bg-gray-200 mb-2 h-8 w-16 rounded-md animate-pulse"></h4>
</div>
<div>
<button class="bg-gray-200 mb-2 py-4 px-14 rounded-md animate-pulse"></button>
</div>
</div>
</div>
<div class="px-4 border border-gray-200 rounded-sm w-72 m-2 md:w-80 md:m-10">
<div class="grid-center pt-2">
<div class="w-full h-48 bg-gray-200"></div>
</div>
<div class="space-y-4">
<div>
<h4 class="bg-gray-200 mt-4 py-4 px-14 rounded-md animate-pulse"></h4>
</div>
<div>
<h4 class="bg-gray-200 mb-2 h-8 w-16 rounded-md animate-pulse"></h4>
</div>
<div>
<button class="bg-gray-200 mb-2 py-4 px-14 rounded-md animate-pulse"></button>
</div>
</div>
</div>
<div class="px-4 border border-gray-200 rounded-sm w-72 md:w-80 m-2 md:m-10">
<div class="grid-center pt-2">
<div class="w-full h-48 bg-gray-200"></div>
</div>
<div class="space-y-4">
<div>
<h4 class="bg-gray-200 mt-4 py-4 px-14 rounded-md animate-pulse"></h4>
</div>
<div>
<h4 class="bg-gray-200 mb-2 h-8 w-16 rounded-md animate-pulse"></h4>
</div>
<div>
<button class="bg-gray-200 mb-2 py-4 px-14 rounded-md animate-pulse"></button>
</div>
</div>
</div>
</main>
</template>
As the name suggests, the Loader component will be displayed if the loading
is true.
Navbar component
Navigate to your Navbar.vue
file and paste the following code:
<script setup>
import Pinia_Logo from "../assets/svg/Pinia.svg";
import { ShoppingCartIcon } from '@heroicons/vue/24/solid';
import { useProductStore } from "../stores/productStore";
const productStore = useProductStore();
</script>
<template>
<div>
<header>
<nav class="w-full h-14 flex-between p-2 bg-[#eee]">
<div class="flex-center space-x-1">
<img :src="Pinia_Logo" alt="Pinia" class="w-10 h-10">
<h1 class="font-bold text-base md:text-2xl">
<router-link to="/">Store</router-link>
</h1>
</div>
<div>
<router-link to="/cart">
<div class="w-5.5 h-5.2 rounded-full ml-1 text-center bg-red-500 md:w-6 md:h-6">
<span class="text-white text-sm">{{ productStore.cartQuantity }}</span>
</div>
<ShoppingCartIcon class="w-6 h-6 md:w-8 md:h-8" />
</router-link>
</div>
</nav>
</header>
</div>
</template>
In our Navbar component we have a Pinia logo on the left side and a shopping cart icon on the right side. We have imported the shopping cart from @heroicons/vue
. We have also imported our store and instantiated it using the productStore
variable.
We now have access to any property defined in the state
, getters
and actions
directly on the store.
In our Navbar component, we are utilizing our store to get the number of products in the cart.
Product_Cards component
Navigate to your Product_Cards.vue
file and paste the following code:
<script setup>
import { useProductStore } from '../stores/productStore';
import { ref, watchEffect } from "vue";
import Loader from "./Loader.vue";
import { useToast } from "vue-toastification";
const toast = useToast();
const productStore = useProductStore();
const products = ref([]);
const loading = ref(null);
const error = ref("");
watchEffect(() => {
productStore.fetchProducts()
})
watchEffect(() => {
loading.value = productStore.loading;
products.value = productStore.products;
})
watchEffect(() => {
error.value = productStore.error;
});
const handleAddToCart = (product) => {
productStore.addToCart(product);
toast.success("Product added to cart successfully", {
timeout: 3000
});
}
</script>
<template>
<main class="flex-center flex-wrap">
<div v-if="error" class="mt-48">
<p class="text-red-500 font-bold text-base md:text-lg">{{ error }}</p>
</div>
<div v-if="loading && !error">
<Loader />
</div>
<div v-else v-for="product in products" :key="product.id"
class="px-4 border border-gray-200 rounded-sm w-80 m-10">
<div class="grid-center">
<img :src="product.image" :alt="product.title" class="w-48 h-48 md:w-64 md:h-64">
</div>
<div class="space-y-4">
<div class="pt-2">
<h1 class="font-bold line-clamp-1">{{ product.title }}</h1>
</div>
<div>
<h4 class="font-bold">Price: ${{ product.price }}</h4>
</div>
<div>
<button @click="handleAddToCart(product)"
class="bg-black hover:bg-white hover:text-black mb-2 transition duration-200 ease-in border border-black text-white py-1.5 px-2 rounded-md">Add
To Cart</button>
</div>
</div>
</div>
</main>
</template>
In our Product_Cards component, we have imported:
useProductStore
ref - To create reactive variables
watchEffect - To run our functions immediately while reactively tracking its dependencies.
useToast - To display important messages.
Loader component
We call fetchProducts
inside the watchEffect to fetch the products. Then, we create a function called handleAddToCart
that accepts a product as a parameter. We then create a toast with a message indicating that the product has been successfully added to the cart after passing the parameter to the addToCart
method.
The template also has a number of "v-if" directives. First, we show the error
message if there is any. In the event that loading is true
and no error message is present, we then display the "Loader" component.
Finally, we iterate through the products and display the image, title and price of the product. We then create an event which when clicked will add the product to the cart. In our scenario, we have created an event which has a property known as handleAddToCart
which takes in a product.
Routing
Now, let's modify the index.js
file. Open it and replace its content with the following:
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/cart',
name: 'cart',
component: () => import('../views/Cart.vue')
}
]
})
export default router
Here, we import the Home.vue
and Cart.vue
files and use them as components. You may have noticed that I have used a different syntax when defining the Cart
route.
Before you start scratching your head, the method used here is known as route level code-splitting. This method generates a separate chunk for this route which is lazy loaded when the route is visited. The purpose of lazy loading is to reduce initial load time.
Creating our Views
In your views directory, create:
Home.vue
file and paste the following code:
<script setup>
import Product_Cards from '../components/Product_Cards.vue';
</script>
<template>
<main>
<Product_Cards/>
</main>
</template>
Here, we have imported our Product_Cards
component and used it in the template.
Cart.vue
file and paste the following code:
<script setup>
import { ref, watchEffect } from 'vue';
import { useProductStore } from '../stores/productStore';
const productStore = useProductStore();
const cartProducts = ref([]);
watchEffect(() => {
cartProducts.value = productStore.cart;
})
</script>
<template>
<div class="overflow-y-auto h-96 py-2 px-4">
<div v-if="productStore.cartQuantity === 0" class="mt-32 grid place-items-center">
<h2 class="font-bold text-2xl">Your Cart Is Empty</h2>
</div>
<div v-else v-for="product in cartProducts" :key="product.id"
class="max-h-96 py-2 space-y-2 bg-gray-100 border border-gray-200 rounded-md w-full grid place-items-center px-6 my-4 md:px-4 md:h-32 md:flex md:justify-between md:items-center md:space-y-0">
<div>
<img :src="product.image" :alt="product.title" class="w-28 h-28 rounded-md">
</div>
<div>
<p class="line-clamp-1">{{ product.title }}</p>
</div>
<div>
<p class="font-bold">Price: ${{ product.price }}</p>
</div>
<div>
<span>Quantity: {{ productStore.itemQuantity(product) }}</span>
</div>
<div class="flex items-center space-x-2">
<button @click="productStore.removeFromCart(product)"
class="py-1 rounded-md px-2 bg-yellow-500 hover:bg-yellow-600">-</button>
<p>{{ productStore.itemQuantity(product) }}</p>
<button @click="productStore.addToCart(product)"
class="py-1 rounded-md px-2 bg-yellow-500 hover:bg-yellow-600">+</button>
</div>
</div>
</div>
<div v-if="productStore.cartQuantity > 0" class="mt-10 w-full grid place-items-center bg-white absolute z-5 md:mt-16">
<p class="font-bold text-2xl">Total: ${{ productStore.productTotal.toFixed(2) }}</p>
</div>
</template>
Here, we have imported:
ref - To create a reactive variable which will be assigned products from the store
watchEffect - To track reactive dependencies.
useProductStore
The template also has a number of "v-if" directives. The first statement checks whether there are any products in the cart. If there are no products in the cart, a message is displayed. If the cart contains products, we iterate through the products in the cart and display the image, title and price of the product. On the same breathe, we are able to increase, decrease and view the quantity of a particular product.
Finally, we display the total price of all the products in the cart.
App File
Finally, we navigate to our App.vue
file and paste the following code:
<script setup>
import Navbar from './components/Navbar.vue';
</script>
<template>
<div>
<Navbar/>
<router-view/>
</div>
</template>
Here, we have imported our Navbar component and placed it above the router-view component so that our Navbar component can be visible in different routes.
Lastly, run npm run dev
in the terminal to view your awesome shopping cart project.
You may have encountered an error that asks if a specific file exists. Don't worry, just head down to the end of this great article and download the pinia svg logo from my Github repository. Then, create a directory known as svg
in your assets directory and then add the pinia svg logo.
Conclusion
The new Vue state management feature has really had me pumped. As we just saw, it is simple, modular, powerful and easy to use. Creating stores with Pinia is really enjoyable.
To understand Pinia even better, you can create real-world projects for practice purposes. Don't forget to check out the Pinia docs to learn even more advanced ways of using it.
Thank you for taking your time to read this great article and happy coding.
As I promised, here is my Github repo link - github.com/selemondev/Pinia-Article-Demo