Pinia Beginner Tutorial

In Vue3, state management is an unavoidable topic.

Pinia is a lightweight state management library for Vue.js that allows you to share and manage state between components. We can think of Pinia as a global data warehouse from which all components can fetch or update data.

In this chapter, we introduce Pinia, the officially recommended state management library for Vue3. Compared to Vuex, Pinia provides a simpler state management solution that aligns better with the Vue3 Composition API mindset.

The following diagram compares the structural differences between the two:

  • Vuex uses a single store with multi-level modules in a tree structure, with fixed hierarchy, relies on mutations, and is heavier overall.
  • Pinia, in contrast, consists of multiple independent stores; it is flat, lightweight, has no module separation, and no namespace overhead.

Core features of Pinia:

  • Supports Vue2 and Vue3
  • Minimalist API design
  • Full TypeScript support
  • Supports Composition API
  • Modular design without nested modules

Pinia's flat structure allows components to connect directly to any store and take what they need, without going through tree modules or namespace paths. State flow is simple and direct, with low coupling.


Installation and Configuration

Create a project with Vite:

npm create vite@latest vue-pinia-demo --template vue
cd vue-pinia-demo
npm install

Install Pinia

First, install Pinia in your Vue3 project:

npm install pinia
# 或者
yarn add pinia

Pinia has three parts:

  • state: stores data
  • getters: computes derived data
  • actions: executes logic, modifies state

Configure Pinia

Configure Pinia in src/main.js or src/main.ts:

Example

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// Create the Pinia instance
const pinia = createPinia()
// Create the Vue app
const app = createApp(App)

// Use Pinia
app.use(pinia)
app.mount('#app')

Create Your First Store

A store is the data warehouse in Pinia. Let's create a simple counter store.

Define a Store

Create a new file src/stores/useCounter.js:

Example

// stores/useCounter.js
import { defineStore } from 'pinia'

// Define a store using defineStore
// The first parameter is the unique ID of the store
// The second parameter is the configuration options for the store
export const useCounterStore = defineStore('counter', {
  // state: defines the store's state data
  state: () => ({
    count: 0,
    name: 'My Counter'
  }),

  // getters: defines computed properties based on state
  getters: {
    doubleCount: (state) => state.count * 2,
    // Use this to access other getters
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // actions: defines methods to modify state
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    // Can accept parameters
    incrementBy(amount) {
      this.count += amount
    },
    // Async action
    async incrementAsync() {
      // Simulate async operation
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})

Store Structure Explanation

Let's understand the various parts of a store through a table:

Part Purpose Example
state Defines the stored data count: 0
getters Computed properties based on state doubleCount: state => state.count * 2
actions Methods to modify state increment() { this.count++ }

Using the Store in Components

Basic Usage

The component code for src/components/CounterComponent.vue is as follows:

Example

<!-- CounterComponent.vue -->
<template>
  <div class="counter">
    <h3>{{ store.name }}</h3>
    <p>Current count: {{ store.count }}</p>
    <p>Double count: {{ store.doubleCount }}</p>
    <p>Double plus one: {{ store.doubleCountPlusOne }}</p>
   
    <button @click="store.increment()">+1</button>
    <button @click="store.decrement()">-1</button>
    <button @click="store.incrementBy(5)">+5</button>
    <button @click="store.incrementAsync()">Async +1</button>
   
    <button @click="reset">Reset</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '../stores/useCounter'

// Use the store in setup
const store = useCounterStore()

// Method to reset state
function reset() {
store.$reset() // The $reset method can reset the state to its initial value
}
</script>

Modify src/App.vue as follows:

Example

<script setup>
import CounterComponent from './components/CounterComponent.vue'
</script>

<template>
  <CounterComponent />
</template>

The entire project structure:

Runnpm run devcommand, and visit in the browserhttp://localhost:5173/, and view the effect:

Reactive Destructuring

If you want to directly use state properties in the template, you can usestoreToRefs:

Example

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Name: {{ name }}</p>
  </div>
</template>

<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()

// Use storeToRefs to keep reactivity
const { count, name } = storeToRefs(store)

// Note: destructuring directly will lose reactivity!
// Wrong way: const { count, name } = store
</script>

Composition API Style Store

Pinia also supports defining stores using the Composition API style:

Example

// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // state
  const user = ref(null)
  const isLoggedIn = ref(false)

  // getters
  const userName = computed(() => user.value?.name || 'Guest')
  const userAge = computed(() => user.value?.age || 0)

  // actions
  function login(userData) {
    user.value = userData
    isLoggedIn.value = true
  }

  function logout() {
    user.value = null
    isLoggedIn.value = false
  }

  return {
    user,
    isLoggedIn,
    userName,
    userAge,
    login,
    logout
  }
})

Interaction Between Stores

Multiple stores can call each other:

Example

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),

  actions: {
    addItem(product) {
      this.items.push(product)
     
      // Call an action from another store
      const userStore = useUserStore()
      if (userStore.isLoggedIn) {
        // Sync to the user's shopping cart
        this.syncToUserCart()
      }
    }
  }
})

Practical Example: Shopping Cart

Let's create a complete shopping cart example:

Example

// stores/products.js
export const useProductStore = defineStore('products', {
  state: () => ({
    products: [
      { id: 1, name: 'Laptop', price: 5999, stock: 10 },
      { id: 2, name: 'Smartphone', price: 3999, stock: 20 },
      { id: 3, name: 'Wireless Earphones', price: 299, stock: 50 }
    ]
  }),

  getters: {
    // Get product by ID
    getProductById: (state) => (id) => {
      return state.products.find(product => product.id === id)
    },
    // Get products in stock
    availableProducts: (state) => {
      return state.products.filter(product => product.stock > 0)
    }
  }
})

Example

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [], // { productId, quantity }
    discount: 0
  }),

  getters: {
    // Total number of items in the cart
    totalItems: (state) => {
      return state.items.reduce((total, item) => total + item.quantity, 0)
    },
    // Total amount of the cart
    totalPrice: (state) => {
      const productStore = useProductStore()
      return state.items.reduce((total, item) => {
        const product = productStore.getProductById(item.productId)
        return total + (product?.price || 0) * item.quantity
      }, 0)
    },
    // Total price after discount
    finalPrice: (state) => {
      return state.totalPrice * (1 - state.discount / 100)
    }
  },

  actions: {
    // Add product to cart
    addToCart(productId, quantity = 1) {
      const existingItem = this.items.find(item => item.productId === productId)
     
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({ productId, quantity })
      }
    },

    // Remove product from cart
    removeFromCart(productId) {
      this.items = this.items.filter(item => item.productId !== productId)
    },

    // Clear the cart
    clearCart() {
      this.items = []
      this.discount = 0
    },

    // Set discount
    setDiscount(percent) {
      this.discount = Math.max(0, Math.min(100, percent))
    }
  }
})

Using the Shopping Cart in a Component

Example

<!-- ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <h3>Shopping Cart ({{ cart.totalItems }} items)</h3>
   
    <div v-if="cart.items.length === 0">
      <p>The cart is empty</p>
    </div>
   
    <div v-else>
      <div v-for="item in cartItems" :key="item.product.id" class="cart-item">
        <span>{{ item.product.name }}</span>
        <span>¥{{ item.product.price }}</span>
        <span>Quantity: {{ item.quantity }}</span>
        <span>Subtotal: ¥{{ item.product.price * item.quantity }}</span>
        <button @click="cart.removeFromCart(item.product.id)">Delete</button>
      </div>
     
      <div class="cart-summary">
        <p>Total: ¥{{ cart.totalPrice }}</p>
        <p v-if="cart.discount >0">Discount: {{ cart.discount }}%</p>
        <p>Amount Paid: ¥{{ cart.finalPrice }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useProductStore } from '@/stores/products'

const cart = useCartStore()
const products = useProductStore()

// Calculate shopping cart item details
const cartItems = computed(() => {
  return cart.items.map(item => ({
    product: products.getProductById(item.productId),
    quantity: item.quantity
})).filter(item => item.product) // Filter out products that don't exist
})
</script>

Best Practices and Considerations

1. Store Naming Conventions

Example

// Good naming
export const useUserStore = defineStore('user', { /* ... */ })
export const useProductStore = defineStore('products', { /* ... */ })

// Naming to avoid
export const userStore = defineStore('user', { /* ... */ }) // Missing the use prefix

2. State Initialization

Example

// Recommended: use a function to return the initial state
state: () => ({
  items: [],
  loading: false,
  error: null
})

// Not recommended: directly using an object
state: {
  items: []  // This will cause all instances to share the same array!
}

3. Handling Async Operations

Example

actions: {
  async fetchUserData(userId) {
    this.loading = true
    this.error = null
   
    try {
      const response = await api.getUser(userId)
      this.user = response.data
    } catch (error) {
      this.error = error.message
    } finally {
      this.loading = false
    }
  }
}

4. Data Persistence

For data that needs to be persisted, you can use a plugin:

npm install pinia-plugin-persistedstate

Example

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {}
  }),
 
  // Enable persistence
  persist: true
})

Pinia API

Category API Description
Creating defineStore Define an independent store, containing state / getters / actions
Instantiation createPinia Create a Pinia root instance for app.use()
Component usage useStore()(User-defined, e.g.useUserStore) Call a store, returns reactive state, getters, and actions
State state Returns a function object, used to define mutable global state
State operations store.$state Directly read/write the entire state (object-level)
State operations store.$patch() Batch modify state, supports both object and function modes
State replacement store.$reset() Reset to the initial state, only available when defined using the setup method
State subscription store.$subscribe() Listen to state changes, suitable for local persistence
Action invocation store.$onAction() Listen before and after action calls, can be used for logging and tracking
Getter getters Derived data, automatically cached based on state
Plugins pinia.use() Register plugins to extend store capabilities
Definition method defineStore(id, options) Options API syntax
Definition method defineStore(id, () => {...}) Setup syntax, returns state / getter / action
Store properties store.$id Unique identifier of the current store
Store properties store.$ready(Some versions) Status flag after store initialization is complete
Persistence (plugin) persist Enable storage plugins (such as localStorage)
Utility functions storeToRefs() Convert state / getters to refs, preserving reactivity

For more, refer to the Pinia official website:https://pinia.vuejs.org/

Other extensions