Everything you need to know about Pinia

Everything you need to know about Pinia

·

15 min read

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 :

  1. The Composition API and the Options API are both used by Pinia.
  2. Pinia can be used by Vue 2 and Vue 3 applications, both with devtools support.
  3. Mutations are eliminated in Pinia due to their excessive verbosity.
  4. Pinia has proper TypeScript support.
  5. 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 :

  1. A list of all products
  2. Add to cart functionality
  3. Increase item quantity functionality
  4. 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.

pinia-demo-terminal.png

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:

pinia-demo-homepage.png

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:

  1. base.css
  2. logo.svg
  3. 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:

  1. @tailwind base
  2. @tailwind components
  3. @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

pinia-demo-main.js.png

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:

pinia-demo-structure.png

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:

  1. Axios - A promise based HTTP client for the browser that enables us to fetch data from an external API.

  2. Vue-toastification - A lightweight and configurable notification library for displaying important messages.

  3. Heroicons - A set of free open source SVG icon library.

  4. 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:

pinia-demo-main.js-pinia.png

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 :

  1. state - A function to define the central state. It is equivalent to data in components.

  2. getters - Getters receive the state as the first parameter to encourage the usage of arrow function. They are equivalent to computed values in components.

  3. actions - In actions 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 to methods 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:

  1. products - For holding the fetched products

  2. loading - For holding the loading state

  3. cart - For holding the selected products

  4. 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:

  1. cartQuantity - To get how many products have been added to the cart.

  2. itemQuantity - To get the quantity of a particular product in the cart.

  3. 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:

  1. addToCart - The addToCart function takes a product 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.

  2. removeFromCart - The removeFromCart function takes a product 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:

  1. Loader.vue

  2. Navbar.vue

  3. 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:

  1. useProductStore

  2. ref - To create reactive variables

  3. watchEffect - To run our functions immediately while reactively tracking its dependencies.

  4. useToast - To display important messages.

  5. 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:

  1. ref - To create a reactive variable which will be assigned products from the store

  2. watchEffect - To track reactive dependencies.

  3. 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