Guide·

Getting Started with Pinia

Master state management in Vue 3 with Pinia, the intuitive and type-safe store library.
Getting Started with Pinia
Pinia is the official state management library for Vue 3. It provides a simple, type-safe API with full TypeScript support, devtools integration, and a modular architecture that makes managing application state intuitive and scalable.

Why Pinia?

TypeScript First

Full type inference with autocompletion for state, getters, and actions

Vue Devtools

Time-travel debugging, state inspection, and action tracking built-in

Modular Design

Create multiple stores that can import and use each other

Installation

With Nuxt

Pinia is built into Nuxt 3. Just start using it:

// stores/counter.ts
export const useCounterStore = defineStore("counter", {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
  },
})

For explicit installation:

npm install pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@pinia/nuxt"],
})

With Vue + Vite

npm install pinia
// main.ts
import { createApp } from "vue"
import { createPinia } from "pinia"
import App from "./App.vue"

const app = createApp(App)
app.use(createPinia())
app.mount("#app")

Defining Stores

Options API Style

The familiar options syntax:

// stores/user.ts
import { defineStore } from "pinia"

export const useUserStore = defineStore("user", {
  state: () => ({
    name: "",
    email: "",
    isLoggedIn: false,
  }),

  getters: {
    initials: (state) => {
      return state.name
        .split(" ")
        .map((n) => n[0])
        .join("")
    },
    displayName: (state) => {
      return state.isLoggedIn ? state.name : "Guest"
    },
  },

  actions: {
    async login(email: string, password: string) {
      const response = await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify({ email, password }),
      })
      const data = await response.json()
      this.name = data.name
      this.email = data.email
      this.isLoggedIn = true
    },

    logout() {
      this.name = ""
      this.email = ""
      this.isLoggedIn = false
    },
  },
})

Composition API Style (Setup Stores)

Use ref(), computed(), and functions:

// stores/cart.ts
import { defineStore } from "pinia"
import { ref, computed } from "vue"

export const useCartStore = defineStore("cart", () => {
  // State
  const items = ref<CartItem[]>([])

  // Getters
  const totalItems = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  const isEmpty = computed(() => items.value.length === 0)

  // Actions
  function addItem(product: Product) {
    const existing = items.value.find((item) => item.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(productId: string) {
    const index = items.value.findIndex((item) => item.id === productId)
    if (index > -1) {
      items.value.splice(index, 1)
    }
  }

  function clearCart() {
    items.value = []
  }

  return { items, totalItems, totalPrice, isEmpty, addItem, removeItem, clearCart }
})

Using Stores in Components

Basic Usage

<script setup>
  const counterStore = useCounterStore()
</script>

<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <button @click="counterStore.increment">Increment</button>
  </div>
</template>

Destructuring with storeToRefs

Use storeToRefs() to destructure while keeping reactivity:

<script setup>
  import { storeToRefs } from "pinia"

  const cartStore = useCartStore()
  // Reactive state and getters
  const { items, totalItems, totalPrice } = storeToRefs(cartStore)
  // Actions can be destructured directly
  const { addItem, removeItem } = cartStore
</script>

<template>
  <div>
    <p>{{ totalItems }} items - ${{ totalPrice.toFixed(2) }}</p>
    <ul>
      <li v-for="item in items" :key="item.id">
        {{ item.name }} x{{ item.quantity }}
        <button @click="removeItem(item.id)">Remove</button>
      </li>
    </ul>
  </div>
</template>

Modifying State

<script setup>
  const userStore = useUserStore()

  // Direct mutation
  function updateName(name: string) {
    userStore.name = name
  }

  // Using $patch for multiple changes
  function updateProfile(name: string, email: string) {
    userStore.$patch({
      name,
      email,
    })
  }

  // $patch with a function for complex updates
  function addNotification(notification: Notification) {
    userStore.$patch((state) => {
      state.notifications.push(notification)
      state.unreadCount++
    })
  }

  // Reset to initial state
  function resetUser() {
    userStore.$reset()
  }
</script>

Getters

Getters are computed properties for stores:

export const useProductStore = defineStore("products", {
  state: () => ({
    products: [] as Product[],
    searchQuery: "",
    selectedCategory: null as string | null,
  }),

  getters: {
    // Simple getter
    productCount: (state) => state.products.length,

    // Getter using another getter
    filteredProducts(): Product[] {
      let result = this.products
      if (this.selectedCategory) {
        result = result.filter((p) => p.category === this.selectedCategory)
      }
      if (this.searchQuery) {
        const query = this.searchQuery.toLowerCase()
        result = result.filter((p) => p.name.toLowerCase().includes(query))
      }
      return result
    },

    // Getter that returns a function (for arguments)
    getProductById: (state) => {
      return (id: string) => state.products.find((p) => p.id === id)
    },

    // Getter accessing other stores
    cartProductDetails(): ProductWithQuantity[] {
      const cartStore = useCartStore()
      return cartStore.items.map((item) => ({
        ...this.getProductById(item.id)!,
        quantity: item.quantity,
      }))
    },
  },
})

Async Actions

Handle API calls and async operations:

export const usePostStore = defineStore("posts", {
  state: () => ({
    posts: [] as Post[],
    loading: false,
    error: null as string | null,
  }),

  actions: {
    async fetchPosts() {
      this.loading = true
      this.error = null
      try {
        const response = await fetch("/api/posts")
        this.posts = await response.json()
      } catch (e) {
        this.error = "Failed to fetch posts"
      } finally {
        this.loading = false
      }
    },

    async createPost(data: CreatePostData) {
      const response = await fetch("/api/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      })
      const newPost = await response.json()
      this.posts.push(newPost)
      return newPost
    },

    async deletePost(id: string) {
      await fetch(`/api/posts/${id}`, { method: "DELETE" })
      const index = this.posts.findIndex((p) => p.id === id)
      if (index > -1) {
        this.posts.splice(index, 1)
      }
    },
  },
})

Store Composition

Stores can use other stores:

// stores/checkout.ts
export const useCheckoutStore = defineStore("checkout", () => {
  const cartStore = useCartStore()
  const userStore = useUserStore()

  const canCheckout = computed(() => !cartStore.isEmpty && userStore.isLoggedIn)

  const orderSummary = computed(() => ({
    items: cartStore.items,
    subtotal: cartStore.totalPrice,
    tax: cartStore.totalPrice * 0.1,
    total: cartStore.totalPrice * 1.1,
    shippingAddress: userStore.address,
  }))

  async function placeOrder() {
    if (!canCheckout.value) {
      throw new Error("Cannot checkout")
    }

    const response = await fetch("/api/orders", {
      method: "POST",
      body: JSON.stringify(orderSummary.value),
    })

    if (response.ok) {
      cartStore.clearCart()
    }

    return response.json()
  }

  return { canCheckout, orderSummary, placeOrder }
})

Subscribing to Changes

React to store changes:

const cartStore = useCartStore()

// Subscribe to state changes
cartStore.$subscribe((mutation, state) => {
  // Save to localStorage on every change
  localStorage.setItem("cart", JSON.stringify(state.items))
})

// Subscribe to actions
cartStore.$onAction(({ name, args, after, onError }) => {
  console.log(`Action ${name} called with`, args)

  after((result) => {
    console.log(`Action ${name} finished with`, result)
  })

  onError((error) => {
    console.error(`Action ${name} failed with`, error)
  })
})

Persisting State

Use the persist plugin to save state:

npm install pinia-plugin-persistedstate
// main.ts or plugins/pinia.ts
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/settings.ts
export const useSettingsStore = defineStore(
  "settings",
  () => {
    const theme = ref("light")
    const language = ref("en")

    return { theme, language }
  },
  {
    persist: true, // Saves to localStorage automatically
  }
)

Testing Stores

import { setActivePinia, createPinia } from "pinia"
import { useCounterStore } from "./counter"

describe("Counter Store", () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it("increments count", () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
    store.increment()
    expect(store.count).toBe(1)
  })

  it("computes double count", () => {
    const store = useCounterStore()
    store.count = 5
    expect(store.doubleCount).toBe(10)
  })
})

Key Features

Lightweight

Only ~1.5kb gzipped with no dependencies beyond Vue

Hot Module Replacement

Modify stores without reloading the page or losing state

SSR Support

Works seamlessly with server-side rendering in Nuxt

Explore the full Pinia documentation for advanced patterns, plugins, and best practices.
Enjoyed this post?
Subscribe to get notified when I publish new articles.

Need a Full Stack Engineer?

10+ years building performant web applications. Let's talk about your next project.